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 * The complaint this fixes: with the AI chat docked at 1440px, a board with
* many canvas controls (XIAO: display + camera + mic + scope) wrapped the * many canvas controls (XIAO: display + camera + mic + scope) wrapped the
* strip INSIDE the header — canvas controls orphaned on a second line next * 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 * to a black hole — because the drop-below fallback and the label collapse
* query thresholds instead of the strip's real width. These lock the rule: * fired on guessed thresholds instead of the strip's real width. These
* needed width vs available width, nothing else. * 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 { describe, it, expect } from 'vitest';
import { import {
stripAvailableWidth, decideStripLayout,
stripMustDropBelow, stripAvailableBelow,
stripAvailableInline,
stripNeededWidth, stripNeededWidth,
} from '../components/layout/headerStripFit'; } from '../components/layout/headerStripFit';
const base = { const base = {
contentWidth: 1044, // 1440 minus 16px header padding minus 380px docked chat contentWidth: 1044, // 1440 minus 16px header padding minus 380px docked chat
leftWidth: 370, // brand + File/Edit/View/Account/Help leftWidth: 352, // brand + File/Edit/View/Account/Help
hostMarginX: 20, // .header-editor-toolbar { margin: 0 10px } 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 stripPaddingX: 6, // in-header strip pads 6px right
}; };
describe('header strip fit', () => { // Zone widths per candidate layout: [view-mode toggle, editor zone, canvas
it('sums the zones plus the strip and host chrome', () => { // controls]. On the narrow brand row the toggle is hidden by its own
expect(stripNeededWidth({ ...base, zoneWidths: [0, 382, 268] })).toBe(676); // container query (0); on the wide own-bar it shows (139).
expect(stripAvailableWidth({ ...base, zoneWidths: [] })).toBe(674); 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', () => { it('keeps an Uno-sized strip labelled on the brand row at 1440 with the chat docked', () => {
// 0 = the view-mode toggle hidden by its container query. expect(decideStripLayout({ ...base, ...uno })).toEqual({ below: false, compact: false });
expect(stripMustDropBelow({ ...base, zoneWidths: [0, 382, 266] })).toBe(false);
}); });
it('drops a XIAO-sized strip below at the same width', () => { it('goes icon-only on the brand row before dropping below', () => {
// Display + camera + mic + scope + add: ~344px of canvas controls. // 1300px window with the chat: 904 content, 532 for the strip.
expect(stripMustDropBelow({ ...base, zoneWidths: [0, 420, 344] })).toBe(true); 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', () => { it('is decided by the real widths, not a viewport threshold', () => {
// Same 1000px window without the chat: the old media query forced two // 1000px window without the chat: the old media query forced two rows
// rows below 1040px; a strip whose labels have collapsed fits fine. // below 1040px; a strip whose labels fit stays on one row.
const noChat = { ...base, contentWidth: 968, leftWidth: 352 }; const noChat = { ...base, contentWidth: 968 };
expect(stripMustDropBelow({ ...noChat, zoneWidths: [0, 322, 268] })).toBe(false); expect(
// ...and a wider one on the same window does not. decideStripLayout({
expect(stripMustDropBelow({ ...noChat, zoneWidths: [139, 395, 268] })).toBe(true); ...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', () => { 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 /* 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 (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 wrap instead of overlap). The Libraries label goes with the first tier —
where the canvas controls drop their labels (1010px) and the board the `unified-toolbar--compact` class AppHeader sets when it measures that
selector shrinks (905px), so the whole row degrades in step. */ the labelled strip does not fit (see SimulatorCanvas.css) — and the board
@container unified-toolbar (max-width: 1010px) { pill label with the bar's own 905px tier, so the whole row degrades in
.tb-libraries-label { step. */
display: none; .unified-toolbar.unified-toolbar--compact .tb-libraries-label {
} display: none;
.tb-btn-libraries { }
padding: 0 6px; .unified-toolbar.unified-toolbar--compact .tb-btn-libraries {
} padding: 0 6px;
} }
@container unified-toolbar (max-width: 905px) { @container unified-toolbar (max-width: 905px) {
.tb-board-pill-label { .tb-board-pill-label {

View File

@ -7,7 +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 { applyStripLayout, STRIP_BELOW_CLASS } from './headerStripFit';
import './LanguageSwitcher.css'; import './LanguageSwitcher.css';
const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio'; const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio';
@ -41,28 +41,21 @@ 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, // Editor variant: where does the toolbar strip go — on the brand row or
// not guessed — see headerStripFit.ts. `app-header--strip-below` moves // on its own bar below, labelled or icon-only? Measured, not guessed —
// it to a full-width second bar. Re-measured whenever the strip host, // see headerStripFit.ts, which sets `app-header--strip-below` on the
// the brand block or a strip zone resizes (window, docked chat, board // header and `unified-toolbar--compact` on the strip. Re-measured
// controls mounting, label collapses). The first measure runs before // whenever the strip host, the brand block or a strip zone resizes
// paint; later ones are deferred a frame so toggling the class never // (window, docked chat, board controls mounting). The first measure runs
// re-enters ResizeObserver delivery. // before paint; later ones are deferred a frame so toggling the classes
// never re-enters ResizeObserver delivery.
const headerRef = useRef<HTMLElement>(null); const headerRef = useRef<HTMLElement>(null);
const hasStrip = !!editorToolbar; const hasStrip = !!editorToolbar;
useLayoutEffect(() => { useLayoutEffect(() => {
const header = headerRef.current; const header = headerRef.current;
if (!header || !hasStrip) return; if (!header || !hasStrip) return;
const CLS = 'app-header--strip-below';
const apply = () => { const apply = () => {
// Always measure the INLINE layout — the question is "would it fit on applyStripLayout(header);
// 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(); apply();
if (typeof ResizeObserver === 'undefined') return; if (typeof ResizeObserver === 'undefined') return;
@ -95,7 +88,7 @@ export const AppHeader: React.FC<AppHeaderProps> = ({ editorMenu, editorToolbar
ro.disconnect(); ro.disconnect();
mo?.disconnect(); mo?.disconnect();
if (raf) cancelAnimationFrame(raf); if (raf) cancelAnimationFrame(raf);
header.classList.remove(CLS); header.classList.remove(STRIP_BELOW_CLASS);
}; };
}, [hasStrip]); }, [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 * 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 * 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 * it and it must drop to a full-width second bar; and before that, its
* queries meant guessing thresholds (1040 / 1060 / 1400px by chat state) * labels (Serial / Camera / Mic / Scope / Add / Libraries) go icon-only.
* that were only right for one board: a XIAO with display + camera + mic + * Deciding either with media / container queries meant guessing widths
* scope controls needs a wider strip than an Uno, so at 1440px with the * (1040 / 1060 / 1400px by chat state; 1010px for the labels) that were
* chat docked the strip wrapped INSIDE the header instead — an orphan * only right for one board: a XIAO with display + camera + mic + scope
* group of canvas controls floating in a tall header next to a black hole. * 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 * So the header measures instead. Pure decision over measured widths so it
* unit-tested; `AppHeader` wires it to a ResizeObserver. * can be unit-tested; `AppHeader` wires it to a ResizeObserver.
*/ */
export interface StripFitInput { export interface StripFitInput {
@ -19,34 +23,81 @@ export interface StripFitInput {
contentWidth: number; contentWidth: number;
/** Width of `.header-left` (brand + menus), pinned to the first row. */ /** Width of `.header-left` (brand + menus), pinned to the first row. */
leftWidth: number; leftWidth: number;
/** Horizontal margins of the strip host (`.header-editor-toolbar`). */ /** Horizontal margins of the strip host (`.header-editor-toolbar`) on the
hostMarginX: number; * 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`). */ /** Horizontal padding of the strip itself (`.unified-toolbar`). */
stripPaddingX: number; stripPaddingX: number;
/** Natural width of each strip zone at its own content size. */ /** Natural width of each strip zone at its own content size, measured IN
zoneWidths: readonly number[]; * 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 interface StripLayout {
export function stripNeededWidth(i: StripFitInput): number { /** The strip drops to a full-width bar under the brand row. */
return i.zoneWidths.reduce((a, b) => a + b, 0) + i.stripPaddingX + i.hostMarginX; 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. */ const sum = (xs: readonly number[]) => xs.reduce((a, b) => a + b, 0);
export function stripAvailableWidth(i: StripFitInput): number {
return i.contentWidth - i.leftWidth; /** 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. */ /** Width the strip gets on the brand row next to the brand + menus. */
export function stripMustDropBelow(i: StripFitInput): boolean { export function stripAvailableInline(i: StripFitInput): number {
return stripNeededWidth(i) > stripAvailableWidth(i); 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 * One row beats two: stay on the brand row with labels if they fit, else
* zone that flexbox has grown to fill the row (the editor zone) reports its * icon-only if that fits; only then drop below — with labels if the full
* content width, not the width it happens to occupy — the styles are * bar holds them, icon-only otherwise (past that the bar wraps, which is
* restored before returning, and the whole thing runs before paint. * 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 { export function measureStripFit(header: HTMLElement): StripFitInput | null {
const content = header.querySelector<HTMLElement>(':scope > .header-content'); 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; const strip = host?.firstElementChild as HTMLElement | null | undefined;
if (!content || !left || !host || !strip) return null; 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 zones = Array.from(strip.children) as HTMLElement[];
const saved = zones.map((z) => z.style.flex); const savedFlex = 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 px = (v: string) => parseFloat(v) || 0;
const hostCs = getComputedStyle(host); const marginX = (el: HTMLElement) => {
const stripCs = getComputedStyle(strip); const cs = getComputedStyle(el);
return { return px(cs.marginLeft) + px(cs.marginRight);
contentWidth: content.clientWidth,
leftWidth: left.getBoundingClientRect().width,
hostMarginX: px(hostCs.marginLeft) + px(hostCs.marginRight),
stripPaddingX: px(stripCs.paddingLeft) + px(stripCs.paddingRight),
zoneWidths,
}; };
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, 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. 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 Priority: labels -> icons, then drop the non-interactive count, then the
zoom buttons (mouse-wheel zoom still works). Mirrors the thresholds the zoom buttons (mouse-wheel zoom still works). The finer tiers below stay
editor toolbar and view-mode toggle collapse at, so the whole bar degrades keyed to the bar's width; the FIRST tier (labels -> icons) is instead the
together and nothing overlaps. */ `unified-toolbar--compact` class, which AppHeader sets when it MEASURES
@container unified-toolbar (max-width: 1010px) { 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 /* Pack the toolbar buttons tighter once the AI chat steals horizontal
room, so Serial/Camera/Mic/Scope stop overlapping. */ room, so Serial/Camera/Mic/Scope stop overlapping. */
.canvas-header-left, gap: 4px;
.canvas-header-right { }
gap: 4px; .unified-toolbar.unified-toolbar--compact .canvas-serial-btn {
} padding: 0 8px;
.canvas-serial-btn { font-size: 0; /* hide "Serial" / "Scope" text, keep the icon */
padding: 0 8px; }
font-size: 0; /* hide "Serial" / "Scope" text, keep the icon */ .unified-toolbar.unified-toolbar--compact .canvas-serial-btn svg {
} width: 18px;
.canvas-serial-btn svg { height: 18px;
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. */
/* Same collapse as Serial: hide the "Camera" / "Mic" label, keep the icon. .unified-toolbar.unified-toolbar--compact .canvas-cam-mic-btn {
gap: 0 drops the now-empty icon-to-label space. */ padding: 0 8px;
.canvas-cam-mic-btn { gap: 0;
padding: 0 8px; font-size: 0;
gap: 0; }
font-size: 0; .unified-toolbar.unified-toolbar--compact .add-component-btn {
} padding: 0 9px;
.add-component-btn { font-size: 0; /* hide "Add" text, keep the icon */
padding: 0 9px; }
font-size: 0; /* hide "Add" text, keep the icon */ .unified-toolbar.unified-toolbar--compact .add-component-btn svg {
} width: 18px;
.add-component-btn svg { height: 18px;
width: 18px; }
height: 18px; .unified-toolbar.unified-toolbar--compact .board-selector {
} max-width: 120px;
.board-selector { text-overflow: ellipsis;
max-width: 120px; overflow: hidden;
text-overflow: ellipsis;
overflow: hidden;
}
} }
@container unified-toolbar (max-width: 905px) { @container unified-toolbar (max-width: 905px) {
.component-count { .component-count {