feat(editor): fold the "..." menu into File — with its PRO pills

The toolbar's overflow button held four leftovers (Share/Embed, Sync to
GitHub, Upload firmware, Record simulation). They move into the File
menu, which is where a session-frequency action belongs, and the strip
loses one more button. The PRO pill travels with them — same style the
overflow used (now .emb-pro) — and the two File items that were always
premium (BOM, schematic image) finally show it too: users should know an
item is premium BEFORE clicking, not via a surprise upgrade prompt.

The pro actions keep firing the same window events the overflow items
fired (share/github-sync/record prompts), so the overlay's listeners are
untouched and OSS builds keep their silent no-op. Dead overflow CSS
removed with the button.
This commit is contained in:
David Montero Crespo 2026-07-30 19:18:37 +02:00
parent ffb4fc0159
commit 8868a3c585
5 changed files with 58 additions and 197 deletions

View File

@ -83,3 +83,18 @@
background: #3c3c3c;
margin: 4px 6px;
}
/* Same PRO pill the old toolbar overflow used (tb-overflow-pro) users
should know an item is premium BEFORE clicking, not via a surprise
upgrade prompt. */
.emb-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;
margin-left: 8px;
}

View File

@ -31,7 +31,7 @@ import {
import './EditorMenuBar.css';
type Item =
| { kind: 'command'; id: EditorCommandId; label: string; shortcut?: string }
| { kind: 'command'; id: EditorCommandId; label: string; shortcut?: string; pro?: boolean }
| { kind: 'link'; href: string; label: string }
| { kind: 'separator' };
@ -89,12 +89,20 @@ export const EditorMenuBar: React.FC = () => {
{ kind: 'separator' },
{ kind: 'command', id: 'project.import', label: t('editor.toolbar.importLabel', 'Import project') },
{ kind: 'command', id: 'project.export', label: t('editor.toolbar.exportLabel', 'Export project (.zip)') },
{ kind: 'command', id: 'project.exportBom', label: t('editor.toolbar.exportBomLabel', 'Bill of Materials (CSV)') },
{ kind: 'command', id: 'project.exportBom', label: t('editor.toolbar.exportBomLabel', 'Bill of Materials (CSV)'), pro: true },
{
kind: 'command',
id: 'project.exportScreenshot',
label: t('editor.toolbar.exportScreenshotLabel', 'Schematic image (PNG)'),
pro: true,
},
{ kind: 'separator' },
// The toolbar's "..." menu folded in here — same actions, same PRO
// pills, one button fewer in the strip.
{ kind: 'command', id: 'project.share', label: t('editor.toolbar.shareLabel', 'Share / Embed') },
{ kind: 'command', id: 'project.githubSync', label: t('editor.toolbar.githubSyncLabel', 'Sync to GitHub'), pro: true },
{ kind: 'command', id: 'firmware.upload', label: t('editor.toolbar.uploadFirmwareLabel', 'Upload firmware') },
{ kind: 'command', id: 'sim.record', label: t('editor.toolbar.recordLabel', 'Record simulation'), pro: true },
];
const helpItems: Item[] = [
@ -142,7 +150,10 @@ export const EditorMenuBar: React.FC = () => {
runEditorCommand(item.id);
}}
>
<span>{item.label}</span>
<span>
{item.label}
{item.pro && <span className="emb-pro">PRO</span>}
</span>
{item.shortcut && <span className="emb-shortcut">{item.shortcut}</span>}
</button>
);

View File

