diff --git a/README.md b/README.md index f30a5949..c0efe5b7 100644 --- a/README.md +++ b/README.md @@ -440,6 +440,12 @@ For commercial licensing inquiries: [davidmonterocrespo24@gmail.com](mailto:davi See [LICENSE](LICENSE) and [COMMERCIAL_LICENSE.md](COMMERCIAL_LICENSE.md) for full terms. +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=davidmonterocrespo24/velxio&type=Date)](https://star-history.com/#davidmonterocrespo24/velxio&Date) + +--- + ## References - [Wokwi](https://wokwi.com) — Inspiration diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a27464fd..58bb1384 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import { Esp32C3SimulatorPage } from './pages/Esp32C3SimulatorPage'; import { RaspberryPiPicoSimulatorPage } from './pages/RaspberryPiPicoSimulatorPage'; import { RaspberryPiSimulatorPage } from './pages/RaspberryPiSimulatorPage'; import { Velxio2Page } from './pages/Velxio2Page'; +import { AboutPage } from './pages/AboutPage'; import { useAuthStore } from './store/useAuthStore'; import './App.css'; @@ -52,6 +53,7 @@ function App() { } /> } /> } /> + } /> {/* Canonical project URL by ID */} } /> {/* Legacy slug route — redirects to /project/:id */} diff --git a/frontend/src/components/simulator/BoardOnCanvas.tsx b/frontend/src/components/simulator/BoardOnCanvas.tsx index 74eeb84f..e3d33a12 100644 --- a/frontend/src/components/simulator/BoardOnCanvas.tsx +++ b/frontend/src/components/simulator/BoardOnCanvas.tsx @@ -40,6 +40,7 @@ interface BoardOnCanvasProps { isActive?: boolean; onMouseDown: (e: React.MouseEvent) => void; onPinClick: (componentId: string, pinName: string, x: number, y: number) => void; + zoom?: number; } export const BoardOnCanvas = ({ @@ -49,6 +50,7 @@ export const BoardOnCanvas = ({ isActive = false, onMouseDown, onPinClick, + zoom = 1, }: BoardOnCanvasProps) => { const { id, boardKind, x, y } = board; const size = BOARD_SIZE[boardKind] ?? { w: 300, h: 200 }; @@ -156,6 +158,7 @@ export const BoardOnCanvas = ({ showPins={true} wrapperOffsetX={0} wrapperOffsetY={0} + zoom={zoom} /> ); diff --git a/frontend/src/components/simulator/PinOverlay.tsx b/frontend/src/components/simulator/PinOverlay.tsx index e50d3158..c45dc287 100644 --- a/frontend/src/components/simulator/PinOverlay.tsx +++ b/frontend/src/components/simulator/PinOverlay.tsx @@ -3,10 +3,22 @@ * * Renders clickable pin indicators over components to enable wire creation. * Shows when hovering over a component or when creating a wire. + * + * On touch devices the hit-target is scaled up inversely to the canvas zoom + * so the *screen-space* tap area stays at least ~40px regardless of zoom level. */ import React, { useEffect, useState } from 'react'; +/** Detect touch-capable device once */ +const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0); + +/** Minimum visual pin size in *world* pixels at zoom 1 */ +const PIN_VISUAL = 12; + +/** Desired minimum screen-space hit-target size for touch (px) */ +const TOUCH_MIN_SCREEN_PX = 44; + interface PinInfo { name: string; x: number; // CSS pixels @@ -23,6 +35,8 @@ interface PinOverlayProps { /** Extra offset to compensate for wrapper padding/border. Default: 4 (x), 6 (y) for component wrappers. Pass 0 when the element has no wrapper. */ wrapperOffsetX?: number; wrapperOffsetY?: number; + /** Current canvas zoom level — used to keep touch targets usable at any zoom */ + zoom?: number; } export const PinOverlay: React.FC = ({ @@ -33,6 +47,7 @@ export const PinOverlay: React.FC = ({ showPins, wrapperOffsetX = 4, wrapperOffsetY = 6, + zoom = 1, }) => { const [pins, setPins] = useState([]); @@ -56,6 +71,13 @@ export const PinOverlay: React.FC = ({ return null; } + // On touch devices, compute world-space size so the pin is at least + // TOUCH_MIN_SCREEN_PX on screen. On desktop, keep the original 12px. + const pinSize = isTouchDevice + ? Math.max(PIN_VISUAL, TOUCH_MIN_SCREEN_PX / zoom) + : PIN_VISUAL; + const pinHalf = pinSize / 2; + return (
= ({ }} > {pins.map((pin, index) => { - // Pin coordinates are already in CSS pixels const pinX = pin.x; const pinY = pin.y; @@ -81,20 +102,22 @@ export const PinOverlay: React.FC = ({ }} onTouchEnd={(e) => { e.stopPropagation(); + e.preventDefault(); onPinClick(componentId, pin.name, componentX + wrapperOffsetX + pinX, componentY + wrapperOffsetY + pinY); }} style={{ position: 'absolute', - left: `${pinX - 6}px`, - top: `${pinY - 6}px`, - width: '12px', - height: '12px', + left: `${pinX - pinHalf}px`, + top: `${pinY - pinHalf}px`, + width: `${pinSize}px`, + height: `${pinSize}px`, borderRadius: '3px', backgroundColor: 'rgba(0, 200, 255, 0.8)', border: '1.5px solid white', cursor: 'crosshair', pointerEvents: 'all', transition: 'all 0.15s', + touchAction: 'none', }} onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'rgba(0, 255, 100, 1)'; diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 61dadb17..bf2a77fc 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -23,6 +23,9 @@ import { renderedToWaypoints, renderedPointsToPath, } from '../../utils/wireHitDetection'; + +/** Detect touch-capable device once */ +const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0); import type { ComponentMetadata } from '../../types/component-metadata'; import type { BoardKind } from '../../types/board'; import { BOARD_KIND_LABELS } from '../../types/board'; @@ -396,6 +399,18 @@ export const SimulatorCanvas = () => { if (e.touches.length !== 1) return; const touch = e.touches[0]; + // ── Segment drag (wire editing) via touch ── + if (segmentDragRef.current) { + const world = toWorld(touch.clientX, touch.clientY); + const sd = segmentDragRef.current; + sd.isDragging = true; + const newValue = sd.axis === 'horizontal' ? world.y : world.x; + const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue); + const overridePath = renderedPointsToPath(newPts); + setSegmentDragPreview({ wireId: sd.wireId, overridePath }); + return; + } + // ── Wire preview: update position as finger moves ── if (wireInProgressRef.current && !isPanningRef.current && !touchDraggedComponentIdRef.current) { const world = toWorld(touch.clientX, touch.clientY); @@ -464,6 +479,24 @@ export const SimulatorCanvas = () => { if (e.touches.length > 0) return; // Still fingers on screen + // ── Finish segment drag (wire editing) via touch ── + if (segmentDragRef.current) { + const sd = segmentDragRef.current; + if (sd.isDragging) { + segmentDragJustCommittedRef.current = true; + const changed = e.changedTouches[0]; + if (changed) { + const world = toWorld(changed.clientX, changed.clientY); + const newValue = sd.axis === 'horizontal' ? world.y : world.x; + const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue); + updateWire(sd.wireId, { waypoints: renderedToWaypoints(newPts) }); + } + } + segmentDragRef.current = null; + setSegmentDragPreview(null); + return; + } + // ── Finish panning ── let wasPanning = false; if (isPanningRef.current) { @@ -480,7 +513,7 @@ export const SimulatorCanvas = () => { const dx = changed.clientX - touchClickStartPosRef.current.x; const dy = changed.clientY - touchClickStartPosRef.current.y; const dist = Math.sqrt(dx * dx + dy * dy); - const isShortTap = dist < 10 && elapsed < 400; + const isShortTap = dist < 20 && elapsed < 400; // If we actually panned (moved significantly), don't process as tap if (wasPanning && !isShortTap) return; @@ -530,7 +563,8 @@ export const SimulatorCanvas = () => { if (isShortTap) { const now = Date.now(); const world = toWorld(changed.clientX, changed.clientY); - const threshold = 8 / zoomRef.current; + const baseThreshold = isTouchDevice ? 20 : 8; + const threshold = baseThreshold / zoomRef.current; const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold); // Double-tap → delete wire @@ -961,6 +995,28 @@ export const SimulatorCanvas = () => { [selectedWireId], ); + // Handle touchstart on a segment handle circle (mobile wire editing) + const handleHandleTouchStart = useCallback( + (e: React.TouchEvent, segIndex: number) => { + e.stopPropagation(); + if (!selectedWireId) return; + const wire = wiresRef.current.find((w) => w.id === selectedWireId); + if (!wire) return; + const segments = getRenderedSegments(wire); + const seg = segments[segIndex]; + if (!seg) return; + const expandedPts = getRenderedPoints(wire); + segmentDragRef.current = { + wireId: wire.id, + segIndex, + axis: seg.axis, + renderedPts: expandedPts, + isDragging: false, + }; + }, + [selectedWireId], + ); + // Zoom centered on cursor const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); @@ -1135,6 +1191,7 @@ export const SimulatorCanvas = () => { componentY={component.y} onPinClick={handlePinClick} showPins={showPinsForComponent} + zoom={zoom} /> )} @@ -1325,6 +1382,7 @@ export const SimulatorCanvas = () => { segmentDragPreview={segmentDragPreview} segmentHandles={segmentHandles} onHandleMouseDown={handleHandleMouseDown} + onHandleTouchStart={handleHandleTouchStart} /> {/* All boards on canvas */} @@ -1343,6 +1401,7 @@ export const SimulatorCanvas = () => { setDragOffset({ x: world.x - board.x, y: world.y - board.y }); }} onPinClick={handlePinClick} + zoom={zoom} /> ))} diff --git a/frontend/src/components/simulator/WireLayer.tsx b/frontend/src/components/simulator/WireLayer.tsx index c2c59dc3..bf7d264c 100644 --- a/frontend/src/components/simulator/WireLayer.tsx +++ b/frontend/src/components/simulator/WireLayer.tsx @@ -3,6 +3,8 @@ import { useSimulatorStore } from '../../store/useSimulatorStore'; import { WireRenderer } from './WireRenderer'; import { WireInProgressRenderer } from './WireInProgressRenderer'; +const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0); + export interface SegmentHandle { segIndex: number; axis: 'horizontal' | 'vertical'; @@ -18,6 +20,8 @@ interface WireLayerProps { segmentHandles: SegmentHandle[]; /** Called when user starts dragging a handle (passes segIndex) */ onHandleMouseDown: (e: React.MouseEvent, segIndex: number) => void; + /** Called when user starts dragging a handle via touch (passes segIndex) */ + onHandleTouchStart?: (e: React.TouchEvent, segIndex: number) => void; } export const WireLayer: React.FC = ({ @@ -25,6 +29,7 @@ export const WireLayer: React.FC = ({ segmentDragPreview, segmentHandles, onHandleMouseDown, + onHandleTouchStart, }) => { const wires = useSimulatorStore((s) => s.wires); const wireInProgress = useSimulatorStore((s) => s.wireInProgress); @@ -64,12 +69,13 @@ export const WireLayer: React.FC = ({ key={handle.segIndex} cx={handle.mx} cy={handle.my} - r={7} + r={isTouchDevice ? 14 : 7} fill="white" stroke="#007acc" strokeWidth={2} - style={{ pointerEvents: 'all', cursor: handle.axis === 'horizontal' ? 'ns-resize' : 'ew-resize' }} + style={{ pointerEvents: 'all', cursor: handle.axis === 'horizontal' ? 'ns-resize' : 'ew-resize', touchAction: 'none' }} onMouseDown={(e) => onHandleMouseDown(e, handle.segIndex)} + onTouchStart={(e) => onHandleTouchStart?.(e, handle.segIndex)} /> ))} diff --git a/frontend/src/pages/AboutPage.css b/frontend/src/pages/AboutPage.css new file mode 100644 index 00000000..924b5a56 --- /dev/null +++ b/frontend/src/pages/AboutPage.css @@ -0,0 +1,490 @@ +/* ── AboutPage ────────────────────────────────────────── */ + +.about-page { + min-height: 100vh; + background: var(--bg, #0a0a0a); + color: var(--text, #e5e5e7); + font-family: var(--font, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); + -webkit-font-smoothing: antialiased; +} + +/* ── Hero ─────────────────────────────────────────────── */ +.about-hero { + padding: 100px 40px 60px; + text-align: center; + background: linear-gradient(180deg, rgba(0,113,227,0.08) 0%, transparent 100%); + border-bottom: 1px solid var(--border, #1d1d1f); +} + +.about-hero-inner { + max-width: 700px; + margin: 0 auto; +} + +.about-hero-title { + font-size: 48px; + font-weight: 700; + letter-spacing: -1.5px; + margin: 0 0 16px; + background: linear-gradient(135deg, #fff 0%, #999 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.about-hero-sub { + font-size: 18px; + color: #999; + line-height: 1.6; + margin: 0; +} + +/* ── Sections ────────────────────────────────────────── */ +.about-section { + padding: 72px 40px; +} + +.about-section-alt { + background: #0f0f0f; +} + +.about-container { + max-width: 800px; + margin: 0 auto; +} + +.about-heading { + font-size: 32px; + font-weight: 700; + letter-spacing: -0.8px; + margin: 0 0 28px; + color: #fff; +} + +.about-section p, +.about-story p { + font-size: 16px; + line-height: 1.75; + color: #aaa; + margin: 0 0 16px; +} + +.about-section a { + color: #0071e3; + text-decoration: none; +} + +.about-section a:hover { + text-decoration: underline; +} + +.about-section strong { + color: #ddd; +} + +/* ── Credits list ────────────────────────────────────── */ +.about-credits-list { + list-style: none; + padding: 0; + margin: 16px 0 24px; +} + +.about-credits-list li { + position: relative; + padding-left: 20px; + margin-bottom: 10px; + font-size: 15px; + color: #aaa; + line-height: 1.5; +} + +.about-credits-list li::before { + content: ''; + position: absolute; + left: 0; + top: 8px; + width: 8px; + height: 8px; + border-radius: 2px; + background: #0071e3; +} + +/* ── Architecture grid ───────────────────────────────── */ +.about-arch-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; +} + +.about-arch-card { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 12px; + padding: 28px; + transition: border-color 0.2s; +} + +.about-arch-card:hover { + border-color: #0071e3; +} + +.about-arch-icon { + width: 36px; + height: 36px; + margin-bottom: 14px; + color: #0071e3; +} + +.about-arch-icon svg { + width: 100%; + height: 100%; +} + +.about-arch-card h3 { + font-size: 17px; + font-weight: 600; + color: #fff; + margin: 0 0 8px; +} + +.about-arch-card p { + font-size: 14px; + line-height: 1.6; + color: #888; + margin: 0; +} + +/* ── Creator ─────────────────────────────────────────── */ +.about-creator { + display: flex; + gap: 40px; + align-items: flex-start; +} + +.about-creator-photo { + flex-shrink: 0; +} + +.about-creator-avatar { + width: 120px; + height: 120px; + border-radius: 50%; + background: linear-gradient(135deg, #0071e3 0%, #005bb5 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: 36px; + font-weight: 700; + color: #fff; + letter-spacing: 1px; +} + +.about-creator-name { + font-size: 26px; + font-weight: 700; + color: #fff; + margin: 0 0 6px; +} + +.about-creator-role { + font-size: 15px; + color: #888; + margin: 0 0 18px; +} + +.about-creator-bio { + font-size: 15px; + line-height: 1.7; + color: #aaa; + margin: 0 0 14px; +} + +.about-creator-stack { + margin-top: 24px; +} + +.about-creator-stack h4 { + font-size: 14px; + font-weight: 600; + color: #888; + text-transform: uppercase; + letter-spacing: 1px; + margin: 0 0 12px; +} + +.about-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.about-tag { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 6px; + padding: 4px 12px; + font-size: 13px; + color: #aaa; +} + +.about-creator-links { + display: flex; + gap: 16px; + margin-top: 24px; +} + +.about-social-link { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: 8px; + background: #1a1a1a; + border: 1px solid #2a2a2a; + color: #ccc; + font-size: 14px; + font-weight: 500; + text-decoration: none; + transition: border-color 0.2s, color 0.2s; +} + +.about-social-link:hover { + border-color: #0071e3; + color: #fff; + text-decoration: none; +} + +.about-social-link svg { + width: 16px; + height: 16px; +} + +/* ── Quote ────────────────────────────────────────────── */ +.about-quote { + border-left: 3px solid #0071e3; + padding: 24px 32px; + margin: 0; + background: rgba(0,113,227,0.04); + border-radius: 0 12px 12px 0; +} + +.about-quote p { + font-size: 17px; + line-height: 1.8; + color: #bbb; + margin: 0 0 12px; + font-style: italic; +} + +.about-quote em { + color: #ddd; +} + +.about-quote strong { + color: #fff; +} + +.about-quote cite { + display: block; + font-size: 14px; + color: #666; + font-style: normal; + margin-top: 16px; +} + +/* ── Stats ────────────────────────────────────────────── */ +.about-stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + margin-bottom: 40px; +} + +.about-stat { + text-align: center; + padding: 28px 16px; + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 12px; +} + +.about-stat-number { + display: block; + font-size: 36px; + font-weight: 700; + color: #0071e3; + letter-spacing: -1px; +} + +.about-stat-label { + display: block; + font-size: 13px; + color: #888; + margin-top: 6px; +} + +/* ── Press ────────────────────────────────────────────── */ +.about-press { + text-align: center; +} + +.about-press > p { + font-size: 14px; + color: #666; + margin-bottom: 16px; +} + +.about-press-list { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; +} + +.about-press-badge { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 8px; + padding: 8px 18px; + font-size: 14px; + color: #aaa; + text-decoration: none; + transition: border-color 0.2s; +} + +a.about-press-badge:hover { + border-color: #0071e3; + color: #fff; + text-decoration: none; +} + +/* ── CTA ─────────────────────────────────────────────── */ +.about-cta { + text-align: center; + padding: 72px 40px; + border-top: 1px solid var(--border, #1d1d1f); +} + +.about-cta h2 { + font-size: 32px; + font-weight: 700; + color: #fff; + margin: 0 0 12px; +} + +.about-cta p { + font-size: 16px; + color: #888; + margin: 0 0 28px; +} + +.about-cta-btns { + display: flex; + gap: 14px; + justify-content: center; +} + +.about-btn-primary { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 28px; + border-radius: 10px; + background: #0071e3; + color: #fff; + font-size: 15px; + font-weight: 600; + text-decoration: none; + transition: background 0.2s; +} + +.about-btn-primary:hover { + background: #0077ed; +} + +.about-btn-secondary { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 28px; + border-radius: 10px; + background: #1a1a1a; + border: 1px solid #333; + color: #ccc; + font-size: 15px; + font-weight: 500; + text-decoration: none; + transition: border-color 0.2s; +} + +.about-btn-secondary:hover { + border-color: #555; + color: #fff; +} + +.about-btn-secondary svg { + width: 16px; + height: 16px; +} + +/* ── Mobile ──────────────────────────────────────────── */ +@media (max-width: 768px) { + .about-hero { + padding: 80px 20px 48px; + } + + .about-hero-title { + font-size: 32px; + } + + .about-section { + padding: 48px 20px; + } + + .about-arch-grid { + grid-template-columns: 1fr; + } + + .about-creator { + flex-direction: column; + align-items: center; + text-align: center; + } + + .about-creator-links { + justify-content: center; + } + + .about-tags { + justify-content: center; + } + + .about-stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .about-cta-btns { + flex-direction: column; + align-items: center; + } +} + +@media (max-width: 480px) { + .about-hero-title { + font-size: 28px; + } + + .about-stats-grid { + grid-template-columns: 1fr 1fr; + gap: 12px; + } + + .about-creator-links { + flex-direction: column; + align-items: stretch; + } + + .about-social-link { + justify-content: center; + } +} diff --git a/frontend/src/pages/AboutPage.tsx b/frontend/src/pages/AboutPage.tsx new file mode 100644 index 00000000..f35ac8ac --- /dev/null +++ b/frontend/src/pages/AboutPage.tsx @@ -0,0 +1,317 @@ +import { Link } from 'react-router-dom'; +import { AppHeader } from '../components/layout/AppHeader'; +import { useSEO } from '../utils/useSEO'; +import { getSeoMeta } from '../seoRoutes'; +import './AboutPage.css'; + +const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio'; +const LINKEDIN_URL = 'https://www.linkedin.com/in/davidmonterocrespo24'; +const GITHUB_PROFILE = 'https://github.com/davidmonterocrespo24'; +const MEDIUM_URL = 'https://medium.com/@davidmonterocrespo24'; +const HN_THREAD_V2 = 'https://news.ycombinator.com/item?id=43484227'; + +/* ── Icons ──────────────────────────────────────────── */ +const IcoChip = () => ( + + + + + +); + +const IcoGitHub = () => ( + + + +); + +const IcoLinkedIn = () => ( + + + +); + +const IcoMedium = () => ( + + + +); + +/* ── Component ──────────────────────────────────────── */ +export const AboutPage: React.FC = () => { + useSEO({ + ...getSeoMeta('/about')!, + jsonLd: { + '@context': 'https://schema.org', + '@type': 'AboutPage', + name: 'About Velxio', + description: 'Learn about Velxio and its creator David Montero Crespo.', + url: 'https://velxio.dev/about', + }, + }); + + return ( +
+ + + {/* Hero */} +
+
+

About Velxio

+

+ A free, open-source embedded systems emulator — built by a single developer with a passion for hardware and open source. +

+
+
+ + {/* The Story */} +
+
+
+

The Story

+

+ Velxio started as a personal exploration into how microcontroller emulators work internally — CPU instructions, + memory management, peripheral timing, and low-level architecture. What began as a learning project during + a vacation quickly grew into something bigger. +

+

+ The idea was simple: what if anyone could simulate Arduino, ESP32, and Raspberry Pi boards + directly in the browser, without buying hardware, without installing toolchains, without cloud accounts? +

+

+ Velxio v1 launched on Product Hunt and Hacker News, supporting Arduino Uno and Raspberry Pi Pico. + The feedback from the maker and embedded community was incredible — and it pushed the project forward. +

+

+ Velxio 2.0 shipped with ESP32 emulation via QEMU (using the lcgamboa fork), a Raspberry Pi 3 + running real Linux, RISC-V support for ESP32-C3 and CH32V003, realistic sensor simulation (DHT22, HC-SR04, + WS2812B NeoPixel), and 19 boards across 5 CPU architectures — all running real compiled code. +

+

+ The v2 launch hit the front page of Hacker News for over 20 hours, reaching nearly 600 GitHub + stars and thousands of visitors in less than 24 hours. It's now being used by students, professors, and makers + around the world. +

+
+
+
+ + {/* Architecture overview */} +
+
+

How It Works

+
+
+
+ +
+

AVR8 & RP2040

+

Cycle-accurate emulation runs entirely in your browser using avr8js and rp2040js. No backend needed for simulation.

+
+
+
+ +
+

ESP32 via QEMU

+

Xtensa ESP32 and ESP32-S3 run on backend QEMU (lcgamboa fork) with real flash images, GPIO, ADC, and timers.

+
+
+
+ +
+

RISC-V In-Browser

+

ESP32-C3 and CH32V003 run on a custom RV32IMC core written in TypeScript — entirely client-side, no QEMU.

+
+
+
+ +
+

Raspberry Pi 3

+

Full ARM Cortex-A53 Linux via QEMU raspi3b — boots real Raspberry Pi OS and runs Python with RPi.GPIO.

+
+
+
+
+ + {/* Open Source Philosophy */} +
+
+

Open Source Philosophy

+

+ Velxio is 100% open source under the AGPLv3 license. No cloud dependency, no student accounts, + no data leaving your network. Universities and bootcamps can deploy it on their own servers with a single Docker + command and give every student access to a complete embedded development environment — for free. +

+

+ The project builds on top of amazing open-source work from the community: +

+ +

+ Velxio was inspired by Wokwi, which is a + fantastic tool. The goal of Velxio is to take a different path: fully open source, self-hostable, and supporting + multiple heterogeneous boards in the same circuit. +

+
+
+ + {/* Creator */} +
+
+

The Creator

+
+
+
DMC
+
+
+

David Montero Crespo

+

Application Architect @ IBM · Montevideo, Uruguay

+

+ Application Architect with over 10 years of experience leading the development of large-scale + enterprise ecosystems. Specialist in Generative AI (LLMs, RAG, LangChain), Cloud-Native architectures + (OpenShift, Kubernetes), and certified Odoo ERP expert. Currently at IBM working on enterprise applications + for the Uruguayan State Insurance Bank (BSE). +

+

+ Studied Computer Science Engineering at Universidad de Oriente in Cuba (2012-2017), then moved to + Uruguay where he built a career spanning roles at Quanam (Odoo implementations for government institutions), + and IBM (enterprise architecture for BPS and BSE). +

+

+ Programming and robotics enthusiast. Creator of other open-source projects including + a 3D racing game running on an ESP32 (viral + on Reddit with 40K+ views) and an iPod Classic clone for Raspberry Pi. +

+ +
+

Tech Stack

+
+ Java + Python + TypeScript + React + Angular + Node.js + FastAPI + Docker + Kubernetes + OpenShift + LangChain + watsonx.ai + Odoo + Arduino + ESP32 + Raspberry Pi +
+
+ + +
+
+
+
+ + {/* Personal story quote */} +
+
+
+

+ "I studied Computer Science in Cuba from 2012 to 2017, and when I moved to Uruguay, the most + common question was: 'How did you graduate as an engineer without internet, without a computer, + without YouTube?' +

+

+ I only had 4 words for the answer: Perseverance! Perseverance! Perseverance! Perseverance! +

+

+ There is no better motivation than not having a plan B." +

+ — David Montero Crespo +
+
+
+ + {/* Community & Press */} +
+
+

Community & Press

+
+
+ 600+ + GitHub Stars +
+
+ 97+ + Countries +
+
+ 19 + Supported Boards +
+
+ 5 + CPU Architectures +
+
+
+

Featured on:

+
+ Hacker News (Front Page) + Product Hunt + Reddit r/arduino + Hackster.io + XDA Developers +
+
+
+
+ + {/* CTA */} +
+
+

Ready to try Velxio?

+

No signup required. Runs 100% in your browser. Free and open source.

+
+ Open Editor + + View on GitHub + +
+
+
+ + {/* Footer */} + +
+ ); +}; diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index fb58336c..be07a3e7 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -739,6 +739,7 @@ export const LandingPage: React.FC = () => { Docs Examples Editor + About

MIT License · Powered by avr8js & wokwi-elements diff --git a/frontend/src/seoRoutes.ts b/frontend/src/seoRoutes.ts index 2f9cb2fc..3165fd8d 100644 --- a/frontend/src/seoRoutes.ts +++ b/frontend/src/seoRoutes.ts @@ -191,6 +191,18 @@ export const SEO_ROUTES: SeoRoute[] = [ }, }, + // ── About + { + path: '/about', + priority: 0.7, + changefreq: 'monthly', + seoMeta: { + title: 'About Velxio — Open Source Embedded Emulator by David Montero Crespo', + description: 'Learn about Velxio, the free open-source multi-board embedded emulator, and its creator David Montero Crespo — Application Architect at IBM, programming and robotics enthusiast.', + url: `${DOMAIN}/about`, + }, + }, + // ── Auth / admin (noindex) { path: '/login', noindex: true }, { path: '/register', noindex: true }, diff --git a/frontend/src/utils/analytics.ts b/frontend/src/utils/analytics.ts index 9fa34181..7e7c9408 100644 --- a/frontend/src/utils/analytics.ts +++ b/frontend/src/utils/analytics.ts @@ -5,9 +5,10 @@ * Each function maps to an event that should be marked as a Key Event in GA4. */ -declare function gtag(command: 'event', eventName: string, eventParams?: Record): void; +type GtagFn = (command: 'event', eventName: string, eventParams?: Record) => void; function fireEvent(eventName: string, params: Record): void { + const gtag = (window as unknown as { gtag?: GtagFn }).gtag; if (typeof gtag === 'function') { gtag('event', eventName, params); }