@@ -1,25 +1,25 @@
// ==========================================
// Chat Switchboard – Panel Registry
// Chat Switchboard – Panel Registry (v0.22.0)
// ==========================================
// Independent panel system replacing the hardcoded two-tab side panel.
// Workspace-aware panel system. Panels are shown inside the
// workspace secondary pane (.workspace-secondary). The workspace
// owns the resize handle between primary and secondary.
//
// Each panel registers with name, DOM element, and lifecycle hooks.
// The registry owns all open/close/focus logic — individual panels
// never touch the <aside> container directly.
// never touch the secondary container directly.
//
// Supports single-panel and dual-view modes. In dual mode the panel
// body splits into primary (left) + divider + secondary (right) via
// CSS grid. The split ratio is drag-adjustable.
//
// See DESIGN-0.18.1.md for full spec.
// Layout model (v0.22.0):
// .workspace
// .workspace-primary ← main content (chat / notes / editor)
// .workspace-handle ← drag-resize between panes
// .workspace-secondary ← panel pages (preview / notes / project)
// ── Panel Registry ──────────────────────────
const PanelRegistry = {
_panels : { } , // { name: { element, label, onOpen, onClose, saveState, restoreState, actions, state } }
_active : null , // primary (left) panel name
_secondary : null , // secondary (right) panel name — null when not in dual mode
_dualMode : false ,
_splitRatio : 0.5 , // 0– 1, fraction of space allocated to primary
_active : null , // currently visible panel name
_order : [ ] , // registration order (for tab rendering + cycle)
/**
@@ -27,7 +27,7 @@ const PanelRegistry = {
* @param {string} name — unique identifier (e.g. 'preview', 'notes')
* @param {object} opts
* @param {HTMLElement} opts.element — the .side-panel-page div
* @param {string} opts.label — display name for tab button
* @param {string} opts.label — display name
* @param {Function} [opts.onOpen] — called when panel becomes visible
* @param {Function} [opts.onClose] — called when panel is hidden
* @param {Function} [opts.saveState] — returns state snapshot before hiding
@@ -56,12 +56,8 @@ const PanelRegistry = {
} ,
/**
* Open a panel (and the contai ner if cl osed).
*
* Single mode: hides current active, shows requested panel.
* Dual mode:
* - Already visible (active or secondary): swap it to primary.
* - Not visible: replaces secondary panel.
* Open a panel (and the se condary pa ne if collap sed).
* Hides current active panel, shows the requested one.
*/
open ( name ) {
const panel = this . _panels [ name ] ;
@@ -73,39 +69,21 @@ const PanelRegistry = {
const container = this . _container ( ) ;
if ( ! container ) return ;
if ( this . _dualMode ) {
// Already primary — no-op
if ( this . _active === name ) {
this . _renderLabel ( ) ;
this . _renderActions ( ) ;
return ;
}
// Currently secondary — promote to primary, swap
if ( this . _secondary === name ) {
const oldActive = this . _active ;
this . _active = name ;
this . _secondary = oldActive ;
this . _applyDualLayout ( ) ;
this . _renderLabel ( ) ;
this . _renderActions ( ) ;
return ;
}
// New panel — replace secondary
if ( this . _secondary ) this . _hide ( this . _secondary ) ;
this . _show ( name ) ;
this . _secondary = name ;
this . _applyDualLayout ( ) ;
// Already active — just refresh label/actions
if ( this . _active === name ) {
this . _renderLabel ( ) ;
this . _renderActions ( ) ;
return ;
}
// ── Single mode ──
// Hide current active
if ( this . _active && this . _active !== name ) {
this . _hide ( this . _active ) ;
}
// Show the secondary pane
container . classList . add ( 'open' ) ;
this . _showHandle ( true ) ;
this . _show ( name ) ;
this . _active = name ;
this . _showOverlay ( ) ;
@@ -114,38 +92,12 @@ const PanelRegistry = {
} ,
/**
* Close a specific panel.
*
* Dual mode: closing either panel exits dual, keeping the other.
* Single mode: closes container.
* Close a specific panel. If it's active, close the secondary pane.
*/
close ( name ) {
const panel = this . _panels [ name ] ;
if ( ! panel ) return ;
if ( this . _dualMode ) {
if ( name === this . _secondary ) {
this . _hide ( this . _secondary ) ;
this . _secondary = null ;
this . _exitDualLayout ( ) ;
this . _renderLabel ( ) ;
this . _renderActions ( ) ;
return ;
}
if ( name === this . _active ) {
this . _hide ( this . _active ) ;
// Promote secondary to primary
this . _active = this . _secondary ;
this . _secondary = null ;
this . _exitDualLayout ( ) ;
this . _renderLabel ( ) ;
this . _renderActions ( ) ;
return ;
}
return ; // not visible — nothing to close
}
// ── Single mode ──
if ( this . _active === name ) {
this . _hide ( name ) ;
this . _active = null ;
@@ -165,89 +117,34 @@ const PanelRegistry = {
} ,
/**
* Close all panels and the contai ner .
* Close all panels and the se condary pa ne.
*/
closeAll ( ) {
if ( this . _secondary ) {
this . _hide ( this . _secondary ) ;
this . _secondary = null ;
}
if ( this . _active ) {
this . _hide ( this . _active ) ;
this . _active = null ;
}
if ( this . _dualMode ) this . _exitDualLayout ( ) ;
this . _closeContainer ( ) ;
} ,
/** Is a specific panel currently visible? */
isOpen ( name ) {
return this . _active === name || this . _secondary === name ;
return this . _active === name ;
} ,
/** Is the panel contai ner open at all? */
/** Is the secondary pa ne open at all? */
isContainerOpen ( ) {
return this . _container ( ) ? . classList . contains ( 'open' ) || false ;
} ,
/** Get the primary panel name (or null). */
/** Get the active panel name (or null). */
active ( ) {
return this . _active ;
} ,
/** Is dual-view mode active? */
isDual ( ) {
return this . _dualMode ;
} ,
/**
* Toggle dual-view mode.
*
* Entering: picks the next registered panel as secondary. If
* container is closed, opens first two panels. Needs ≥ 2 panels.
* Exiting: hides secondary, keeps primary.
*/
toggleDual ( ) {
if ( this . _dualMode ) {
// Exit dual
if ( this . _secondary ) this . _hide ( this . _secondary ) ;
this . _secondary = null ;
this . _exitDualLayout ( ) ;
this . _renderLabel ( ) ;
this . _renderActions ( ) ;
return ;
}
// Enter dual — need at least 2 panels
if ( this . _order . length < 2 ) return ;
const container = this . _container ( ) ;
if ( ! container ) return ;
// If container not open, open first panel as primary
if ( ! this . _active ) {
container . classList . add ( 'open' ) ;
this . _show ( this . _order [ 0 ] ) ;
this . _active = this . _order [ 0 ] ;
}
// Pick secondary: first registered panel that isn't active
const secondary = this . _order . find ( n => n !== this . _active ) ;
if ( ! secondary ) return ;
this . _show ( secondary ) ;
this . _secondary = secondary ;
this . _dualMode = true ;
this . _applyDualLayout ( ) ;
this . _renderLabel ( ) ;
this . _renderActions ( ) ;
} ,
/**
* Cycle to the next registered panel. If container is closed, open
* the first panel. Wraps around.
*
* In dual mode, cycles the secondary panel through non-primary panels.
*/
cycle ( ) {
if ( this . _order . length === 0 ) return ;
@@ -257,23 +154,6 @@ const PanelRegistry = {
return ;
}
if ( this . _dualMode && this . _secondary ) {
// Cycle secondary through panels that aren't the primary
const others = this . _order . filter ( n => n !== this . _active ) ;
if ( others . length < 2 ) return ; // only one option, already showing
const idx = others . indexOf ( this . _secondary ) ;
const next = others [ ( idx + 1 ) % others . length ] ;
if ( next === this . _secondary ) return ;
this . _hide ( this . _secondary ) ;
this . _show ( next ) ;
this . _secondary = next ;
this . _applyDualLayout ( ) ;
this . _renderLabel ( ) ;
this . _renderActions ( ) ;
return ;
}
// Single mode: cycle active
const idx = this . _order . indexOf ( this . _active ) ;
const next = this . _order [ ( idx + 1 ) % this . _order . length ] ;
this . open ( next ) ;
@@ -281,8 +161,9 @@ const PanelRegistry = {
// ── Internal ────────────────────────────
/** The secondary pane element. */
_container ( ) {
return document . getElementById ( 'sidePanel ' ) ;
return document . getElementById ( 'workspaceSecondary ' ) ;
} ,
_body ( ) {
@@ -304,7 +185,7 @@ const PanelRegistry = {
if ( panel . onOpen ) panel . onOpen ( ) ;
} ,
/** Hide a panel: save state, call onClose, set display:none, clear grid placement . */
/** Hide a panel: save state, call onClose, set display:none. */
_hide ( name ) {
const panel = this . _panels [ name ] ;
if ( ! panel ) return ;
@@ -316,68 +197,23 @@ const PanelRegistry = {
if ( panel . onClose ) panel . onClose ( ) ;
panel . element . style . display = 'none' ;
panel . element . style . gridColumn = '' ;
} ,
/** Close the <aside> contai ner . */
/** Close the secondary pa ne. */
_closeContainer ( ) {
const container = this . _container ( ) ;
if ( ! container ) return ;
container . classList . remove ( 'open' , 'fullscreen' , 'dual-open' );
container . classList . remove ( 'open' , 'fullscreen' ) ;
container . style . width = '' ;
container . style . minWidth = '' ;
this . _showHandle ( false ) ;
this . _hideOverlay ( ) ;
} ,
// ── Dual layout ─────────────────────────
/** Apply CSS grid layout for dual-view. */
_applyDualLayout ( ) {
this . _dualMode = true ;
const body = this . _body ( ) ;
const container = this . _container ( ) ;
const divider = document . getElementById ( 'sidePanelDivider' ) ;
if ( ! body || ! container ) return ;
body . classList . add ( 'dual' ) ;
container . classList . add ( 'dual-open' ) ;
if ( divider ) divider . style . display = '' ;
// Assign grid columns: primary=1, divider=2, secondary=3
const primary = this . _panels [ this . _active ] ;
const secondary = this . _panels [ this . _secondary ] ;
if ( primary ) primary . element . style . gridColumn = '1' ;
if ( secondary ) secondary . element . style . gridColumn = '3' ;
this . _applySplitRatio ( ) ;
} ,
/** Remove dual layout, return to single-panel flexbox. */
_exitDualLayout ( ) {
this . _dualMode = false ;
const body = this . _body ( ) ;
const container = this . _container ( ) ;
const divider = document . getElementById ( 'sidePanelDivider' ) ;
if ( body ) {
body . classList . remove ( 'dual' ) ;
body . style . gridTemplateColumns = '' ;
}
if ( container ) container . classList . remove ( 'dual-open' ) ;
if ( divider ) divider . style . display = 'none' ;
// Clear grid-column on all panels
for ( const name of this . _order ) {
const p = this . _panels [ name ] ;
if ( p ) p . element . style . gridColumn = '' ;
}
} ,
/** Set grid-template-columns based on current _splitRatio. */
_applySplitRatio ( ) {
const body = this . _body ( ) ;
if ( ! body || ! this . _dualMode ) return ;
const r = this . _splitRatio ;
body . style . gridTemplateColumns = ` ${ r } fr 6px ${ 1 - r } fr ` ;
/** Show/hide the workspace resize handle. */
_showHandle ( show ) {
const handle = document . getElementById ( 'workspaceHandle' ) ;
if ( handle ) handle . classList . toggle ( 'active' , show ) ;
} ,
// ── Mobile overlay ──────────────────────
@@ -386,7 +222,7 @@ const PanelRegistry = {
return window . innerWidth <= 768 ;
} ,
/** Show overlay behind panel on mobile (tap-to-close). */
/** Show overlay behind secondary on mobile (tap-to-close). */
_showOverlay ( ) {
const ov = document . getElementById ( 'sidePanelOverlay' ) ;
if ( ov && this . _isMobile ( ) ) ov . style . display = 'block' ;
@@ -398,28 +234,22 @@ const PanelRegistry = {
if ( ov ) ov . style . display = 'none' ;
} ,
// ── T ab and action rendering ────────────
// ── L abel and action rendering ──────────
/** Update the header label to show the active panel name. */
_renderLabel ( ) {
const el = document . getElementById ( 'sidePanelLabel' ) ;
if ( ! el ) return ;
if ( this . _active ) {
const p = this . _panels [ this . _active ] ;
el . textContent = p ? p . label : '' ;
} else {
el . textContent = '' ;
}
// Update dual-view toggle button visibility
const dualBtn = document . getElementById ( 'sidePanelDualBtn' ) ;
if ( dualBtn ) {
dualBtn . style . display = this . _order . length >= 2 ? '' : 'none' ;
dualBtn . classList . toggle ( 'active' , this . _dualMode ) ;
}
} ,
/** Render per-panel action buttons for the primary ( active) panel. */
/** Render per-panel action buttons for the active panel. */
_renderActions ( ) {
const slot = document . getElementById ( 'sidePanelPanelActions' ) ;
if ( ! slot ) return ;
@@ -448,10 +278,10 @@ function _escPanel(s) {
return s . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) . replace ( /"/g , '"' ) ;
}
// ── Side Panel Container (global operations) ──
// ── Secondary Pane Fullscreen ──────────── ──
function toggleSidePanelFullscreen ( ) {
const panel = document . getElementById ( 'sidePanel ' ) ;
const panel = document . getElementById ( 'workspaceSecondary ' ) ;
if ( ! panel ) return ;
panel . classList . toggle ( 'fullscreen' ) ;
const btn = document . getElementById ( 'sidePanelFullscreenBtn' ) ;
@@ -464,77 +294,49 @@ function toggleSidePanelFullscreen() {
}
}
// ── Side Panel Resize (outer edge) ──────────
// ── Workspace Resize Handle ────── ──────────
function _initSidePanel Resize ( ) {
function _initWorkspace Resize ( ) {
let startX , startW ;
const panel = document . getElementById ( 'sidePanel ' ) ;
const handle = document . getElementById ( 'sidePanelResiz e' ) ;
if ( ! handle || ! panel ) return ;
const secondary = document . getElementById ( 'workspaceSecondary ' ) ;
const handle = document . getElementById ( 'workspaceHandl e' ) ;
if ( ! handle || ! secondary ) return ;
handle . addEventListener ( 'mousedown' , ( e ) => {
if ( panel . classList . contains ( 'fullscreen ' ) ) return ;
const onDown = ( e ) => {
if ( ! handle . classList . contains ( 'active ' ) ) return ;
if ( secondary . classList . contains ( 'fullscreen' ) ) return ;
e . preventDefault ( ) ;
startX = e . clientX ;
startW = panel . getBoundingClientRect ( ) . width ;
panel . style . transition = 'none' ;
const clientX = e . clientX ? ? e . touches ? . [ 0 ] ? . clientX ;
startX = clientX ;
startW = secondary . getBoundingClientRect ( ) . width ;
secondary . style . transition = 'none' ;
document . body . style . cursor = 'col-resize' ;
document . body . style . userSelect = 'none' ;
const onMove = ( e ) => {
const delta = star tX - e . clien tX;
const minW = PanelRegistry . isDual ( ) ? 480 : 280 ;
const newW = Math . max ( minW , Math . min ( window . innerWidth * 0.7 , startW + delta ) ) ;
panel . style . width = newW + 'px' ;
panel . style . minWidth = newW + 'px' ;
const cx = e . clien tX ? ? e . touches ? . [ 0 ] ? . clientX ? ? star tX;
const delta = startX - cx ;
const newW = Math . max ( 280 , Math . min ( window . innerWidth * 0.7 , startW + delta ) ) ;
secondary . style . width = newW + 'px' ;
secondary . style . minWidth = newW + 'px' ;
} ;
const onUp = ( ) => {
document . removeEventListener ( 'mousemove' , onMove ) ;
document . removeEventListener ( 'mouseup' , onUp ) ;
document . removeEventListener ( 'touchmove' , onMove ) ;
document . removeEventListener ( 'touchend' , onUp ) ;
document . body . style . cursor = '' ;
document . body . style . userSelect = '' ;
panel . style . transition = '' ;
secondary . style . transition = '' ;
} ;
document . addEventListener ( 'mousemove' , onMove ) ;
document . addEventListener ( 'mouseup' , onUp ) ;
} ) ;
}
document . addEventListener ( 'touchmove' , onMove , { passive : false } ) ;
document . addEventListener ( 'touchend' , onUp ) ;
} ;
// ── Dual-View Divider Resize ────────────────
function _initDualDivider ( ) {
const divider = document . getElementById ( 'sidePanelDivider' ) ;
if ( ! divider ) return ;
divider . addEventListener ( 'mousedown' , ( e ) => {
if ( ! PanelRegistry . isDual ( ) ) return ;
e . preventDefault ( ) ;
const body = document . getElementById ( 'sidePanelBody' ) ;
if ( ! body ) return ;
const bodyRect = body . getBoundingClientRect ( ) ;
body . style . cursor = 'col-resize' ;
document . body . style . cursor = 'col-resize' ;
document . body . style . userSelect = 'none' ;
const onMove = ( e ) => {
const x = e . clientX - bodyRect . left ;
const total = bodyRect . width - 6 ; // subtract divider width
const ratio = Math . max ( 0.2 , Math . min ( 0.8 , x / ( total + 6 ) ) ) ;
PanelRegistry . _splitRatio = ratio ;
PanelRegistry . _applySplitRatio ( ) ;
} ;
const onUp = ( ) => {
document . removeEventListener ( 'mousemove' , onMove ) ;
document . removeEventListener ( 'mouseup' , onUp ) ;
body . style . cursor = '' ;
document . body . style . cursor = '' ;
document . body . style . userSelect = '' ;
} ;
document . addEventListener ( 'mousemove' , onMove ) ;
document . addEventListener ( 'mouseup' , onUp ) ;
} ) ;
handle . addEventListener ( 'mousedown' , onDown ) ;
handle . addEventListener ( 'touchstart' , onDown , { passive : false } ) ;
}
// ── Mobile Swipe Navigation ─────────────────
@@ -550,7 +352,6 @@ function _initPanelSwipe() {
body . addEventListener ( 'touchstart' , ( e ) => {
if ( ! PanelRegistry . isContainerOpen ( ) ) return ;
if ( PanelRegistry . _order . length < 2 ) return ;
// Only track single-finger swipes
if ( e . touches . length !== 1 ) return ;
startX = e . touches [ 0 ] . clientX ;
@@ -572,10 +373,8 @@ function _initPanelSwipe() {
if ( Math . abs ( dx ) < 80 || Math . abs ( dx ) < Math . abs ( dy ) * 1.5 ) return ;
if ( dx > 0 ) {
// Swipe right → previous panel
_cyclePanelDirection ( - 1 ) ;
} else {
// Swipe left → next panel
_cyclePanelDirection ( 1 ) ;
}
} , { passive : true } ) ;
@@ -595,7 +394,6 @@ function _cyclePanelDirection(direction) {
}
// ── Responsive Resize Handler ───────────────
// Auto-exit dual mode when viewport shrinks below threshold.
function _initPanelResponsive ( ) {
let wasWide = window . innerWidth > 768 ;
@@ -603,11 +401,6 @@ function _initPanelResponsive() {
window . addEventListener ( 'resize' , ( ) => {
const isWide = window . innerWidth > 768 ;
// Viewport shrank to mobile while dual was active → collapse
if ( wasWide && ! isWide && PanelRegistry . isDual ( ) ) {
PanelRegistry . toggleDual ( ) ; // exits dual, keeps primary
}
// Update overlay visibility: show on mobile if panel open, hide on desktop
if ( PanelRegistry . isContainerOpen ( ) ) {
const ov = document . getElementById ( 'sidePanelOverlay' ) ;
@@ -631,7 +424,7 @@ function _initPanelOverlay() {
} ) ;
}
// ── Legacy Compat (thin wrappers, remove after full migration) ──
// ── Legacy Compat (thin wrappers) ───────── ──
function openSidePanel ( tab ) {
PanelRegistry . open ( tab ) ;