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;
}
/* ── 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) ─────────────────────────────────────

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 { 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<AppHeaderProps> = ({ 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<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
// 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<AppHeaderProps> = ({ editorMenu, editorToolbar
location.pathname === localize(path) ? ' header-nav-link-active' : '';
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-left">
{/* 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,
};
}