@ -350,67 +350,11 @@
}
/* ── Overflow (…) menu ──────────────────────────── */
.tb-overflow-wrap {
position: relative;
}
.tb-btn-overflow {
color: #9d9d9d;
}
.tb-btn-overflow:hover {
background: rgba(255, 255, 255, 0.07);
color: #ccc;
}
.tb-btn-overflow-active {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.tb-overflow-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 200;
background: #2a2a2c;
border: 1px solid #3a3a3c;
border-radius: 10px;
padding: 6px;
min-width: 180px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
gap: 2px;
}
.tb-overflow-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
background: transparent;
border: none;
border-radius: 7px;
color: #ccc;
font-size: 13px;
font-family: inherit;
cursor: pointer;
text-align: left;
white-space: nowrap;
transition:
background 0.12s,
color 0.12s;
}
.tb-overflow-item:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.tb-overflow-item svg {
flex-shrink: 0;
color: var(--color-fg-default);
}
/* ── Run split-button (multi-board: primary Run = all boards + menu) ──── */
.tb-run-split {
@ -468,24 +412,10 @@
cursor: default;
}
.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) ── */
.toolbar-error-detail {

View File

@ -255,8 +255,6 @@ export const EditorToolbar = ({
const firmwareInputRef = useRef<HTMLInputElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [missingLibHint, setMissingLibHint] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const moreMenuRef = useRef<HTMLDivElement>(null);
// Split-button menu for the multi-board Run control ("Run all" / "Run active only").
const [runMenuOpen, setRunMenuOpen] = useState(false);
const runMenuRef = useRef<HTMLDivElement>(null);
@ -291,23 +289,6 @@ export const EditorToolbar = ({
return () => window.removeEventListener('velxio-circuit-fault', onFault);
}, [setCompileLogs]);
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]);
// Close the Run split-menu on outside click / Escape (mirrors the more-menu).
useEffect(() => {
@ -1393,24 +1374,40 @@ export const EditorToolbar = ({
// File-menu commands owned by this toolbar (the handlers close over its
// state). Registered through a latest-ref so the one-time registration
// always invokes the current render's closure, never a stale one.
const menuCommandsRef = useRef({
const makeMenuCommands = () => ({
import: () => importInputRef.current?.click(),
export: () => void handleExport(),
bom: () => void handleExportBom(),
screenshot: () => void handleExportScreenshot(),
firmware: () => firmwareInputRef.current?.click(),
// Pro actions fire the same window events the old "..." menu items
// fired; without the overlay they are silent no-ops, which is fine —
// OSS builds cannot have linked repos or shared projects anyway.
share: () =>
window.dispatchEvent(new CustomEvent('velxio-pro-share-prompt', {
detail: { projectId: currentProject?.id ?? null },
})),
githubSync: () =>
window.dispatchEvent(new CustomEvent('velxio-pro-github-sync-prompt', {
detail: { projectId: currentProject?.id ?? null },
})),
record: () =>
window.dispatchEvent(new CustomEvent('velxio-pro-replay-record-toggle', {
detail: { projectId: currentProject?.id ?? null },
})),
});
menuCommandsRef.current = {
import: () => importInputRef.current?.click(),
export: () => void handleExport(),
bom: () => void handleExportBom(),
screenshot: () => void handleExportScreenshot(),
};
const menuCommandsRef = useRef(makeMenuCommands());
menuCommandsRef.current = makeMenuCommands();
useEffect(() => {
const offs = [
registerEditorCommand('project.import', () => menuCommandsRef.current.import()),
registerEditorCommand('project.export', () => menuCommandsRef.current.export()),
registerEditorCommand('project.exportBom', () => menuCommandsRef.current.bom()),
registerEditorCommand('project.exportScreenshot', () => menuCommandsRef.current.screenshot()),
registerEditorCommand('firmware.upload', () => menuCommandsRef.current.firmware()),
registerEditorCommand('project.share', () => menuCommandsRef.current.share()),
registerEditorCommand('project.githubSync', () => menuCommandsRef.current.githubSync()),
registerEditorCommand('sim.record', () => menuCommandsRef.current.record()),
];
return () => offs.forEach((off) => off());
}, []);
@ -1757,104 +1754,8 @@ export const EditorToolbar = ({
a small "PRO" pill in the menu so users know they're
premium BEFORE clicking, instead of being surprised by an
upgrade prompt. */}
<div className="tb-overflow-wrap" ref={moreMenuRef}>
<button
onClick={() => setMoreMenuOpen((v) => !v)}
className={`tb-btn tb-btn-overflow${moreMenuOpen ? ' tb-btn-overflow-active' : ''}`}
title={t('editor.toolbar.more', 'More')}
aria-haspopup="true"
aria-expanded={moreMenuOpen}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="1.8" />
<circle cx="12" cy="12" r="1.8" />
<circle cx="19" cy="12" r="1.8" />
</svg>
</button>
{moreMenuOpen && (
<div className="tb-overflow-menu" role="menu">
<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>
{/* Sync to GitHub Pro feature. Fires a window event the
pro overlay listens for; if no overlay is loaded (OSS
build) the click is a silent no-op which is fine
OSS users can't have linked repos anyway. */}
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
window.dispatchEvent(new CustomEvent('velxio-pro-github-sync-prompt', {
detail: { projectId: currentProject?.id ?? null },
}));
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.21 11.39.6.11.82-.26.82-.58 0-.29-.01-1.05-.02-2.06-3.34.72-4.04-1.61-4.04-1.61-.55-1.38-1.33-1.75-1.33-1.75-1.09-.74.08-.72.08-.72 1.2.08 1.84 1.24 1.84 1.24 1.07 1.84 2.81 1.31 3.5 1 .11-.78.42-1.31.76-1.62-2.66-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.11-3.18 0 0 1.01-.32 3.3 1.23A11.5 11.5 0 0 1 12 5.8c1.02.01 2.05.14 3.01.4 2.29-1.55 3.3-1.23 3.3-1.23.65 1.66.24 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.62-5.49 5.92.43.37.82 1.1.82 2.22 0 1.6-.02 2.89-.02 3.29 0 .32.22.7.83.58A12 12 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.githubSyncLabel', 'Sync to GitHub')}</span>
<span className="tb-overflow-pro">PRO</span>
</button>
{/* Share / Embed free for all users with a public project.
Watermark removal on the embed is the Pro perk; the
Share modal itself is open to everyone so they can
copy the link / iframe snippet. */}
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
window.dispatchEvent(new CustomEvent('velxio-pro-share-prompt', {
detail: { projectId: currentProject?.id ?? null },
}));
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.shareLabel', 'Share / Embed')}</span>
</button>
{/* Record simulation Pro feature. Dispatches a toggle the
pro overlay handles (plan check, board-type check,
start/stop the recorder). OSS build no listener
silent no-op. */}
<button
className="tb-overflow-item"
role="menuitem"
onClick={() => {
setMoreMenuOpen(false);
window.dispatchEvent(new CustomEvent('velxio-pro-replay-record-toggle', {
detail: { projectId: currentProject?.id ?? null },
}));
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="7" />
</svg>
<span className="tb-overflow-label">{t('editor.toolbar.recordLabel', 'Record simulation')}</span>
<span className="tb-overflow-pro">PRO</span>
</button>
</div>
)}
</div>
{/* The "..." menu is gone: every item it held now lives in the
File menu (with PRO pills where they apply). */}
<div className="tb-divider" />
{/* Output Console toggle */}

View File

@ -25,6 +25,10 @@ export type EditorCommandId =
| 'project.exportBom'
| 'project.exportScreenshot'
| 'file.new'
| 'project.share'
| 'project.githubSync'
| 'firmware.upload'
| 'sim.record'
| 'view.reset'
| 'view.zoomIn'
| 'view.zoomOut';