From a4a0c5db47bf0cd11b5f12075db92fb24a4a6b43 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 17:29:35 +0200 Subject: [PATCH] fix(sidebar): size the sidebar to its container, not the window #sidebar and #sidebar-resize-handle were 100vh, but their parent #app-container is the window minus the 22px #status-bar, so both were 22px taller than the box holding them. Any focus or scrollIntoView below the fold made the browser scroll #app-container by exactly that overflow (clientHeight 1078, scrollHeight 1100, scrollTop 22), clipping the top row of sidebar icons; #app-container is overflow: hidden, so there is no scrollbar to scroll back and the clipping survives until a reload. A container-relative height leaves nothing inside #app-container sized to the viewport. The new CSS source test pins that for the whole file: it reads every (min-/max-)height declaration reaching 100vh and fails if its selector matches an element under #app-container in index.html. --- public/style.css | 4 +- .../app-container-viewport-height-css.test.js | 110 ++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 test/app-container-viewport-height-css.test.js diff --git a/public/style.css b/public/style.css index 128c1599..75ea51e7 100644 --- a/public/style.css +++ b/public/style.css @@ -222,7 +222,7 @@ body { display: flex; flex-direction: column; } background: var(--surface-chrome); display: flex; flex-direction: column; - height: 100vh; + height: 100%; border-right: none; flex-shrink: 0; } @@ -231,7 +231,7 @@ body { display: flex; flex-direction: column; } width: 4px; cursor: col-resize; background: transparent; - height: 100vh; + height: 100%; flex-shrink: 0; position: relative; z-index: 10; diff --git a/test/app-container-viewport-height-css.test.js b/test/app-container-viewport-height-css.test.js new file mode 100644 index 00000000..c9af4b16 --- /dev/null +++ b/test/app-container-viewport-height-css.test.js @@ -0,0 +1,110 @@ +// #app-container is a flex child of body, sharing the window with the 22px +// #status-bar, so it is strictly shorter than the viewport. A descendant sized +// to 100vh therefore overflows it by the height of the status bar, and since +// #app-container is overflow: hidden the browser can scroll that overflow out +// of view (a focus or scrollIntoView below the fold is enough) with no +// scrollbar to bring it back — the top row stays clipped until a reload. +// Container-relative heights are the only safe way to fill #app-container. +// +// Same source-grep shape as test/session-meta-layout-css.test.js — no real CSS +// parser, just brace/comment stripping good enough to isolate selector text — +// with index.html parsed by jsdom to decide which selectors actually land +// inside #app-container. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM } = require('jsdom'); + +const PUBLIC_DIR = path.join(__dirname, '..', 'public'); +const CSS = fs.readFileSync(path.join(PUBLIC_DIR, 'style.css'), 'utf8'); +const CSS_NO_COMMENTS = CSS.replace(/\/\*[\s\S]*?\*\//g, ''); +const HTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8'); + +const RULE_BLOCKS = CSS_NO_COMMENTS.match(/[^{}]+\{[^{}]*\}/g) || []; + +function selectorOf(block) { + const head = block.slice(0, block.lastIndexOf('{')); + const lines = head.split('\n'); + return lines[lines.length - 1].trim(); +} + +function declarationsOf(block) { + return block.slice(block.lastIndexOf('{') + 1, block.lastIndexOf('}')); +} + +function ruleFor(selectorPattern) { + return RULE_BLOCKS.find((block) => selectorPattern.test(selectorOf(block))); +} + +// Every (min-/max-)height declaration whose value reaches or exceeds the full +// viewport height. +function fullViewportHeights(declarations) { + const found = []; + const re = /(?:^|;)\s*((?:min-|max-)?height)\s*:\s*([^;]+)/g; + let m; + while ((m = re.exec(declarations)) !== null) { + const value = m[2].trim(); + const vh = /(\d+(?:\.\d+)?)vh\b/.exec(value); + if (vh && Number(vh[1]) >= 100) found.push(`${m[1]}: ${value}`); + } + return found; +} + +test('style.css: nothing inside #app-container is sized to the full viewport height', () => { + const dom = new JSDOM(HTML); + const { document } = dom.window; + const appContainer = document.getElementById('app-container'); + assert.ok(appContainer, 'expected #app-container in public/index.html'); + + const offenders = []; + for (const block of RULE_BLOCKS) { + const declarations = declarationsOf(block); + const heights = fullViewportHeights(declarations); + if (heights.length === 0) continue; + + const selector = selectorOf(block); + // Pseudo-elements are not queryable; they are sized by their originating + // element, which its own rule covers. + const queryable = selector.replace(/::[a-z-]+(\([^)]*\))?/g, ''); + let matched; + try { + matched = Array.from(document.querySelectorAll(queryable)); + } catch { + offenders.push(`${selector} { ${heights.join('; ')} } — selector could not be evaluated`); + continue; + } + if (matched.some((el) => el === appContainer || appContainer.contains(el))) { + offenders.push(`${selector} { ${heights.join('; ')} }`); + } + } + + assert.deepEqual(offenders, [], + 'these rules size an element inside #app-container to the window instead of its container; ' + + 'use a container-relative height (100%, or flex stretch)'); + + dom.window.close(); +}); + +test('style.css: #app-container shares the window with a fixed-height #status-bar and hides its overflow', () => { + const dom = new JSDOM(HTML); + const { document } = dom.window; + const appContainer = document.getElementById('app-container'); + const statusBar = document.getElementById('status-bar'); + assert.ok(statusBar, 'expected #status-bar in public/index.html'); + assert.ok(!appContainer.contains(statusBar), + '#status-bar sits beside #app-container, so its height comes off the space #app-container gets'); + dom.window.close(); + + const statusBarRule = ruleFor(/^#status-bar$/); + assert.ok(statusBarRule, 'expected a #status-bar rule'); + const statusBarHeight = /(?:^|;)\s*height\s*:\s*(\d+(?:\.\d+)?)px/.exec(declarationsOf(statusBarRule)); + assert.ok(statusBarHeight && Number(statusBarHeight[1]) > 0, + '#status-bar reserves a fixed pixel strip of the window'); + + const appContainerRule = ruleFor(/^#app-container$/); + assert.ok(appContainerRule, 'expected an #app-container rule'); + assert.match(declarationsOf(appContainerRule), /overflow:\s*hidden/, + 'overflow that escapes #app-container gets no scrollbar, so it cannot be scrolled back into view'); +});