From 534b2ff21b2efbda88d29124f7c45520d15bc065 Mon Sep 17 00:00:00 2001 From: David Montero Date: Tue, 18 Aug 2026 05:21:21 +0200 Subject: [PATCH] fix(editor): the header measures whether the toolbar strip fits instead of guessing by breakpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the AI chat docked at 1440px, a board with many canvas controls (XIAO: display + camera + mic + scope) wrapped the strip INSIDE the header — the canvas controls orphaned on a second line next to a black hole — because the drop-to-own-bar fallback fired on media-query thresholds (1040 / 1060 / 1400px by chat state) that were only right for one board's strip width. AppHeader now measures: natural single-row width of the strip (each zone at its content width) against the room left next to the brand + menus, and sets app-header--strip-below when it does not fit. Always measured in the inline layout (class removed, measured, restored before paint) so the below-state's margins and uncollapsed labels never bias the decision, and there is nothing to oscillate. Re-measured via ResizeObserver on the strip host, the brand block and the zones (plus a MutationObserver for the portaled canvas controls), deferred a frame so a toggle never re-enters delivery. The three media-query blocks collapse into one class rule; a 1000px window without the chat now keeps one row when the collapsed strip fits, instead of being forced to two. --- frontend/src/App.css | 72 +++++------------- .../src/__tests__/header-strip-fit.test.ts | 53 +++++++++++++ frontend/src/components/layout/AppHeader.tsx | 63 ++++++++++++++- .../src/components/layout/headerStripFit.ts | 76 +++++++++++++++++++ 4 files changed, 211 insertions(+), 53 deletions(-) create mode 100644 frontend/src/__tests__/header-strip-fit.test.ts create mode 100644 frontend/src/components/layout/headerStripFit.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index 4a476c07..59bfe3e3 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -566,63 +566,33 @@ body { height: 44px; } -/* ── Tight fallback for the in-header strip ────────────────────────────── +/* ── Fallback for the in-header strip: its own full-width bar ─────────── Between "fits in one row" and "phone" there is an awkward middle (e.g. 1440px with the chat docked) where flex-wrap produced an orphan group of canvas controls floating inside a tall transparent header, next to a - dead hole before the right-side controls. Below the width where one row - GENUINELY fits — thresholds account for the docked chat's reserved - 380px / 36px — the strip drops back to being its own full-width second - bar with its old skin: exactly the familiar pre-merge layout, which - reads as intentional instead of broken. Three blocks because a media - query cannot test a body class in its condition. */ + dead hole before the right edge. When one row GENUINELY cannot hold the + strip, it drops back to being its own full-width second bar with its old + skin: exactly the familiar pre-merge layout, which reads as intentional + instead of broken. -@media (max-width: 1039.9px) { - .app-header--with-toolbar .header-content { - flex-wrap: wrap; - } - .app-header--with-toolbar .header-editor-toolbar { - flex-basis: 100%; - order: 10; - margin: 0 -16px; - } - .app-header--with-toolbar .header-editor-toolbar .unified-toolbar { - background: #252526; - border-top: 1px solid #333; - min-height: 38px; - } + Whether it fits is MEASURED by AppHeader (headerStripFit.ts), which sets + this class — not guessed from media queries. The old thresholds + (1040 / 1060 / 1400px by chat state) were right for one board only: a + XIAO with display + camera + mic + scope controls needs a wider strip + than an Uno, and at 1440px with the chat docked it wrapped inside the + header again. */ +.app-header--with-toolbar.app-header--strip-below .header-content { + flex-wrap: wrap; } - -@media (max-width: 1059.9px) { - body.velxio-agent-collapsed .app-header--with-toolbar .header-content { - flex-wrap: wrap; - } - body.velxio-agent-collapsed .app-header--with-toolbar .header-editor-toolbar { - flex-basis: 100%; - order: 10; - margin: 0 -16px; - } - body.velxio-agent-collapsed .app-header--with-toolbar .header-editor-toolbar .unified-toolbar { - background: #252526; - border-top: 1px solid #333; - min-height: 38px; - } +.app-header--with-toolbar.app-header--strip-below .header-editor-toolbar { + flex-basis: 100%; + order: 10; + margin: 0 -16px; } - -@media (max-width: 1399.9px) { - body.velxio-agent-open .app-header--with-toolbar .header-content { - flex-wrap: wrap; - } - body.velxio-agent-open .app-header--with-toolbar .header-editor-toolbar { - flex-basis: 100%; - order: 10; - margin: 0 -16px; - } - body.velxio-agent-open .app-header--with-toolbar .header-editor-toolbar .unified-toolbar { - background: #252526; - border-top: 1px solid #333; - min-height: 38px; - } +.app-header--with-toolbar.app-header--strip-below .header-editor-toolbar .unified-toolbar { + background: #252526; + border-top: 1px solid #333; + min-height: 38px; } /* ── Editor corner box (bottom-left) ───────────────────────────────────── diff --git a/frontend/src/__tests__/header-strip-fit.test.ts b/frontend/src/__tests__/header-strip-fit.test.ts new file mode 100644 index 00000000..0915d209 --- /dev/null +++ b/frontend/src/__tests__/header-strip-fit.test.ts @@ -0,0 +1,53 @@ +/** + * Whether the editor toolbar strip fits on the header's brand row. + * + * The complaint this fixes: with the AI chat docked at 1440px, a board with + * many canvas controls (XIAO: display + camera + mic + scope) wrapped the + * strip INSIDE the header — canvas controls orphaned on a second line next + * to a black hole — because the drop-below fallback fired on guessed media + * query thresholds instead of the strip's real width. These lock the rule: + * needed width vs available width, nothing else. + */ +import { describe, it, expect } from 'vitest'; +import { + stripAvailableWidth, + stripMustDropBelow, + stripNeededWidth, +} from '../components/layout/headerStripFit'; + +const base = { + contentWidth: 1044, // 1440 minus 16px header padding minus 380px docked chat + leftWidth: 370, // brand + File/Edit/View/Account/Help + hostMarginX: 20, // .header-editor-toolbar { margin: 0 10px } + stripPaddingX: 6, // in-header strip pads 6px right +}; + +describe('header strip fit', () => { + it('sums the zones plus the strip and host chrome', () => { + expect(stripNeededWidth({ ...base, zoneWidths: [0, 382, 268] })).toBe(676); + expect(stripAvailableWidth({ ...base, zoneWidths: [] })).toBe(674); + }); + + it('keeps an Uno-sized strip on the brand row at 1440 with the chat docked', () => { + // 0 = the view-mode toggle hidden by its container query. + expect(stripMustDropBelow({ ...base, zoneWidths: [0, 382, 266] })).toBe(false); + }); + + it('drops a XIAO-sized strip below at the same width', () => { + // Display + camera + mic + scope + add: ~344px of canvas controls. + expect(stripMustDropBelow({ ...base, zoneWidths: [0, 420, 344] })).toBe(true); + }); + + it('is decided by the real widths, not a viewport threshold', () => { + // Same 1000px window without the chat: the old media query forced two + // rows below 1040px; a strip whose labels have collapsed fits fine. + const noChat = { ...base, contentWidth: 968, leftWidth: 352 }; + expect(stripMustDropBelow({ ...noChat, zoneWidths: [0, 322, 268] })).toBe(false); + // ...and a wider one on the same window does not. + expect(stripMustDropBelow({ ...noChat, zoneWidths: [139, 395, 268] })).toBe(true); + }); + + it('treats an exact fit as fitting', () => { + expect(stripMustDropBelow({ ...base, zoneWidths: [0, 380, 268] })).toBe(false); + }); +}); diff --git a/frontend/src/components/layout/AppHeader.tsx b/frontend/src/components/layout/AppHeader.tsx index c120ae93..67bccc2a 100644 --- a/frontend/src/components/layout/AppHeader.tsx +++ b/frontend/src/components/layout/AppHeader.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useLayoutEffect, useRef } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useProjectStore } from '../../store/useProjectStore'; @@ -7,6 +7,7 @@ import { LanguageSwitcher } from './LanguageSwitcher'; import { useLocalizedHref, useCurrentLocale } from '../../i18n/useLocalizedNavigate'; import { blogUrlFor } from '../../i18n/path'; import { trackVisitGitHub, trackVisitDiscord } from '../../utils/analytics'; +import { measureStripFit, stripMustDropBelow } from './headerStripFit'; import './LanguageSwitcher.css'; const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio'; @@ -40,6 +41,64 @@ export const AppHeader: React.FC = ({ editorMenu, editorToolbar setMenuOpen(false); }, [location.pathname]); + // Editor variant: does the toolbar strip fit on the brand row? Measured, + // not guessed — see headerStripFit.ts. `app-header--strip-below` moves + // it to a full-width second bar. Re-measured whenever the strip host, + // the brand block or a strip zone resizes (window, docked chat, board + // controls mounting, label collapses). The first measure runs before + // paint; later ones are deferred a frame so toggling the class never + // re-enters ResizeObserver delivery. + const headerRef = useRef(null); + const hasStrip = !!editorToolbar; + useLayoutEffect(() => { + const header = headerRef.current; + if (!header || !hasStrip) return; + const CLS = 'app-header--strip-below'; + const apply = () => { + // Always measure the INLINE layout — the question is "would it fit on + // the brand row?", and the below-state has different host margins and + // wider (uncollapsed) labels. Removing the class, measuring and + // restoring it happens in one synchronous pass before the rendering + // step, so nothing paints and ResizeObserver sees no transient. + header.classList.remove(CLS); + const m = measureStripFit(header); + header.classList.toggle(CLS, !!m && stripMustDropBelow(m)); + }; + apply(); + if (typeof ResizeObserver === 'undefined') return; + let raf = 0; + const schedule = () => { + if (raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + apply(); + }); + }; + const ro = new ResizeObserver(schedule); + const content = header.querySelector(':scope > .header-content'); + const left = content?.querySelector(':scope > .header-left'); + const host = content?.querySelector(':scope > .header-editor-toolbar'); + const strip = host?.firstElementChild; + for (const el of [left, host, ...(strip ? Array.from(strip.children) : [])]) { + if (el) ro.observe(el); + } + // Zones mount their controls later (the canvas side is a portal) — + // pick up children that appear after mount. + const mo = strip + ? new MutationObserver(() => { + for (const z of Array.from(strip.children)) ro.observe(z); + schedule(); + }) + : null; + if (strip && mo) mo.observe(strip, { childList: true }); + return () => { + ro.disconnect(); + mo?.disconnect(); + if (raf) cancelAnimationFrame(raf); + header.classList.remove(CLS); + }; + }, [hasStrip]); + // Tauri desktop: skip the header entirely. The marketing nav was // already hidden, but the strip itself was still painting an empty // black bar over the editor. Brand/auto-save/share/auth-slot all @@ -56,7 +115,7 @@ export const AppHeader: React.FC = ({ editorMenu, editorToolbar location.pathname === localize(path) ? ' header-nav-link-active' : ''; return ( -
+
{/* Brand */} diff --git a/frontend/src/components/layout/headerStripFit.ts b/frontend/src/components/layout/headerStripFit.ts new file mode 100644 index 00000000..d8e40c04 --- /dev/null +++ b/frontend/src/components/layout/headerStripFit.ts @@ -0,0 +1,76 @@ +/** + * Does the editor toolbar strip fit on the header's first row? + * + * The strip rides inside the header next to the brand + menus. When the + * window is small or the AI chat is docked, one row genuinely cannot hold + * it and it must drop to a full-width second bar. Deciding that with media + * queries meant guessing thresholds (1040 / 1060 / 1400px by chat state) + * that were only right for one board: a XIAO with display + camera + mic + + * scope controls needs a wider strip than an Uno, so at 1440px with the + * chat docked the strip wrapped INSIDE the header instead — an orphan + * group of canvas controls floating in a tall header next to a black hole. + * + * So the header measures instead. Pure function over the DOM so it can be + * unit-tested; `AppHeader` wires it to a ResizeObserver. + */ + +export interface StripFitInput { + /** Width of `.header-content` — the row the brand, menus and strip share. */ + contentWidth: number; + /** Width of `.header-left` (brand + menus), pinned to the first row. */ + leftWidth: number; + /** Horizontal margins of the strip host (`.header-editor-toolbar`). */ + hostMarginX: number; + /** Horizontal padding of the strip itself (`.unified-toolbar`). */ + stripPaddingX: number; + /** Natural width of each strip zone at its own content size. */ + zoneWidths: readonly number[]; +} + +/** Natural single-row width the strip needs. */ +export function stripNeededWidth(i: StripFitInput): number { + return i.zoneWidths.reduce((a, b) => a + b, 0) + i.stripPaddingX + i.hostMarginX; +} + +/** Width the first row can give the strip next to the brand + menus. */ +export function stripAvailableWidth(i: StripFitInput): number { + return i.contentWidth - i.leftWidth; +} + +/** True when the strip must drop below the brand row. */ +export function stripMustDropBelow(i: StripFitInput): boolean { + return stripNeededWidth(i) > stripAvailableWidth(i); +} + +/** + * Measure the live header. Zones are measured at `flex: 0 0 auto` so a + * zone that flexbox has grown to fill the row (the editor zone) reports its + * content width, not the width it happens to occupy — the styles are + * restored before returning, and the whole thing runs before paint. + */ +export function measureStripFit(header: HTMLElement): StripFitInput | null { + const content = header.querySelector(':scope > .header-content'); + const left = content?.querySelector(':scope > .header-left'); + const host = content?.querySelector(':scope > .header-editor-toolbar'); + const strip = host?.firstElementChild as HTMLElement | null | undefined; + if (!content || !left || !host || !strip) return null; + + const zones = Array.from(strip.children) as HTMLElement[]; + const saved = zones.map((z) => z.style.flex); + for (const z of zones) z.style.flex = '0 0 auto'; + const zoneWidths = zones.map((z) => z.getBoundingClientRect().width); + zones.forEach((z, k) => { + z.style.flex = saved[k]; + }); + + const px = (v: string) => parseFloat(v) || 0; + const hostCs = getComputedStyle(host); + const stripCs = getComputedStyle(strip); + return { + contentWidth: content.clientWidth, + leftWidth: left.getBoundingClientRect().width, + hostMarginX: px(hostCs.marginLeft) + px(hostCs.marginRight), + stripPaddingX: px(stripCs.paddingLeft) + px(stripCs.paddingRight), + zoneWidths, + }; +}