feat(editor): toolbar rides inside the header — one row, not two

With the marketing nav gone from the editor header, its middle was ~700px
of dead space at 1280 while the toolbar occupied a whole second 38px bar.
The unified toolbar strip now fills that middle through a new AppHeader
editorToolbar slot: one 44px row where there used to be 44+38 — a full
row returned to the code and the canvas.

The strip keeps its own class names, so everything that keyed on them
keeps working untouched: the container queries, the docked-chat
padding-right, and the internal flex-wrap. When it truly cannot fit, the
strip wraps and the header grows (height: auto on the modifier class)
instead of clipping or overlapping; brand and the right-side controls
stay pinned to the first line. Inside the header the strip drops its own
background and border so it reads as one bar, not a box within a bar.

Mobile keeps the previous layout (no strip; the mobile tab bar remains).
This commit is contained in:
David Montero Crespo 2026-07-30 18:56:24 +02:00
parent b70bb8ab83
commit ffb4fc0159
3 changed files with 128 additions and 58 deletions

View File

@ -477,3 +477,56 @@ body {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.velxio-ants { animation: none; } .velxio-ants { animation: none; }
} }
/* Editor: toolbar riding inside the header
With the marketing nav gone from the editor header, its middle was dead
space while the toolbar occupied a whole second bar. The strip now fills
that middle: one 44px row instead of 44+38 a full row returned to the
code/canvas. The strip keeps its own class names (container queries, the
docked-chat padding-right) and wraps internally when it truly cannot
fit; the header then grows instead of clipping or overlapping. */
.app-header--with-toolbar {
height: auto;
min-height: 44px;
}
.header-editor-toolbar {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-self: stretch;
margin: 0 10px;
}
.header-editor-toolbar .unified-toolbar {
flex: 1 1 auto;
min-width: 0;
/* Skin: it is part of the header now, not a bar of its own. */
background: transparent;
border-bottom: none;
min-height: 44px;
align-items: center;
}
/* Keep the brand block and the right-side controls pinned to the first
line when the strip wraps the header taller. */
.app-header--with-toolbar .header-left,
.app-header--with-toolbar .header-right {
align-self: flex-start;
min-height: 44px;
display: flex;
align-items: center;
}
.app-header--with-toolbar .header-content {
align-items: stretch;
}
/* The editor zone / toolbar inside the strip: no own chrome, blend into
the header instead of painting a nested dark box with its own border. */
.header-editor-toolbar .editor-toolbar,
.header-editor-toolbar .unified-toolbar-editor .editor-toolbar {
background: transparent;
border-bottom: none;
height: 44px;
}

View File

@ -22,6 +22,11 @@ interface AppHeaderProps {
* toolbar is starved of on small screens; the logo still links home. * toolbar is starved of on small screens; the logo still links home.
* Same mechanism the Tauri desktop build uses (VITE_DESKTOP). */ * Same mechanism the Tauri desktop build uses (VITE_DESKTOP). */
editorMenu?: React.ReactNode; editorMenu?: React.ReactNode;
/** Editor variant: the unified toolbar strip rendered in the header's
* middle the space the marketing nav used to occupy. One row instead
* of header + toolbar stacked; the strip wraps internally when narrow
* and the header grows to fit (height: auto on the modifier class). */
editorToolbar?: React.ReactNode;
} }
const SAVE_STATUS_COPY: Record<AutoSaveState['status'], { label: string; color: string }> = { const SAVE_STATUS_COPY: Record<AutoSaveState['status'], { label: string; color: string }> = {
@ -68,7 +73,7 @@ const AutoSaveIndicator: React.FC<{ state: AutoSaveState }> = ({ state }) => {
); );
}; };
export const AppHeader: React.FC<AppHeaderProps> = ({ autoSave, editorMenu }) => { export const AppHeader: React.FC<AppHeaderProps> = ({ autoSave, editorMenu, editorToolbar }) => {
const location = useLocation(); const location = useLocation();
const currentProject = useProjectStore((s) => s.currentProject); const currentProject = useProjectStore((s) => s.currentProject);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@ -98,7 +103,7 @@ export const AppHeader: React.FC<AppHeaderProps> = ({ autoSave, editorMenu }) =>
location.pathname === localize(path) ? ' header-nav-link-active' : ''; location.pathname === localize(path) ? ' header-nav-link-active' : '';
return ( return (
<header className="app-header"> <header 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 */}
@ -216,6 +221,9 @@ export const AppHeader: React.FC<AppHeaderProps> = ({ autoSave, editorMenu }) =>
)} )}
</div> </div>
{/* Editor toolbar strip — fills the middle the nav vacated. */}
{editorToolbar && <div className="header-editor-toolbar">{editorToolbar}</div>}
{/* Right: language + share + auth + mobile hamburger */} {/* Right: language + share + auth + mobile hamburger */}
<div className="header-right"> <div className="header-right">
<LanguageSwitcher /> <LanguageSwitcher />

