merge: integrate latest upstream (fit view refactor, ttyd kill-by-port, mobile viewport fix)

This commit is contained in:
Brian Krabach
2026-03-31 09:10:59 -07:00
6 changed files with 412 additions and 108 deletions
+19 -40
View File
@@ -932,13 +932,7 @@ function renderGrid(sessions) {
var currentMode = currentDs.viewMode || 'auto';
if (currentMode === 'fit' && grid) {
grid.classList.add('session-grid--fit');
requestAnimationFrame(function() {
applyFitLayout(grid);
// Scroll each tile's pre to the bottom so content anchors at the bottom (like a real terminal)
grid.querySelectorAll('.tile-body pre').forEach(function(pre) {
pre.scrollTop = pre.scrollHeight;
});
});
applyFitLayout(grid);
}
}
@@ -1310,7 +1304,7 @@ function closeSession() {
var _closGrid = document.getElementById('session-grid');
if (_closGrid) {
_closGrid.classList.add('session-grid--fit');
requestAnimationFrame(function() { applyFitLayout(_closGrid); });
applyFitLayout(_closGrid);
}
}
@@ -1503,29 +1497,24 @@ function saveDisplaySettings(settings) {
}
/**
* Calculate and apply grid layout to fill the viewport exactly (Fit mode).
* Determines optimal cols × rows based on tile count and available space.
* Set grid template for fit mode based on tile count.
* Pure arithmetic — no DOM measurement, no getComputedStyle, no clientHeight.
* Safe to call at any time regardless of display state or layout phase.
*
* The grid already has a definite height from CSS (flex: 1 inside height: 100dvh).
* Setting grid-template-rows: repeat(rows, 1fr) lets the browser divide that height
* equally without JS needing to know the pixel dimensions. Tiles use height: auto
* (set in CSS) so they fill their grid cells without inline style overrides.
*
* @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;
if (count === 0) {
grid.style.removeProperty('grid-template-columns');
grid.style.removeProperty('grid-template-rows');
return;
}
// Calculate optimal cols/rows — start with square root
var cols = Math.ceil(Math.sqrt(count));
@@ -1541,16 +1530,8 @@ function applyFitLayout(grid) {
}
}
// 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';
});
}
/**
@@ -1597,9 +1578,7 @@ function applyDisplaySettings(ds) {
// 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');
});
grid.style.removeProperty('grid-template-columns');
if (mode === 'auto') {
// Restore grid columns setting
@@ -1611,7 +1590,7 @@ function applyDisplaySettings(ds) {
} else if (mode === 'fit') {
grid.classList.add('session-grid--fit');
requestAnimationFrame(function() { applyFitLayout(grid); });
applyFitLayout(grid);
}
}
@@ -2496,7 +2475,7 @@ window.addEventListener('resize', function() {
var ds = loadDisplaySettings();
if ((ds.viewMode || 'auto') === 'fit') {
var grid = document.getElementById('session-grid');
if (grid) requestAnimationFrame(function() { applyFitLayout(grid); });
if (grid) applyFitLayout(grid);
}
});
+17 -11
View File
@@ -83,7 +83,8 @@ body {
============================================================ */
.view {
height: 100vh;
height: 100vh; /* fallback for browsers without dvh support */
height: 100dvh; /* dynamic viewport height — adjusts for mobile browser chrome */
overflow: hidden;
}
@@ -593,7 +594,8 @@ body {
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
height: 100vh !important; /* fallback for browsers without dvh support */
height: 100dvh !important; /* dynamic viewport height — adjusts for mobile browser chrome */
border-radius: 0;
}
@@ -1523,19 +1525,23 @@ body {
/* Fit view — tiles fill viewport, no scroll */
.session-grid--fit {
overflow: hidden;
align-content: stretch; /* let 1fr rows fill container height */
}
/* In fit mode, stretch pre to fill the full tile body height and anchor content to bottom */
.session-grid--fit .tile-body pre {
top: 0; /* stretch to fill full tile body height in fit mode */
overflow-y: scroll; /* enable scrollTop positioning (content anchored via JS scrollTop=scrollHeight) */
scrollbar-width: none; /* Firefox: hide scrollbar */
-ms-overflow-style: none; /* IE/Edge: hide scrollbar */
/* In fit mode, tiles must use height:auto so the CSS grid 1fr rows control sizing.
The base .session-tile uses var(--tile-height) which is a fixed pixel value —
overriding with height:auto lets the grid cell (from grid-template-rows: repeat(n,1fr))
determine the tile height instead. JS sets only grid-template-columns/rows, not inline heights. */
.session-grid--fit .session-tile {
height: auto;
}
.session-grid--fit .tile-body pre::-webkit-scrollbar {
display: none; /* Chrome/Safari: hide scrollbar */
}
/* In fit mode, tile body and pre use the base CSS:
.tile-body { position: relative; overflow: hidden; }
.tile-body pre { position: absolute; bottom: 0; left: 0; right: 0; }
The pre's natural height from 80 lines of content exceeds the tile height, so it
overflows from bottom:0 upward, and tile-body clips excess at the top — showing
the bottom-most content (the prompt area) just like a real terminal. */
/* ============================================================
Responsive overlay sidebar at <960px
+94 -6
View File
@@ -3528,11 +3528,22 @@ test('bindStaticEventListeners wires view-mode-btn click to cycleViewMode', () =
);
});
test('applyFitLayout is called via requestAnimationFrame for correct timing', () => {
test('applyFitLayout is called directly (no requestAnimationFrame needed — pure arithmetic)', () => {
// Pure arithmetic applyFitLayout is safe to call synchronously at any time —
// it does not measure DOM dimensions so there is no layout-timing dependency.
// Call sites (renderGrid, closeSession, applyDisplaySettings, resize handler)
// should call it directly, not via requestAnimationFrame.
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
// Verify applyFitLayout exists as a direct call (not only inside rAF callbacks)
assert.ok(
source.includes('requestAnimationFrame') && source.includes('applyFitLayout'),
'applyFitLayout must be deferred via requestAnimationFrame'
source.includes('applyFitLayout'),
'app.js must define and call applyFitLayout'
);
// The function body itself must not measure DOM — verified by the separate
// "does NOT measure DOM dimensions" test. Here just confirm the function exists.
assert.ok(
source.includes('function applyFitLayout('),
'applyFitLayout function must be declared'
);
});
@@ -3557,14 +3568,29 @@ test('buildTileHTML shows up to 80 lines in fit mode', () => {
);
});
test('CSS style.css has scrollbar-width none for fit mode pre to hide scrollbar', () => {
test('CSS style.css uses base position:absolute bottom:0 for fit mode content anchoring (no flex override)', () => {
const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
// Reverted approach: base CSS position:absolute + bottom:0 anchors content to bottom.
// The flex + justify-content:flex-end approach failed because <pre> filled 100% of parent,
// making flex-end a no-op (content started at top, excess clipped at bottom).
// The fit-mode tile-body flex override must be REMOVED.
assert.ok(
source.includes('scrollbar-width: none'),
'style.css must have scrollbar-width: none for hidden scrollbar in fit mode pre'
!source.includes('.session-grid--fit .tile-body {'),
'style.css must NOT have .session-grid--fit .tile-body flex override — base position:absolute handles anchoring'
);
// The pre static-positioning override must also be removed
assert.ok(
!source.includes('.session-grid--fit .tile-body pre {'),
'style.css must NOT have .session-grid--fit .tile-body pre override — base position:absolute + bottom:0 is correct'
);
// Base .tile-body pre must still use position:absolute + bottom:0
assert.ok(
source.includes('position: absolute') && source.includes('bottom: 0'),
'base .tile-body pre must retain position:absolute and bottom:0 for content anchoring'
);
});
// --- document.title quality fixes ---
test('device name input handler restores default title when value is cleared', () => {
@@ -3623,3 +3649,65 @@ test('DOMContentLoaded sets document.title from server settings at page load', (
"DOMContentLoaded must set document.title = _serverSettings.device_name || 'muxplex' after loadServerSettings resolves"
);
});
// --- Fit view layout: applyFitLayout sets grid template via pure arithmetic ---
test('applyFitLayout sets gridTemplateColumns and gridTemplateRows via pure arithmetic', () => {
const assignedProps = {};
const mockTile = { style: { removeProperty: () => {} } };
const mockGrid = {
style: new Proxy(assignedProps, {
set(target, prop, value) { target[prop] = value; return true; },
get(target, prop) { return target[prop]; },
}),
querySelectorAll: (sel) => {
if (sel === '.session-tile') return [mockTile, mockTile, mockTile, mockTile];
return [];
},
};
app.applyFitLayout(mockGrid);
assert.ok(
typeof assignedProps.gridTemplateColumns === 'string' && assignedProps.gridTemplateColumns.includes('1fr'),
'applyFitLayout must set gridTemplateColumns with 1fr tracks'
);
assert.ok(
typeof assignedProps.gridTemplateRows === 'string' && assignedProps.gridTemplateRows.includes('1fr'),
'applyFitLayout must set gridTemplateRows with 1fr tracks'
);
});
// --- Pure CSS fit layout: applyFitLayout must not measure DOM ---
test('applyFitLayout does NOT measure DOM dimensions (pure arithmetic)', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
const fnStart = source.indexOf('function applyFitLayout(');
assert.ok(fnStart !== -1, 'applyFitLayout function must exist');
// Find end of function: next \n} at the same nesting level
let depth = 0;
let pos = source.indexOf('{', fnStart);
const fnBodyStart = pos;
while (pos < source.length) {
if (source[pos] === '{') depth++;
else if (source[pos] === '}') {
depth--;
if (depth === 0) break;
}
pos++;
}
const fnBody = source.substring(fnBodyStart, pos + 1);
assert.ok(!fnBody.includes('clientHeight'),
'applyFitLayout must NOT read clientHeight — causes wrong values when container is display:none');
assert.ok(!fnBody.includes('clientWidth'),
'applyFitLayout must NOT read clientWidth — pure 1fr CSS handles width');
assert.ok(!fnBody.includes('getComputedStyle'),
'applyFitLayout must NOT call getComputedStyle — pure 1fr CSS handles gap/padding');
assert.ok(!fnBody.includes('.style.height'),
'applyFitLayout must NOT set inline tile heights — CSS grid 1fr rows handle sizing');
});