fix(editor): the toolbar strip's label collapse is measured too, and each layout is judged by its own widths

Follow-up to 534b2ff. Dropping the strip to its own bar was now measured,
but two things still produced a wrapped bar on the XIAO example at 1440
with the chat docked:

- The first collapse tier (Serial / Camera / Mic / Scope / Add / Libraries
  labels -> icons) was still a fixed 1010px container query, so on the
  1060px own-bar the labels stayed and the bar wrapped in two. That tier is
  now the unified-toolbar--compact class, set by the same measurement:
  labels on the brand row if they fit, icons on the brand row if those fit,
  else the own bar — labelled if the full bar holds it, icons otherwise.
  The finer tiers (board pill 905, count/zoom 835, view toggle 760/1140)
  stay as container queries.
- Each candidate layout is measured IN that layout: the zones carry their
  own container queries (the view-mode toggle hides on a narrow row and
  reappears on the wide bar), so a width read on the brand row said the
  bar would fit when it would not.
This commit is contained in:
David Montero 2026-08-18 05:49:45 +02:00
parent 534b2ff21b
commit b9f663f02e
5 changed files with 276 additions and 130 deletions

View File

@ -1,53 +1,111 @@
/**
* Whether the editor toolbar strip fits on the header's brand row.
* Where the editor toolbar strip goes: on the header's brand row or on its
* own bar below, labelled or icon-only.
*
* 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.
* to a black hole — because the drop-below fallback and the label collapse
* fired on guessed thresholds instead of the strip's real width. These
* lock the rule: measured needed width vs measured available width, one
* row preferred over two, labels preferred over icons.
*/
import { describe, it, expect } from 'vitest';
import {
stripAvailableWidth,
stripMustDropBelow,
decideStripLayout,
stripAvailableBelow,
stripAvailableInline,
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 }
leftWidth: 352, // brand + File/Edit/View/Account/Help
hostMarginXInline: 20, // .header-editor-toolbar { margin: 0 10px }
hostMarginXBelow: -32, // own bar bleeds into the header padding
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);
// Zone widths per candidate layout: [view-mode toggle, editor zone, canvas
// controls]. On the narrow brand row the toggle is hidden by its own
// container query (0); on the wide own-bar it shows (139).
const uno = {
inlineZoneWidths: [0, 382, 268],
inlineCompactZoneWidths: [0, 350, 240],
belowZoneWidths: [139, 395, 268],
};
// Display + camera + mic + scope + add: ~640px of labelled canvas controls,
// ~344px icon-only; the editor zone carries "Libraries" (~530 / ~420).
const xiao = {
inlineZoneWidths: [0, 530, 640],
inlineCompactZoneWidths: [0, 420, 344],
belowZoneWidths: [139, 530, 640],
};
describe('header strip layout', () => {
it('sums the zones plus the strip padding; available subtracts brand + margins', () => {
const i = { ...base, ...uno };
expect(stripNeededWidth(i, 'inline')).toBe(656);
expect(stripNeededWidth(i, 'inline-compact')).toBe(596);
expect(stripNeededWidth(i, 'below')).toBe(808);
expect(stripAvailableInline(i)).toBe(672);
expect(stripAvailableBelow(i)).toBe(1076);
});
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('keeps an Uno-sized strip labelled on the brand row at 1440 with the chat docked', () => {
expect(decideStripLayout({ ...base, ...uno })).toEqual({ below: false, compact: 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('goes icon-only on the brand row before dropping below', () => {
// 1300px window with the chat: 904 content, 532 for the strip.
const i = { ...base, contentWidth: 904, ...uno };
expect(stripAvailableInline(i)).toBe(532);
// labelled 656 does not fit, compact 596 does not either -> below
expect(decideStripLayout(i)).toEqual({ below: true, compact: false });
// a slightly narrower compact strip fits inline -> one row, icons
expect(decideStripLayout({ ...i, inlineCompactZoneWidths: [0, 300, 220] })).toEqual({
below: false,
compact: true,
});
});
it('drops a XIAO-sized strip below AND compacts it when the full bar cannot hold the labels', () => {
// 1440 with the chat: 1076px bar, 1315px labelled on that bar -> compact.
expect(decideStripLayout({ ...base, ...xiao })).toEqual({ below: true, compact: true });
// The same board on a 1920 window with the chat: 1524 content ->
// 1152 inline (labels 1176 no, compact 770 yes) -> one row, icons.
expect(decideStripLayout({ ...base, contentWidth: 1524, ...xiao })).toEqual({
below: false,
compact: true,
});
});
it('judges the own-bar by the widths the strip has THERE (the view toggle reappears)', () => {
// 1300 with the chat: 532 inline (no), 936 own-bar; measured on the brand
// row the strip would seem to fit the bar (656 <= 936) — but there the
// hidden view-mode toggle comes back and the bar wraps in two.
const i = { ...base, contentWidth: 904, ...uno, belowZoneWidths: [139, 530, 268] };
expect(decideStripLayout(i)).toEqual({ below: true, compact: 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);
// 1000px window without the chat: the old media query forced two rows
// below 1040px; a strip whose labels fit stays on one row.
const noChat = { ...base, contentWidth: 968 };
expect(
decideStripLayout({
...noChat,
inlineZoneWidths: [0, 322, 268],
inlineCompactZoneWidths: [0, 300, 240],
belowZoneWidths: [139, 395, 268],
}),
).toEqual({ below: false, compact: false });
});
it('treats an exact fit as fitting', () => {
expect(stripMustDropBelow({ ...base, zoneWidths: [0, 380, 268] })).toBe(false);
expect(decideStripLayout({ ...base, ...uno, inlineZoneWidths: [0, 398, 268] })).toEqual({
below: false,
compact: false,
});
});
});

View File

@ -173,16 +173,16 @@
/* Same two collapses when the toolbar rides inside the shared desktop bar
(App.css turns the wrapper's own containment off there so the bar can
wrap instead of overlap): key them to the BAR's width, at the thresholds
where the canvas controls drop their labels (1010px) and the board
selector shrinks (905px), so the whole row degrades in step. */
@container unified-toolbar (max-width: 1010px) {
.tb-libraries-label {
display: none;
}
.tb-btn-libraries {
padding: 0 6px;
}
wrap instead of overlap). The Libraries label goes with the first tier —
the `unified-toolbar--compact` class AppHeader sets when it measures that
the labelled strip does not fit (see SimulatorCanvas.css) — and the board
pill label with the bar's own 905px tier, so the whole row degrades in
step. */
.unified-toolbar.unified-toolbar--compact .tb-libraries-label {
display: none;
}
.unified-toolbar.unified-toolbar--compact .tb-btn-libraries {
padding: 0 6px;
}
@container unified-toolbar (max-width: 905px) {
.tb-board-pill-label {

View File

@ -7,7 +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 { applyStripLayout, STRIP_BELOW_CLASS } from './headerStripFit';
import './LanguageSwitcher.css';
const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio';
@ -41,28 +41,21 @@ 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.
// Editor variant: where does the toolbar strip go — on the brand row or
// on its own bar below, labelled or icon-only? Measured, not guessed —
// see headerStripFit.ts, which sets `app-header--strip-below` on the
// header and `unified-toolbar--compact` on the strip. Re-measured
// whenever the strip host, the brand block or a strip zone resizes
// (window, docked chat, board controls mounting). The first measure runs
// before paint; later ones are deferred a frame so toggling the classes
// 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));
applyStripLayout(header);
};
apply();
if (typeof ResizeObserver === 'undefined') return;
@ -95,7 +88,7 @@ export const AppHeader: React.FC<AppHeaderProps> = ({ editorMenu, editorToolbar
ro.disconnect();
mo?.disconnect();
if (raf) cancelAnimationFrame(raf);
header.classList.remove(CLS);
header.classList.remove(STRIP_BELOW_CLASS);
};
}, [hasStrip]);

View File

@ -1,17 +1,21 @@
/**
* Does the editor toolbar strip fit on the header's first row?
* How the editor toolbar strip is laid out in the header: on the brand row
* or on its own full-width bar below it, and with labels or icon-only.
*
* 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.
* it and it must drop to a full-width second bar; and before that, its
* labels (Serial / Camera / Mic / Scope / Add / Libraries) go icon-only.
* Deciding either with media / container queries meant guessing widths
* (1040 / 1060 / 1400px by chat state; 1010px for the labels) that were
* only right for one board: a XIAO with display + camera + mic + scope
* needs a wider strip than an Uno, so at 1440px with the chat docked the
* strip wrapped INSIDE the header — an orphan group of canvas controls
* floating in a tall header next to a black hole — and, once dropped
* below, kept its labels at 1060px and wrapped that bar in two as well.
*
* So the header measures instead. Pure function over the DOM so it can be
* unit-tested; `AppHeader` wires it to a ResizeObserver.
* So the header measures instead. Pure decision over measured widths so it
* can be unit-tested; `AppHeader` wires it to a ResizeObserver.
*/
export interface StripFitInput {
@ -19,34 +23,81 @@ export interface StripFitInput {
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 margins of the strip host (`.header-editor-toolbar`) on the
* brand row (positive) and as its own bar (negative: it bleeds into the
* header padding). */
hostMarginXInline: number;
hostMarginXBelow: 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 width of each strip zone at its own content size, measured IN
* the candidate layout it is compared against — the zones carry their
* own container queries (the view-mode toggle hides under 760px, its
* labels under 1140px, the board pill under 905px), so the same strip
* is wider on the wide own-bar than on the narrow brand row. */
inlineZoneWidths: readonly number[];
inlineCompactZoneWidths: readonly number[];
belowZoneWidths: 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;
export interface StripLayout {
/** The strip drops to a full-width bar under the brand row. */
below: boolean;
/** The strip's first collapse tier (labels -> icons) is forced on. */
compact: boolean;
}
/** Width the first row can give the strip next to the brand + menus. */
export function stripAvailableWidth(i: StripFitInput): number {
return i.contentWidth - i.leftWidth;
const sum = (xs: readonly number[]) => xs.reduce((a, b) => a + b, 0);
/** Natural single-row width the strip needs in a candidate layout. */
export function stripNeededWidth(
i: StripFitInput,
layout: 'inline' | 'inline-compact' | 'below' = 'inline',
): number {
const zones =
layout === 'inline'
? i.inlineZoneWidths
: layout === 'inline-compact'
? i.inlineCompactZoneWidths
: i.belowZoneWidths;
return sum(zones) + i.stripPaddingX;
}
/** True when the strip must drop below the brand row. */
export function stripMustDropBelow(i: StripFitInput): boolean {
return stripNeededWidth(i) > stripAvailableWidth(i);
/** Width the strip gets on the brand row next to the brand + menus. */
export function stripAvailableInline(i: StripFitInput): number {
return i.contentWidth - i.leftWidth - i.hostMarginXInline;
}
/** Width the strip gets as its own bar. */
export function stripAvailableBelow(i: StripFitInput): number {
return i.contentWidth - i.hostMarginXBelow;
}
/**
* 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.
* One row beats two: stay on the brand row with labels if they fit, else
* icon-only if that fits; only then drop below — with labels if the full
* bar holds them, icon-only otherwise (past that the bar wraps, which is
* the phone regime).
*/
export function decideStripLayout(i: StripFitInput): StripLayout {
const inline = stripAvailableInline(i);
if (stripNeededWidth(i, 'inline') <= inline) return { below: false, compact: false };
if (stripNeededWidth(i, 'inline-compact') <= inline) return { below: false, compact: true };
return { below: true, compact: stripNeededWidth(i, 'below') > stripAvailableBelow(i) };
}
export const STRIP_BELOW_CLASS = 'app-header--strip-below';
export const STRIP_COMPACT_CLASS = 'unified-toolbar--compact';
/**
* Measure the live header in the three layouts the decision compares —
* brand-row with labels, brand-row compact, own-bar with labels — by
* toggling the two classes and reading widths, then restoring what was
* there. Zones are read at
* `flex: 0 0 auto` so a zone flexbox has grown to fill the row (the editor
* zone) reports its content width, not the width it happens to occupy.
* Everything runs synchronously before the rendering step: nothing paints
* in between and ResizeObserver never sees the transients.
*/
export function measureStripFit(header: HTMLElement): StripFitInput | null {
const content = header.querySelector<HTMLElement>(':scope > .header-content');
@ -55,22 +106,64 @@ export function measureStripFit(header: HTMLElement): StripFitInput | null {
const strip = host?.firstElementChild as HTMLElement | null | undefined;
if (!content || !left || !host || !strip) return null;
const hadBelow = header.classList.contains(STRIP_BELOW_CLASS);
const hadCompact = strip.classList.contains(STRIP_COMPACT_CLASS);
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 savedFlex = zones.map((z) => z.style.flex);
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,
const marginX = (el: HTMLElement) => {
const cs = getComputedStyle(el);
return px(cs.marginLeft) + px(cs.marginRight);
};
const zoneWidths = () => zones.map((z) => z.getBoundingClientRect().width);
try {
for (const z of zones) z.style.flex = '0 0 auto';
header.classList.remove(STRIP_BELOW_CLASS);
strip.classList.remove(STRIP_COMPACT_CLASS);
const inlineZoneWidths = zoneWidths();
const stripCs = getComputedStyle(strip);
const stripPaddingX = px(stripCs.paddingLeft) + px(stripCs.paddingRight);
const hostMarginXInline = marginX(host);
const contentWidth = content.clientWidth;
const leftWidth = left.getBoundingClientRect().width;
strip.classList.add(STRIP_COMPACT_CLASS);
const inlineCompactZoneWidths = zoneWidths();
strip.classList.remove(STRIP_COMPACT_CLASS);
header.classList.add(STRIP_BELOW_CLASS);
const belowZoneWidths = zoneWidths();
const hostMarginXBelow = marginX(host);
return {
contentWidth,
leftWidth,
hostMarginXInline,
hostMarginXBelow,
stripPaddingX,
inlineZoneWidths,
inlineCompactZoneWidths,
belowZoneWidths,
};
} finally {
zones.forEach((z, k) => {
z.style.flex = savedFlex[k];
});
header.classList.toggle(STRIP_BELOW_CLASS, hadBelow);
strip.classList.toggle(STRIP_COMPACT_CLASS, hadCompact);
}
}
/** Measure and apply: the two classes end up matching the decision. */
export function applyStripLayout(header: HTMLElement): StripLayout | null {
const m = measureStripFit(header);
if (!m) return null;
const layout = decideStripLayout(m);
header.classList.toggle(STRIP_BELOW_CLASS, layout.below);
const strip = header.querySelector<HTMLElement>(
':scope > .header-content > .header-editor-toolbar > *',
);
strip?.classList.toggle(STRIP_COMPACT_CLASS, layout.compact);
return layout;
}

View File

@ -446,44 +446,46 @@
mobile block does — but keyed to the bar's own width via a container query,
not the viewport, so it triggers on a wide screen with the chat open too.
Priority: labels -> icons, then drop the non-interactive count, then the
zoom buttons (mouse-wheel zoom still works). Mirrors the thresholds the
editor toolbar and view-mode toggle collapse at, so the whole bar degrades
together and nothing overlaps. */
@container unified-toolbar (max-width: 1010px) {
zoom buttons (mouse-wheel zoom still works). The finer tiers below stay
keyed to the bar's width; the FIRST tier (labels -> icons) is instead the
`unified-toolbar--compact` class, which AppHeader sets when it MEASURES
that the labelled strip does not fit the row it is on (headerStripFit.ts)
— a fixed 1010px threshold was right for one board's set of controls
only: a XIAO with camera + mic + scope kept its labels at 1060px and
wrapped the bar into two rows. */
.unified-toolbar.unified-toolbar--compact .canvas-header-left,
.unified-toolbar.unified-toolbar--compact .canvas-header-right {
/* Pack the toolbar buttons tighter once the AI chat steals horizontal
room, so Serial/Camera/Mic/Scope stop overlapping. */
.canvas-header-left,
.canvas-header-right {
gap: 4px;
}
.canvas-serial-btn {
padding: 0 8px;
font-size: 0; /* hide "Serial" / "Scope" text, keep the icon */
}
.canvas-serial-btn svg {
width: 18px;
height: 18px;
}
/* Same collapse as Serial: hide the "Camera" / "Mic" label, keep the icon.
gap: 0 drops the now-empty icon-to-label space. */
.canvas-cam-mic-btn {
padding: 0 8px;
gap: 0;
font-size: 0;
}
.add-component-btn {
padding: 0 9px;
font-size: 0; /* hide "Add" text, keep the icon */
}
.add-component-btn svg {
width: 18px;
height: 18px;
}
.board-selector {
max-width: 120px;
text-overflow: ellipsis;
overflow: hidden;
}
gap: 4px;
}
.unified-toolbar.unified-toolbar--compact .canvas-serial-btn {
padding: 0 8px;
font-size: 0; /* hide "Serial" / "Scope" text, keep the icon */
}
.unified-toolbar.unified-toolbar--compact .canvas-serial-btn svg {
width: 18px;
height: 18px;
}
/* Same collapse as Serial: hide the "Camera" / "Mic" label, keep the icon.
gap: 0 drops the now-empty icon-to-label space. */
.unified-toolbar.unified-toolbar--compact .canvas-cam-mic-btn {
padding: 0 8px;
gap: 0;
font-size: 0;
}
.unified-toolbar.unified-toolbar--compact .add-component-btn {
padding: 0 9px;
font-size: 0; /* hide "Add" text, keep the icon */
}
.unified-toolbar.unified-toolbar--compact .add-component-btn svg {
width: 18px;
height: 18px;
}
.unified-toolbar.unified-toolbar--compact .board-selector {
max-width: 120px;
text-overflow: ellipsis;
overflow: hidden;
}
@container unified-toolbar (max-width: 905px) {
.component-count {