View File

@ -376,62 +376,16 @@ export const EditorPage: React.FC = () => {
[explorerWidth], [explorerWidth],
); );
return ( /* Unified toolbar (desktop)
<div className="app"> Editor controls + canvas controls in one strip; the canvas side is
<AppHeader autoSave={autoSave} editorMenu={!isMobile ? <EditorMenuBar /> : undefined} /> portaled into `canvasHeaderSlot` from SimulatorCanvas. Since the
marketing nav left the editor header, the header's middle is empty
{/* ── Mobile tab bar (top, above panels) ── */} space so this strip now rides INSIDE the AppHeader row (via the
{isMobile && ( editorToolbar prop) instead of being a second bar: one 44px row where
<nav className="mobile-tab-bar"> there used to be 44+38. On narrow widths the strip wraps internally
<button and the header grows; the docked AI chat is avoided by the same
className={`mobile-tab-btn${mobileView === 'code' ? ' mobile-tab-btn--active' : ''}`} padding-right the strip always had. */
onClick={() => setMobileView('code')} const unifiedToolbar = !isMobile ? (
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
<span>&lt;/&gt; {t('editor.shell.code')}</span>
</button>
<button
className={`mobile-tab-btn${mobileView === 'circuit' ? ' mobile-tab-btn--active' : ''}`}
onClick={() => setMobileView('circuit')}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="7" width="20" height="14" rx="2" />
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
<line x1="12" y1="12" x2="12" y2="16" />
<line x1="10" y1="14" x2="14" y2="14" />
</svg>
<span>{t('editor.shell.circuit')}</span>
</button>
</nav>
)}
{/* Unified top toolbar (desktop only)
Editor controls + canvas controls share a single full-width row so
the bar doesn't reflow when the editor/canvas splitter is dragged.
The canvas controls (board selector, Serial, Scope, zoom, Add) are
portaled into `canvasHeaderSlot` from inside SimulatorCanvas. */}
{!isMobile && (
<div className="unified-toolbar"> <div className="unified-toolbar">
<button <button
className="explorer-toggle-btn unified-toolbar-explorer-toggle" className="explorer-toggle-btn unified-toolbar-explorer-toggle"
@ -524,8 +478,63 @@ export const EditorPage: React.FC = () => {
</div> </div>
<div className="unified-toolbar-canvas" ref={setCanvasHeaderSlot} /> <div className="unified-toolbar-canvas" ref={setCanvasHeaderSlot} />
</div> </div>
) : undefined;
return (
<div className="app">
<AppHeader
autoSave={autoSave}
editorMenu={!isMobile ? <EditorMenuBar /> : undefined}
editorToolbar={unifiedToolbar}
/>
{/* ── Mobile tab bar (top, above panels) ── */}
{isMobile && (
<nav className="mobile-tab-bar">
<button
className={`mobile-tab-btn${mobileView === 'code' ? ' mobile-tab-btn--active' : ''}`}
onClick={() => setMobileView('code')}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
<span>&lt;/&gt; {t('editor.shell.code')}</span>
</button>
<button
className={`mobile-tab-btn${mobileView === 'circuit' ? ' mobile-tab-btn--active' : ''}`}
onClick={() => setMobileView('circuit')}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="7" width="20" height="14" rx="2" />
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
<line x1="12" y1="12" x2="12" y2="16" />
<line x1="10" y1="14" x2="14" y2="14" />
</svg>
<span>{t('editor.shell.circuit')}</span>
</button>
</nav>
)} )}
<div className="app-container" ref={containerRef}> <div className="app-container" ref={containerRef}>
{/* ── Editor side ── */} {/* ── Editor side ── */}
<div <div