feat(editor): starter dialog v2 — circuit thumbnails, blank canvas behind, one ESP32 per chip

The starter cards now carry the same circuit thumbnails the examples
gallery uses (ExampleThumbnail → /examples-thumbs/<id>.webp with the
CircuitPreview SVG fallback): boards with a Blink example reuse its thumb,
the rest get freshly captured starter-<kind>.webp shots in the same folder
(XIAO S3/C3, Netduino 2, Pi 4/5; plus the missing xiao-esp32c6-blink and
xiao-rp2040-blink example thumbs).

The ESP32 section is now one card per chip generation with the Seeed XIAO
variant preferred (DevKit V1 / XIAO S3 / XIAO C3 / XIAO C6), and XIAO
RP2040 joins the Raspberry Pi section — replacing the previous
every-variant list and the separate XIAO section.

Fixes: a pristine /editor visit clears the hardcoded Uno starter BEFORE
showing the dialog (empty canvas behind; cancel now leaves a blank
workspace), and clearing the last board re-points the editor at a
nonexistent group so Monaco no longer keeps showing the deleted board's
sketch (removeBoard only re-points when another board remains).
This commit is contained in:
David Montero Crespo 2026-08-07 21:07:48 +02:00
parent 3bcb8d518e
commit 93c5c60d9c
10 changed files with 163 additions and 82 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -25,7 +25,7 @@
background: #1e1e1e;
border: 1px solid #444;
border-radius: 10px;
width: min(780px, 94vw);
width: min(920px, 94vw);
max-height: 86vh;
display: flex;
flex-direction: column;
@ -68,20 +68,20 @@
.new-project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
}
.new-project-card {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
text-align: left;
background: #2b2b2b;
border: 1px solid #3d3d3d;
border-radius: 8px;
padding: 10px 12px;
padding: 0;
overflow: hidden;
color: #ddd;
cursor: pointer;
font: inherit;
@ -93,12 +93,47 @@
border-color: #5a5a5a;
}
/* Circuit thumbnail — same 300x180 (5:3) frame as the gallery cards. */
.new-project-card-thumb {
display: block;
width: 100%;
aspect-ratio: 5 / 3;
background: #0a0a0c;
overflow: hidden;
flex-shrink: 0;
}
.new-project-card-thumb img,
.new-project-card-thumb svg {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.new-project-card-info {
display: flex;
flex-direction: column;
gap: 3px;
padding: 9px 12px 11px;
}
.new-project-card-blank {
border-style: dashed;
border-color: #4a4f58;
margin-top: 12px;
}
.new-project-card-thumb-blank {
display: flex;
align-items: center;
justify-content: center;
font-size: 42px;
font-weight: 300;
color: #4a4f58;
background: transparent;
}
.new-project-card-name {
font-weight: 600;
font-size: 13px;

View File

@ -1,11 +1,12 @@
/**
* NewProjectDialog — starter-template picker.
*
* Shown (a) on a pristine `/editor` visit, instead of silently dropping the
* user into the hardcoded Arduino Uno + LED starter, and (b) from the
* "New workspace" button / File menu entry. Offers a blank workspace plus a
* ready-to-run Blink starter per board family (Arduino, ESP32, Seeed XIAO,
* STM32, Raspberry Pi).
* Shown (a) on a pristine `/editor` visit (over an emptied canvas), and
* (b) from the "New workspace" button / File menu entry. Offers a blank
* workspace plus a ready-to-run Blink starter per board family — Arduino,
* ESP32 (one card per chip generation, XIAO variant preferred), STM32,
* Raspberry Pi — each card carrying the same circuit thumbnail the examples
* gallery uses (/examples-thumbs/<id>.webp, CircuitPreview fallback).
*
* Selecting a board loads its gallery Blink example when one exists (full
* wiring: 220Ω resistor + LED); boards without one get a fresh board whose
@ -31,9 +32,11 @@ import {
} from '../../lib/proBoardRegistry';
import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../../store/useSimulatorStore';
import { useProjectStore } from '../../store/useProjectStore';
import { useEditorStore } from '../../store/useEditorStore';
import { getLocaleFromPath, localizedPath } from '../../i18n/path';
import { loadExample } from '../../utils/loadExample';
import type { ExampleProject } from '../../data/examples';
import { ExampleThumbnail } from '../examples/ExampleThumbnail';
import { trackSelectBoard } from '../../utils/analytics';
import './NewProjectDialog.css';
@ -46,13 +49,6 @@ interface NewProjectDialogProps {
const BOARD_BLURBS: Record<string, string> = {
'arduino-uno': '8-bit AVR, 32KB flash, 14 digital I/O',
esp32: 'Xtensa LX6 dual-core, WiFi+BT, 38 GPIO (QEMU)',
'esp32-devkit-c-v4': 'ESP32 DevKit C V4, official Espressif (QEMU)',
'esp32-cam': 'ESP32 + 2MP camera, microSD (QEMU)',
'wemos-lolin32-lite': 'Compact ESP32, LiPo battery support (QEMU)',
'esp32-s3': 'Xtensa LX7 dual-core, WiFi+BT, AI accel (QEMU)',
'arduino-nano-esp32': 'Nano form-factor, ESP32-S3, RGB LED (QEMU)',
'esp32-c3': 'RISC-V single-core, WiFi+BLE, 22 GPIO (QEMU)',
'aitewinrobot-esp32c3-supermini': 'ESP32-C3 SuperMini (QEMU)',
'xiao-esp32-s3': 'Seeed XIAO tiny form, 8MB flash+PSRAM (QEMU)',
'xiao-esp32-c3': 'Seeed XIAO ESP32-C3 mini board (QEMU)',
'stm32-bluepill': 'STM32F103C8 Cortex-M3, 64KB flash, 37 GPIO (QEMU)',
@ -69,19 +65,6 @@ const BOARD_BLURBS: Record<string, string> = {
'raspberry-pi-5': 'ARM64 Cortex-A76 quad-core + RP1 I/O, Linux/Python (QEMU)',
};
const ESP32_BOARDS: BoardKind[] = [
'esp32',
'esp32-devkit-c-v4',
'esp32-cam',
'wemos-lolin32-lite',
'esp32-s3',
'arduino-nano-esp32',
'esp32-c3',
'aitewinrobot-esp32c3-supermini',
];
const XIAO_BOARDS: BoardKind[] = ['xiao-esp32-s3', 'xiao-esp32-c3'];
const STM32_BOARDS: BoardKind[] = [
'stm32-bluepill',
'stm32-blackpill',
@ -93,12 +76,47 @@ const STM32_BOARDS: BoardKind[] = [
'stm32-netduino2',
];
const PI_BOARDS: BoardKind[] = [
'raspberry-pi-pico',
'raspberry-pi-3',
'raspberry-pi-4',
'raspberry-pi-5',
];
/**
* Starter-card thumbnail: reuse the gallery's convention-based thumbs
* (/examples-thumbs/<id>.webp, CircuitPreview SVG fallback). Kinds with a
* Blink example use that example's thumb; the rest use a captured
* `starter-<kind>` shot living in the same folder.
*/
function thumbStubFor(kind: string, label: string): ExampleProject {
return {
id: PREFERRED_BLINK_EXAMPLE[kind] ?? `starter-${kind}`,
title: label,
description: '',
category: 'basics',
difficulty: 'beginner',
boardType: kind,
code: '',
components: [],
wires: [],
};
}
/**
* Empty the whole workspace: boards, components, wires, current project and
* every editor file group. Removing the LAST board leaves the editor's
* files/activeFileId mirror pointing at the deleted group (removeBoard only
* re-points when another board remains), so Monaco kept showing the dead
* sketch — pointing the editor at a nonexistent group blanks it.
*/
export function clearWorkspaceForStarter(): void {
// currentProject FIRST — same auto-save hazard loadExample documents:
// mutating the stores while a saved project is still "current" lets the
// debounced PUT overwrite that project with the emptied content.
useProjectStore.getState().clearCurrentProject();
const sim = useSimulatorStore.getState();
sim.boards.forEach((b) => sim.stopBoard(b.id));
sim.boards.map((b) => b.id).forEach((id) => sim.removeBoard(id));
sim.setComponents([]);
sim.setWires([]);
const editor = useEditorStore.getState();
Object.keys(editor.fileGroups).forEach((g) => editor.deleteFileGroup(g));
editor.setActiveGroup('');
}
/**
* Gallery Blink example per board kind, when one exists. Preferred ids first
@ -147,16 +165,7 @@ async function findBlinkExample(kind: string): Promise<ExampleProject | undefine
* LED (createFileGroup picks the per-board/language blink content).
*/
async function applyStarter(kind: string | 'blank'): Promise<void> {
// Clear currentProject FIRST — same auto-save hazard loadExample documents:
// mutating the stores while a saved project is still "current" lets the
// debounced PUT overwrite that project with the starter's content.
useProjectStore.getState().clearCurrentProject();
const sim = useSimulatorStore.getState();
sim.boards.forEach((b) => sim.stopBoard(b.id));
sim.boards.map((b) => b.id).forEach((id) => sim.removeBoard(id));
sim.setComponents([]);
sim.setWires([]);
clearWorkspaceForStarter();
if (kind !== 'blank') {
let example: ExampleProject | undefined;
@ -208,18 +217,37 @@ export const NewProjectDialog: React.FC<NewProjectDialogProps> = ({ isOpen, onCl
const sections = useMemo(() => {
const defs = listProBoards();
const overlay = (prefix: string) =>
defs
.filter((d) => d.kind.startsWith(prefix))
.map((d) => ({ kind: d.kind, blurb: d.description }));
const oss = (kinds: BoardKind[]) =>
kinds.map((k) => ({ kind: k as string, blurb: BOARD_BLURBS[k] ?? '' }));
const oss = (k: BoardKind) => ({ kind: k as string, blurb: BOARD_BLURBS[k] ?? '' });
// Overlay-registered kinds only exist when the private overlay mounted —
// an OSS build simply doesn't show those cards.
const pro = (k: string) => {
const d = defs.find((x) => x.kind === k);
return d ? [{ kind: d.kind, blurb: d.description }] : [];
};
return [
{ title: 'Arduino', entries: oss(['arduino-uno']) },
{ title: 'ESP32', entries: [...oss(ESP32_BOARDS), ...overlay('esp32')] },
{ title: 'Seeed Studio XIAO', entries: [...oss(XIAO_BOARDS), ...overlay('xiao')] },
{ title: 'STM32', entries: oss(STM32_BOARDS) },
{ title: 'Raspberry Pi', entries: oss(PI_BOARDS) },
{ title: 'Arduino', entries: [oss('arduino-uno')] },
// One card per ESP32 chip generation, XIAO variant preferred where
// Seeed makes one: classic → DevKit V1, S3/C3 → XIAO, C6 → XIAO (overlay).
{
title: 'ESP32',
entries: [
oss('esp32'),
oss('xiao-esp32-s3'),
oss('xiao-esp32-c3'),
...pro('xiao-esp32c6'),
],
},
{ title: 'STM32', entries: STM32_BOARDS.map(oss) },
{
title: 'Raspberry Pi',
entries: [
oss('raspberry-pi-pico'),
...pro('xiao-rp2040'),
oss('raspberry-pi-3'),
oss('raspberry-pi-4'),
oss('raspberry-pi-5'),
],
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [proBoardsVersion]);
@ -272,11 +300,14 @@ export const NewProjectDialog: React.FC<NewProjectDialogProps> = ({ isOpen, onCl
className="new-project-card new-project-card-blank"
onClick={() => handleSelect('blank')}
>
<span className="new-project-card-name">
{t('editor.newProject.blankTitle')}
</span>
<span className="new-project-card-desc">
{t('editor.newProject.blankDesc')}
<span className="new-project-card-thumb new-project-card-thumb-blank">+</span>
<span className="new-project-card-info">
<span className="new-project-card-name">
{t('editor.newProject.blankTitle')}
</span>
<span className="new-project-card-desc">
{t('editor.newProject.blankDesc')}
</span>
</span>
</button>
</div>
@ -286,19 +317,29 @@ export const NewProjectDialog: React.FC<NewProjectDialogProps> = ({ isOpen, onCl
<React.Fragment key={section.title}>
<div className="new-project-section-title">{section.title}</div>
<div className="new-project-grid">
{section.entries.map(({ kind, blurb }) => (
<button
key={kind}
className="new-project-card"
onClick={() => handleSelect(kind)}
>
{isProBoardKind(kind) && <ProPill />}
<span className="new-project-card-name">
{BOARD_KIND_LABELS[kind as BoardKind] ?? kind}
</span>
<span className="new-project-card-desc">{blurb}</span>
</button>
))}
{section.entries.map(({ kind, blurb }) => {
const label = BOARD_KIND_LABELS[kind as BoardKind] ?? kind;
return (
<button
key={kind}
className="new-project-card"
onClick={() => handleSelect(kind)}
>
<span className="new-project-card-thumb">
<ExampleThumbnail
example={thumbStubFor(kind, label)}
width={300}
height={180}
/>
</span>
{isProBoardKind(kind) && <ProPill />}
<span className="new-project-card-info">
<span className="new-project-card-name">{label}</span>
<span className="new-project-card-desc">{blurb}</span>
</span>
</button>
);
})}
</div>
</React.Fragment>
),

View File

@ -26,7 +26,10 @@ import { useEditorStore } from '../store/useEditorStore';
import { useCompileLogsStore } from '../store/useCompileLogsStore';
import { useOscilloscopeStore } from '../store/useOscilloscopeStore';
import { useProjectStore } from '../store/useProjectStore';
import { NewProjectDialog } from '../components/editor/NewProjectDialog';
import {
NewProjectDialog,
clearWorkspaceForStarter,
} from '../components/editor/NewProjectDialog';
import { useAutoSaveProject } from '../hooks/useAutoSaveProject';
import { registerEditorCommand } from '../lib/editorCommands';
import { EditorMenuBar } from '../components/editor/EditorMenuBar';
@ -110,12 +113,13 @@ export const EditorPage: React.FC = () => {
// Pristine-visit starter dialog: a bare /editor landing still holds the
// hardcoded Uno + LED starter (see useSimulatorStore INITIAL_BOARD /
// builtin components). Offer the template picker instead of silently
// dropping the user into the Arduino blink. Guarded so it never fires over
// a loaded project/example URL, a restored login draft (both leave the
// stores non-pristine), or twice per page load. Declared AFTER the
// restoreStashedWorkspace effect — mount order is what makes the
// pristine check see the restored draft.
// builtin components). Clear it and offer the template picker over an
// EMPTY canvas instead of silently dropping the user into the Arduino
// blink (cancelling the dialog leaves a blank workspace). Guarded so it
// never fires over a loaded project/example URL, a restored login draft
// (both leave the stores non-pristine), or twice per page load. Declared
// AFTER the restoreStashedWorkspace effect — mount order is what makes
// the pristine check see the restored draft.
useEffect(() => {
if (starterDialogShownThisLoad) return;
const locale = getLocaleFromPath(window.location.pathname);
@ -130,6 +134,7 @@ export const EditorPage: React.FC = () => {
sim.components.every((c) => c.id === 'led_builtin' || c.id === 'r_builtin');
if (!pristine) return;
starterDialogShownThisLoad = true;
clearWorkspaceForStarter();
setShowNewProjectDialog(true);
}, []);