diff --git a/frontend/src/__tests__/header-strip-fit.test.ts b/frontend/src/__tests__/header-strip-fit.test.ts index 0915d209..a4db1bd7 100644 --- a/frontend/src/__tests__/header-strip-fit.test.ts +++ b/frontend/src/__tests__/header-strip-fit.test.ts @@ -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, + }); }); }); diff --git a/frontend/src/components/editor/EditorToolbar.css b/frontend/src/components/editor/EditorToolbar.css index 5c068480..06342577 100644 --- a/frontend/src/components/editor/EditorToolbar.css +++ b/frontend/src/components/editor/EditorToolbar.css @@ -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 { diff --git a/frontend/src/components/layout/AppHeader.tsx b/frontend/src/components/layout/AppHeader.tsx index 67bccc2a..27221350 100644 --- a/frontend/src/components/layout/AppHeader.tsx +++ b/frontend/src/components/layout/AppHeader.tsx @@ -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 = ({ 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(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 = ({ editorMenu, editorToolbar ro.disconnect(); mo?.disconnect(); if (raf) cancelAnimationFrame(raf); - header.classList.remove(CLS); + header.classList.remove(STRIP_BELOW_CLASS); }; }, [hasStrip]); diff --git a/frontend/src/components/layout/headerStripFit.ts b/frontend/src/components/layout/headerStripFit.ts index d8e40c04..62bd549b 100644 --- a/frontend/src/components/layout/headerStripFit.ts +++ b/frontend/src/components/layout/headerStripFit.ts @@ -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(':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( + ':scope > .header-content > .header-editor-toolbar > *', + ); + strip?.classList.toggle(STRIP_COMPACT_CLASS, layout.compact); + return layout; } diff --git a/frontend/src/components/simulator/SimulatorCanvas.css b/frontend/src/components/simulator/SimulatorCanvas.css index 9263217a..7ab58d99 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.css +++ b/frontend/src/components/simulator/SimulatorCanvas.css @@ -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 {