fix(editor): the header measures whether the toolbar strip fits instead of guessing by breakpoint

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.
This commit is contained in:
David Montero 2026-08-18 05:21:21 +02:00
parent 7fc131d4fb
commit 534b2ff21b
4 changed files with 211 additions and 53 deletions

View File

@ -566,63 +566,33 @@ body {
height: 44px; 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. 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 1440px with the chat docked) where flex-wrap produced an orphan group of
canvas controls floating inside a tall transparent header, next to a canvas controls floating inside a tall transparent header, next to a
dead hole before the right-side controls. Below the width where one row dead hole before the right edge. When one row GENUINELY cannot hold the
GENUINELY fits — thresholds account for the docked chat's reserved strip, it drops back to being its own full-width second bar with its old
380px / 36px — the strip drops back to being its own full-width second skin: exactly the familiar pre-merge layout, which reads as intentional
bar with its old skin: exactly the familiar pre-merge layout, which instead of broken.
reads as intentional instead of broken. Three blocks because a media
query cannot test a body class in its condition. */
@media (max-width: 1039.9px) { Whether it fits is MEASURED by AppHeader (headerStripFit.ts), which sets
.app-header--with-toolbar .header-content { this class — not guessed from media queries. The old thresholds
flex-wrap: wrap; (1040 / 1060 / 1400px by chat state) were right for one board only: a
} XIAO with display + camera + mic + scope controls needs a wider strip
.app-header--with-toolbar .header-editor-toolbar { than an Uno, and at 1440px with the chat docked it wrapped inside the
flex-basis: 100%; header again. */
order: 10; .app-header--with-toolbar.app-header--strip-below .header-content {
margin: 0 -16px; flex-wrap: wrap;
}
.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 {
@media (max-width: 1059.9px) { flex-basis: 100%;
body.velxio-agent-collapsed .app-header--with-toolbar .header-content { order: 10;
flex-wrap: wrap; margin: 0 -16px;
}
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 .unified-toolbar {
@media (max-width: 1399.9px) { background: #252526;
body.velxio-agent-open .app-header--with-toolbar .header-content { border-top: 1px solid #333;
flex-wrap: wrap; min-height: 38px;
}
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;
}
} }
/* ── Editor corner box (bottom-left) ───────────────────────────────────── /* ── Editor corner box (bottom-left) ─────────────────────────────────────

View File

@ -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);
});
});

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useProjectStore } from '../../store/useProjectStore'; import { useProjectStore } from '../../store/useProjectStore';
@ -7,6 +7,7 @@ import { LanguageSwitcher } from './LanguageSwitcher';
import { useLocalizedHref, useCurrentLocale } from '../../i18n/useLocalizedNavigate'; import { useLocalizedHref, useCurrentLocale } from '../../i18n/useLocalizedNavigate';
import { blogUrlFor } from '../../i18n/path'; import { blogUrlFor } from '../../i18n/path';
import { trackVisitGitHub, trackVisitDiscord } from '../../utils/analytics'; import { trackVisitGitHub, trackVisitDiscord } from '../../utils/analytics';
import { measureStripFit, stripMustDropBelow } from './headerStripFit';
import './LanguageSwitcher.css'; import './LanguageSwitcher.css';
const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio'; const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio';
@ -40,6 +41,64 @@ export const AppHeader: React.FC<AppHeaderProps> = ({ editorMenu, editorToolbar
setMenuOpen(false); setMenuOpen(false);
}, [location.pathname]); }, [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<HTMLElement>(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<HTMLElement>(':scope > .header-content');
const left = content?.querySelector<HTMLElement>(':scope > .header-left');
const host = content?.querySelector<HTMLElement>(':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 // Tauri desktop: skip the header entirely. The marketing nav was
// already hidden, but the strip itself was still painting an empty // already hidden, but the strip itself was still painting an empty
// black bar over the editor. Brand/auto-save/share/auth-slot all // black bar over the editor. Brand/auto-save/share/auth-slot all
@ -56,7 +115,7 @@ export const AppHeader: React.FC<AppHeaderProps> = ({ editorMenu, editorToolbar
location.pathname === localize(path) ? ' header-nav-link-active' : ''; location.pathname === localize(path) ? ' header-nav-link-active' : '';
return ( return (
<header className={"app-header" + (editorToolbar ? ' app-header--with-toolbar' : '')}> <header ref={headerRef} className={"app-header" + (editorToolbar ? ' app-header--with-toolbar' : '')}>
<div className="header-content"> <div className="header-content">
<div className="header-left"> <div className="header-left">
{/* Brand */} {/* Brand */}

View File

@ -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<HTMLElement>(':scope > .header-content');
const left = content?.querySelector<HTMLElement>(':scope > .header-left');
const host = content?.querySelector<HTMLElement>(':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,
};
}