feat(editor-toolbar): in-place Pro upgrade prompt + progressive overflow menu

Replace the hard /pricing redirect on 402 with a window-dispatched
'velxio-pro-upgrade-prompt' event so private overlays can surface an
in-editor upgrade modal instead of bouncing the user out of context.

Move BOM, Schematic image and firmware upload buttons into a "..." More
menu next to the existing Export ZIP icon, freeing two button slots in
the inline toolbar.  Mark the two premium items with a small "PRO" pill
so free-plan users know they're gated before they click — Notion- /
Linear-style discoverability cue.

Also wire Import + Export ZIP to fall back into that same menu once the
toolbar container drops below 320 / 280 px (container queries on the
editor pane width).  Mobile / narrow-split layouts keep full feature
parity through the dropdown instead of overflowing into a horizontally
scrolling row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-05-29 07:41:05 +02:00
parent 309e2f8735
commit 8ecbe89609
2 changed files with 192 additions and 46 deletions

View File

@ -188,6 +188,38 @@
} }
} }
/* Progressive overflow
The overflow menu starts with three items (BOM, Schematic, Firmware)
plus two hidden ones (Import, Export) declared up top. As the toolbar
narrows we drop inline buttons into the menu instead, so mobile users
always reach every action they just open the "..." menu to find it.
Selectors are intentionally compound (.tb-overflow-menu .tb-) to beat
the later-defined `.tb-overflow-item { display: flex }` baseline.
Container width thresholds reflect the editor pane width (toolbar lives
inside it), not the viewport at a 50/50 desktop split a 1280px
viewport gives the toolbar ~376px to work with. */
.tb-overflow-menu .tb-overflow-import,
.tb-overflow-menu .tb-overflow-export {
display: none;
}
@container editor-toolbar (max-width: 320px) {
.tb-btn-import-inline {
display: none;
}
.tb-overflow-menu .tb-overflow-import {
display: flex;
}
}
@container editor-toolbar (max-width: 280px) {
.tb-btn-export-inline {
display: none;
}
.tb-overflow-menu .tb-overflow-export {
display: flex;
}
}
/* Legacy lib icon-only button (for overflow items) */ /* Legacy lib icon-only button (for overflow items) */
.tb-btn-lib { .tb-btn-lib {
color: #00b8d4; color: #00b8d4;
@ -427,6 +459,25 @@
color: #00b8d4; color: #00b8d4;
} }
.tb-overflow-label {
flex: 1;
text-align: left;
}
/* Pro pill signals premium items in the overflow menu BEFORE the user
clicks. Used in place of a hidden gate so free users aren't surprised
by an upgrade prompt on what looked like a free button. */
.tb-overflow-pro {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.5px;
padding: 2px 6px;
border-radius: 3px;
background: linear-gradient(135deg, #ffd54f 0%, #ffb300 100%);
color: #1a1a1a;
flex-shrink: 0;
}
/* ── Error detail bar (for long compiler errors) ── */ /* ── Error detail bar (for long compiler errors) ── */
.toolbar-error-detail { .toolbar-error-detail {
background: #1a0000; background: #1a0000;

View File

@ -148,6 +148,26 @@ export const EditorToolbar = ({
const firmwareInputRef = useRef<HTMLInputElement>(null); const firmwareInputRef = useRef<HTMLInputElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null); const toolbarRef = useRef<HTMLDivElement>(null);
const [missingLibHint, setMissingLibHint] = useState(false); const [missingLibHint, setMissingLibHint] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const moreMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!moreMenuOpen) return;
const onClickOutside = (e: MouseEvent) => {
if (moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) {
setMoreMenuOpen(false);
}
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMoreMenuOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
document.addEventListener('keydown', onEsc);
return () => {
document.removeEventListener('mousedown', onClickOutside);
document.removeEventListener('keydown', onEsc);
};
}, [moreMenuOpen]);
// Compile All / Run All — runs sequentially, logs to console (no dialog) // Compile All / Run All — runs sequentially, logs to console (no dialog)
const [compileAllRunning, setCompileAllRunning] = useState(false); const [compileAllRunning, setCompileAllRunning] = useState(false);
@ -897,7 +917,12 @@ export const EditorToolbar = ({
credentials: 'include', credentials: 'include',
}); });
if (resp.status === 402) { if (resp.status === 402) {
window.location.href = '/pricing?from=screenshot_export'; // Fire the in-place upgrade modal instead of bouncing to /pricing —
// keeps the user in the editor with full context. The pro overlay's
// UpgradeGate listens for this event and opens UpgradePromptModal.
window.dispatchEvent(new CustomEvent('velxio-pro-upgrade-prompt', {
detail: { componentName: 'Schematic screenshot export' },
}));
return; return;
} }
if (resp.status === 401) { if (resp.status === 401) {
@ -944,11 +969,12 @@ export const EditorToolbar = ({
credentials: 'include', credentials: 'include',
}); });
if (resp.status === 402) { if (resp.status === 402) {
// Pro-required. Bounce to /pricing with a hint so the page can // Fire the in-place upgrade modal instead of bouncing to /pricing —
// surface the right upgrade message. (The backend returned a // keeps the user in the editor with full context. The pro overlay's
// structured `detail.error = 'pro_required'` payload but for the // UpgradeGate listens for this event and opens UpgradePromptModal.
// MVP we just route to pricing.) window.dispatchEvent(new CustomEvent('velxio-pro-upgrade-prompt', {
window.location.href = '/pricing?from=bom_export'; detail: { componentName: 'BOM export' },
}));
return; return;
} }
if (resp.status === 401) { if (resp.status === 401) {
@ -1282,12 +1308,11 @@ export const EditorToolbar = ({
<span className="tb-libraries-label">{t('editor.toolbar.libraries.label')}</span> <span className="tb-libraries-label">{t('editor.toolbar.libraries.label')}</span>
</button> </button>
{/* Import zip was previously hidden in a 3-dot overflow menu; {/* Import zip inline by default; container query at narrow
inlined since there's space and the discoverability cost widths swaps this for the corresponding overflow-menu item. */}
outweighed the toolbar savings. */}
<button <button
onClick={() => importInputRef.current?.click()} onClick={() => importInputRef.current?.click()}
className="tb-btn" className="tb-btn tb-btn-import-inline"
title={t('editor.toolbar.import')} title={t('editor.toolbar.import')}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@ -1298,7 +1323,7 @@ export const EditorToolbar = ({
</button> </button>
<button <button
onClick={() => handleExport()} onClick={() => handleExport()}
className="tb-btn" className="tb-btn tb-btn-export-inline"
title={t('editor.toolbar.export')} title={t('editor.toolbar.export')}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@ -1307,41 +1332,111 @@ export const EditorToolbar = ({
<line x1="12" y1="3" x2="12" y2="15" /> <line x1="12" y1="3" x2="12" y2="15" />
</svg> </svg>
</button> </button>
<button {/* Overflow "More" menu collects the secondary actions
onClick={() => handleExportBom()} (BOM, Schematic image, Upload firmware) so the toolbar no
className="tb-btn" longer overflows on narrow widths. The two Pro items show
title={t('editor.toolbar.exportBom')} a small "PRO" pill in the menu so users know they're
> premium BEFORE clicking, instead of being surprised by an
{/* Spreadsheet / list icon distinguishes the BOM from the upgrade prompt. */}
generic project Export ZIP that sits next to it. */} <div className="tb-overflow-wrap" ref={moreMenuRef}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <button
<rect x="3" y="4" width="18" height="16" rx="2" /> onClick={() => setMoreMenuOpen((v) => !v)}
<line x1="3" y1="10" x2="21" y2="10" /> className={`tb-btn tb-btn-overflow${moreMenuOpen ? ' tb-btn-overflow-active' : ''}`}
<line x1="9" y1="4" x2="9" y2="20" /> title={t('editor.toolbar.more', 'More')}
</svg> aria-haspopup="true"
</button> aria-expanded={moreMenuOpen}
<button >
onClick={() => handleExportScreenshot()} <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
className="tb-btn" <circle cx="5" cy="12" r="1.8" />
title={t('editor.toolbar.exportScreenshot')} <circle cx="12" cy="12" r="1.8" />
> <circle cx="19" cy="12" r="1.8" />
{/* Camera/image icon — circuit-to-image export */} </svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> </button>
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" /> {moreMenuOpen && (
<circle cx="12" cy="13" r="4" /> <div className="tb-overflow-menu" role="menu">
</svg> {/* Responsive items hidden by default, shown via
</button> container query when the toolbar is too narrow to
<button keep their inline twins. Keeps mobile users from
onClick={() => firmwareInputRef.current?.click()} losing access to Import / Export entirely. */}
className="tb-btn" <button
title={t('editor.toolbar.uploadFirmware')} className="tb-overflow-item tb-overflow-import"
> role="menuitem"
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> onClick={() => {
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" /> setMoreMenuOpen(false);
<line x1="12" y1="15" x2="12" y2="22" /> importInputRef.current?.click();
<polyline points="8 18 12 22 16 18" /> }}
</svg> >
</button> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.importLabel', 'Import project')}</span>
</button>
<button
className="tb-overflow-item tb-overflow-export"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
handleExport();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.exportLabel', 'Export project (.zip)')}</span>
</button>
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
handleExportBom();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="16" rx="2" />
<line x1="3" y1="10" x2="21" y2="10" />
<line x1="9" y1="4" x2="9" y2="20" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.exportBomLabel', 'Bill of Materials (CSV)')}</span>
<span className="tb-overflow-pro">PRO</span>
</button>
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
handleExportScreenshot();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.exportScreenshotLabel', 'Schematic image (PNG)')}</span>
<span className="tb-overflow-pro">PRO</span>
</button>
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
firmwareInputRef.current?.click();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
<line x1="12" y1="15" x2="12" y2="22" />
<polyline points="8 18 12 22 16 18" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.uploadFirmwareLabel', 'Upload firmware')}</span>
</button>
</div>
)}
</div>
<div className="tb-divider" /> <div className="tb-divider" />