feat: three dashboard view modes — Auto, Fit, Compact

Auto: current behavior (auto-fill grid, fixed tile height, scrollable).
Fit: calculates cols × rows to fill viewport exactly, zero scroll.
Compact: 80px tiles, high density, auto-fill with 200px min-width.

Toggle button (▦) in overview header cycles modes. Fit recalculates
on window resize. View mode persisted in localStorage alongside other
display settings (viewMode: 'auto' default in DISPLAY_DEFAULTS).

- CSS: .session-grid--fit and .session-grid--compact modifiers
- JS: VIEW_MODES, cycleViewMode(), applyFitLayout() functions
- HTML: #view-mode-btn between + and ⚙ in overview header
- Tests: 8 new JS tests, 3 CSS tests, 1 HTML test all passing
This commit is contained in:
Brian Krabach
2026-03-30 22:22:59 -07:00
parent 3a102ba9d8
commit 8c0675157a
6 changed files with 270 additions and 6 deletions
+129 -5
View File
@@ -140,7 +140,10 @@ const DISPLAY_DEFAULTS = {
gridColumns: 'auto', gridColumns: 'auto',
bellSound: false, bellSound: false,
notificationPermission: 'default', notificationPermission: 'default',
viewMode: 'auto',
}; };
var VIEW_MODES = ['auto', 'fit', 'compact'];
const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}'; const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}';
const DELETE_SESSION_DEFAULT_TEMPLATE = 'tmux kill-session -t {name}'; const DELETE_SESSION_DEFAULT_TEMPLATE = 'tmux kill-session -t {name}';
@@ -644,6 +647,17 @@ function renderGrid(sessions) {
updatePillBell(); updatePillBell();
} }
// Reapply view mode layout after grid HTML is rebuilt
var currentDs = loadDisplaySettings();
var currentMode = currentDs.viewMode || 'auto';
if (currentMode === 'fit' && grid) {
grid.classList.add('session-grid--fit');
applyFitLayout(grid);
} else if (currentMode === 'compact' && grid) {
grid.classList.add('session-grid--compact');
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1071,29 +1085,121 @@ function saveDisplaySettings(settings) {
} catch (_) { /* blocked — ok */ } } catch (_) { /* blocked — ok */ }
} }
/**
* Calculate and apply grid layout to fill the viewport exactly (Fit mode).
* Determines optimal cols × rows based on tile count and available space.
* @param {Element} grid - The session grid element
*/
function applyFitLayout(grid) {
var count = grid.querySelectorAll('.session-tile').length;
if (count === 0) return;
// Available space — use grid's parent container
var parent = grid.parentElement;
var availH = parent ? parent.clientHeight : window.innerHeight;
var availW = grid.clientWidth;
// Subtract padding and gap
var style = getComputedStyle(grid);
var padT = parseFloat(style.paddingTop) || 0;
var padB = parseFloat(style.paddingBottom) || 0;
var padL = parseFloat(style.paddingLeft) || 0;
var padR = parseFloat(style.paddingRight) || 0;
var gap = parseFloat(style.gap) || 8;
var innerW = availW - padL - padR;
var innerH = availH - padT - padB;
// Calculate optimal cols/rows — start with square root
var cols = Math.ceil(Math.sqrt(count));
var rows = Math.ceil(count / cols);
// Prefer wider layouts (more cols, fewer rows) since tiles are landscape
if (rows > 1 && cols < count) {
var altCols = cols + 1;
var altRows = Math.ceil(count / altCols);
if (altRows < rows) {
cols = altCols;
rows = altRows;
}
}
// Tile height from available space
var tileH = (innerH - gap * (rows - 1)) / rows;
grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
grid.style.gridTemplateRows = 'repeat(' + rows + ', 1fr)';
// Override tile height so tiles fill the grid rows
grid.querySelectorAll('.session-tile').forEach(function(t) {
t.style.height = tileH + 'px';
});
}
/**
* Cycle the dashboard view mode: auto → fit → compact → auto.
* Persists to localStorage and reapplies display settings.
*/
function cycleViewMode() {
var ds = loadDisplaySettings();
var idx = VIEW_MODES.indexOf(ds.viewMode || 'auto');
ds.viewMode = VIEW_MODES[(idx + 1) % VIEW_MODES.length];
saveDisplaySettings(ds);
applyDisplaySettings(ds);
// Update button label
var btn = document.getElementById('view-mode-btn');
if (btn) btn.title = 'View: ' + ds.viewMode;
}
/** /**
* Apply display settings to the live DOM. * Apply display settings to the live DOM.
* Sets --preview-font-size CSS custom property and updates #session-grid * Sets --preview-font-size CSS custom property and updates #session-grid
* grid-template-columns based on the gridColumns setting. * grid-template-columns based on the gridColumns setting and viewMode.
* @param {object} ds - display settings object * @param {object} ds - display settings object
*/ */
function applyDisplaySettings(ds) { function applyDisplaySettings(ds) {
// Apply font size as CSS custom property (tile previews) // Apply font size as CSS custom property (tile previews)
if (document.documentElement) {
document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px'); document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px');
}
// Apply font size to the live xterm.js terminal without reconnecting // Apply font size to the live xterm.js terminal without reconnecting
if (window._setTerminalFontSize) { if (window._setTerminalFontSize) {
window._setTerminalFontSize(ds.fontSize); window._setTerminalFontSize(ds.fontSize);
} }
// Apply grid columns // Apply view mode to grid
var grid = document.getElementById('session-grid'); var grid = document.getElementById('session-grid');
if (grid) { if (!grid) return;
if (ds.gridColumns === 'auto') {
var mode = ds.viewMode || 'auto';
// Remove all mode classes
grid.classList.remove('session-grid--fit', 'session-grid--compact');
// Reset any inline styles from previous fit calculation
grid.style.removeProperty('grid-template-rows');
grid.querySelectorAll('.session-tile').forEach(function(t) {
t.style.removeProperty('height');
});
if (mode === 'auto') {
// Restore grid columns setting
if (ds.gridColumns === 'auto' || !ds.gridColumns) {
grid.style.removeProperty('grid-template-columns'); grid.style.removeProperty('grid-template-columns');
} else { } else {
grid.style.gridTemplateColumns = 'repeat(' + ds.gridColumns + ', 1fr)'; grid.style.gridTemplateColumns = 'repeat(' + ds.gridColumns + ', 1fr)';
} }
} else if (mode === 'fit') {
grid.classList.add('session-grid--fit');
applyFitLayout(grid);
} else if (mode === 'compact') {
grid.classList.add('session-grid--compact');
// Use auto-fill but with smaller minmax for higher density
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
} }
} }
@@ -1590,6 +1696,7 @@ function bindStaticEventListeners() {
on($('sheet-backdrop'), 'click', closeBottomSheet); on($('sheet-backdrop'), 'click', closeBottomSheet);
// Settings dialog bindings // Settings dialog bindings
on($('view-mode-btn'), 'click', cycleViewMode);
on($('settings-btn'), 'click', openSettings); on($('settings-btn'), 'click', openSettings);
on($('settings-btn-expanded'), 'click', openSettings); on($('settings-btn-expanded'), 'click', openSettings);
on($('settings-close-btn'), 'click', closeSettings); on($('settings-close-btn'), 'click', closeSettings);
@@ -1754,9 +1861,24 @@ function _setViewMode(mode) {
_viewMode = mode; _viewMode = mode;
} }
// Recalculate fit layout on window resize
window.addEventListener('resize', function() {
var ds = loadDisplaySettings();
if ((ds.viewMode || 'auto') === 'fit') {
var grid = document.getElementById('session-grid');
if (grid) applyFitLayout(grid);
}
});
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initDeviceId(); initDeviceId();
applyDisplaySettings(loadDisplaySettings()); var _initDs = loadDisplaySettings();
applyDisplaySettings(_initDs);
// Initialize view mode button title
var vmBtn = document.getElementById('view-mode-btn');
if (vmBtn) vmBtn.title = 'View: ' + (_initDs.viewMode || 'auto');
document.addEventListener('keydown', trackInteraction); document.addEventListener('keydown', trackInteraction);
document.addEventListener('click', trackInteraction); document.addEventListener('click', trackInteraction);
document.addEventListener('touchstart', trackInteraction); document.addEventListener('touchstart', trackInteraction);
@@ -1822,6 +1944,8 @@ if (typeof module !== 'undefined' && module.exports) {
loadDisplaySettings, loadDisplaySettings,
saveDisplaySettings, saveDisplaySettings,
applyDisplaySettings, applyDisplaySettings,
applyFitLayout,
cycleViewMode,
onDisplaySettingChange, onDisplaySettingChange,
openSettings, openSettings,
closeSettings, closeSettings,
+1
View File
@@ -22,6 +22,7 @@
<h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1> <h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
<div class="header-actions"> <div class="header-actions">
<button id="new-session-btn" class="header-btn" aria-label="New session">+</button> <button id="new-session-btn" class="header-btn" aria-label="New session">+</button>
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto">&#9638;</button>
<button id="settings-btn" class="header-btn" aria-label="Settings">&#9881;</button> <button id="settings-btn" class="header-btn" aria-label="Settings">&#9881;</button>
<span id="connection-status"></span> <span id="connection-status"></span>
</div> </div>
+18
View File
@@ -1371,6 +1371,24 @@ body {
z-index: 200; z-index: 200;
} }
/* ============================================================
Dashboard view modes — Compact and Fit
============================================================ */
/* Compact view — small tiles, high density */
.session-grid--compact .session-tile {
height: 80px;
}
.session-grid--compact .tile-body {
height: 40px;
}
/* Fit view — tiles fill viewport, no scroll */
.session-grid--fit {
overflow: hidden;
}
/* ============================================================ /* ============================================================
Responsive overlay sidebar at <960px Responsive overlay sidebar at <960px
============================================================ */ ============================================================ */
+88
View File
@@ -2118,3 +2118,91 @@ test('bindStaticEventListeners wires delete template reset button', () => {
'bindStaticEventListeners must wire #setting-delete-template-reset click handler' 'bindStaticEventListeners must wire #setting-delete-template-reset click handler'
); );
}); });
// --- View mode cycling (Auto / Fit / Compact) ---
test('app.js has VIEW_MODES array with auto, fit, compact', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
assert.ok(source.includes("'auto'"), "must include 'auto' mode");
assert.ok(source.includes("'fit'"), "must include 'fit' mode");
assert.ok(source.includes("'compact'"), "must include 'compact' mode");
assert.ok(source.includes('VIEW_MODES'), 'must define VIEW_MODES');
});
test('app.js exports cycleViewMode function', () => {
assert.ok('cycleViewMode' in app, 'app.js must export cycleViewMode');
assert.strictEqual(typeof app.cycleViewMode, 'function', 'cycleViewMode must be a function');
});
test('app.js exports applyFitLayout function', () => {
assert.ok('applyFitLayout' in app, 'app.js must export applyFitLayout');
assert.strictEqual(typeof app.applyFitLayout, 'function', 'applyFitLayout must be a function');
});
test('DISPLAY_DEFAULTS includes viewMode: auto', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
// DISPLAY_DEFAULTS should define viewMode
const defaultsStart = source.indexOf('DISPLAY_DEFAULTS');
assert.ok(defaultsStart !== -1, 'DISPLAY_DEFAULTS must exist');
const defaultsEnd = source.indexOf('};', defaultsStart);
const defaultsBody = source.substring(defaultsStart, defaultsEnd + 2);
assert.ok(defaultsBody.includes('viewMode'), 'DISPLAY_DEFAULTS must include viewMode');
});
test('applyDisplaySettings handles fit mode by adding session-grid--fit class', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
const fnStart = source.indexOf('function applyDisplaySettings(');
assert.ok(fnStart !== -1, 'applyDisplaySettings must exist');
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 2000);
assert.ok(
fnBody.includes('session-grid--fit'),
'applyDisplaySettings must apply session-grid--fit class for fit mode'
);
});
test('applyDisplaySettings handles compact mode by adding session-grid--compact class', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
const fnStart = source.indexOf('function applyDisplaySettings(');
assert.ok(fnStart !== -1, 'applyDisplaySettings must exist');
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 2000);
assert.ok(
fnBody.includes('session-grid--compact'),
'applyDisplaySettings must apply session-grid--compact class for compact mode'
);
});
test('cycleViewMode cycles through auto -> fit -> compact -> auto', () => {
// Reset display settings to auto
const ds = app.loadDisplaySettings();
ds.viewMode = 'auto';
app.saveDisplaySettings(ds);
// First cycle: auto -> fit
app.cycleViewMode();
const ds1 = app.loadDisplaySettings();
assert.strictEqual(ds1.viewMode, 'fit', 'first cycle should go auto -> fit');
// Second cycle: fit -> compact
app.cycleViewMode();
const ds2 = app.loadDisplaySettings();
assert.strictEqual(ds2.viewMode, 'compact', 'second cycle should go fit -> compact');
// Third cycle: compact -> auto
app.cycleViewMode();
const ds3 = app.loadDisplaySettings();
assert.strictEqual(ds3.viewMode, 'auto', 'third cycle should wrap compact -> auto');
});
test('bindStaticEventListeners wires view-mode-btn click to cycleViewMode', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
const fnStart = source.indexOf('function bindStaticEventListeners(');
assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist');
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 6000);
assert.ok(
fnBody.includes('view-mode-btn'),
'bindStaticEventListeners must wire #view-mode-btn click handler'
);
});
+20
View File
@@ -1681,3 +1681,23 @@ def test_css_sidebar_footer() -> None:
css = read_css() css = read_css()
for cls in (".sidebar-footer", ".sidebar-new-btn"): for cls in (".sidebar-footer", ".sidebar-new-btn"):
assert cls in css, f"Missing CSS selector '{cls}'" assert cls in css, f"Missing CSS selector '{cls}'"
def test_css_compact_view_exists() -> None:
""".session-grid--compact CSS modifier must exist for compact view mode."""
css = read_css()
assert ".session-grid--compact" in css, "Missing .session-grid--compact CSS selector for compact view mode"
def test_css_fit_view_exists() -> None:
""".session-grid--fit CSS modifier must exist for fit view mode."""
css = read_css()
assert ".session-grid--fit" in css, "Missing .session-grid--fit CSS selector for fit view mode"
def test_css_compact_tile_height() -> None:
""".session-grid--compact .session-tile must set a compact height."""
css = read_css()
assert ".session-grid--compact .session-tile" in css, (
"Missing .session-grid--compact .session-tile height override"
)
+13
View File
@@ -1153,3 +1153,16 @@ def test_html_new_session_tab_controls_with_delete() -> None:
"setting-delete-template-reset", "setting-delete-template-reset",
): ):
assert soup.find(id=id_), f"Missing element with id='{id_}'" assert soup.find(id=id_), f"Missing element with id='{id_}'"
def test_html_view_mode_button_exists() -> None:
"""#view-mode-btn must exist in the overview header for cycling view modes."""
soup = _SOUP
btn = soup.find(id="view-mode-btn")
assert btn is not None, "Missing element with id='view-mode-btn' (view mode toggle button)"
# Must be inside the overview header area
overview = soup.find(id="view-overview")
assert overview is not None, "Missing #view-overview"
assert overview.find(id="view-mode-btn") is not None, (
"#view-mode-btn must be inside #view-overview header"
)