feat(design): tokens, webfonts, Lucide icons, board PNG/WebP picture strip

Foundation
- Add 7 token CSS files in src/tokens/ — semantic colors, 4-pt spacing,
  Apple-HIG type ramp, radius/elevation/motion/z-index scales.
- Refactor src/index.css to import tokens and remap legacy aliases
  (--accent, --bg, etc.) onto the new --color-* semantics so existing
  components keep rendering during migration.
- Drop the duplicate font-family from src/App.css; body inherits from :root.
- Global *:focus-visible ring backed by --color-focus-ring (WCAG 2.4.7).

Webfonts (self-hosted)
- Add Inter.var.woff2 (variable, OFL) and JetBrainsMono.var.woff2 to
  public/fonts/. Preloaded in index.html with crossorigin.
- Old stack -apple-system kept as fallback so Mac users still get SF Pro.
- Fixes cross-OS rendering inconsistency (Win/Linux/Android were falling
  back to Segoe UI / Roboto, breaking the type grid).

Component primitives
- New src/components/ui/{Button,Card,Input}.tsx + .css. Built on the
  semantic tokens, ready for incremental migration of .ap-* CSS classes.

Lucide icons
- Replace 6 inline SVG icon components in LandingPage (IcoChip / IcoCpu /
  IcoCode / IcoZap / IcoLayers / IcoMonitor) with lucide-react imports.
  Aliased so call sites are unchanged. ~80 lines of inline SVG removed.
- IcoGitHub kept bespoke (filled glyph, brand-correct).

Marketing assets
- Convert top 8 boards to transparent PNG + WebP at 1x / 2x:
  Arduino Uno, Nano, Mega 2560, Pi Pico, Pi Pico W, ESP32-C3,
  ESP32-DevKit-V1, XIAO ESP32-S3.
- Migrate matching cards in LandingPage and Velxio2Page to <picture>
  with WebP > PNG > SVG fallback. Other 8 boards keep <img src=*.svg>
  for now (Raspberry Pi 3B, ESP32-CAM, etc.).
- Refresh og-image.png — same canonical URL, new content (4 hero boards
  + branding instead of generic logo card).
- Fix latent bug in LandingPage: ESP32 DevKit V1 card was loading
  esp32-devkit-c-v4.svg; now uses esp32-devkit-v1.{webp,png,svg}.

Build verified: npm run build:docker succeeds, 246 SEO pages prerender.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-07 20:45:44 +02:00
parent 0fdbeffda5
commit e0dbde0a76
57 changed files with 882 additions and 211 deletions

View File

@ -90,6 +90,12 @@
<link rel="dns-prefetch" href="https://github.com" />
<link rel="dns-prefetch" href="https://discord.com" />
<!-- ═══════════════════════════════════════════════
WEBFONTS — self-hosted, preloaded
═══════════════════════════════════════════════ -->
<link rel="preload" as="font" type="font/woff2" href="/fonts/Inter.var.woff2" crossorigin />
<link rel="preload" as="font" type="font/woff2" href="/fonts/JetBrainsMono.var.woff2" crossorigin />
<!-- ═══════════════════════════════════════════════
JSON-LD STRUCTURED DATA
Helps Google understand the page type and show

View File

@ -36,6 +36,7 @@
"idb-keyval": "^6.2.2",
"jszip": "^3.10.1",
"littlefs": "^0.1.0",
"lucide-react": "^0.460.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,27 @@
# Self-hosted webfonts
Velxio's design system depends on two variable fonts shipped from this directory:
- `Inter.var.woff2` — Inter variable, 100-900 weight axis. Source: https://github.com/rsms/inter/releases (download the latest, copy `Inter.var.woff2`).
- `JetBrainsMono.var.woff2` — JetBrains Mono variable, 100-800 weight axis. Source: https://github.com/JetBrains/JetBrainsMono/releases (latest tag, file is `webfonts/JetBrainsMono[wght].woff2` — rename to `JetBrainsMono.var.woff2`).
Both are SIL Open Font License 1.1 — include the LICENSE files alongside the woff2 if redistributing.
## Why self-hosted
We previously used `-apple-system, SF Pro` which renders correctly on macOS only. Inter renders identically across Mac, Windows, Linux, Android. JetBrains Mono ships every glyph the editor needs (box-drawing, ligatures off by default — Monaco handles its own font separately, but our `<pre>` and inline `<code>` use this).
## Preload
Both fonts are preloaded in `index.html`:
```html
<link rel="preload" as="font" type="font/woff2" href="/fonts/Inter.var.woff2" crossorigin>
<link rel="preload" as="font" type="font/woff2" href="/fonts/JetBrainsMono.var.woff2" crossorigin>
```
The `crossorigin` attribute is required — without it the browser fetches twice.
## Verifying
After deploy, in DevTools Network tab filter by "Font". Should show two requests, both with `(memory cache)` after the first page load. If you see Roboto / Segoe UI / Helvetica fall through, the font failed to load — check the woff2 path and CORS headers from your CDN.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 181 KiB

View File

@ -5,11 +5,8 @@
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Arial,
sans-serif;
background-color: #000;
color: #f5f5f7;
background-color: var(--color-bg-canvas);
color: var(--color-fg-default);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@ -0,0 +1,90 @@
.ui-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-4);
border: none;
font-family: var(--font-sans);
font-weight: 500;
letter-spacing: -0.1px;
cursor: pointer;
transition:
background-color var(--duration-base) var(--ease-out),
color var(--duration-base) var(--ease-out),
filter var(--duration-base) var(--ease-out),
transform var(--duration-fast) var(--ease-out);
}
.ui-btn--full {
width: 100%;
}
.ui-btn:disabled,
.ui-btn--loading {
opacity: 0.55;
cursor: not-allowed;
}
.ui-btn__icon {
display: inline-flex;
align-items: center;
}
.ui-btn__label {
display: inline-flex;
align-items: center;
}
/* Sizes */
.ui-btn--sm { padding: 8px 14px; font-size: 13px; border-radius: var(--radius-pill); }
.ui-btn--md { padding: 11px 18px; font-size: 15px; border-radius: var(--radius-pill); }
.ui-btn--lg { padding: 14px 22px; font-size: 17px; border-radius: var(--radius-pill); }
/* Variants */
.ui-btn--primary {
background: var(--color-action-primary);
color: var(--color-action-primary-fg);
}
.ui-btn--primary:hover:not(:disabled) {
filter: brightness(1.1);
transform: scale(1.01);
}
.ui-btn--secondary {
background: var(--color-action-secondary);
color: var(--color-fg-default);
}
.ui-btn--secondary:hover:not(:disabled) {
background: var(--color-action-secondary-hover);
transform: scale(1.01);
}
.ui-btn--ghost {
background: transparent;
color: var(--color-fg-default);
}
.ui-btn--ghost:hover:not(:disabled) {
background: var(--color-bg-surface-2);
}
.ui-btn--white {
background: #ffffff;
color: #1d1d1f;
}
.ui-btn--white:hover:not(:disabled) {
filter: brightness(0.96);
}
/* Spinner */
.ui-btn__spinner {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid currentColor;
border-top-color: transparent;
animation: ui-btn-spin 0.8s linear infinite;
}
@keyframes ui-btn-spin {
to { transform: rotate(360deg); }
}

View File

@ -0,0 +1,56 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import './Button.css';
type Variant = 'primary' | 'secondary' | 'ghost' | 'white';
type Size = 'sm' | 'md' | 'lg';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
loading?: boolean;
iconLeft?: ReactNode;
iconRight?: ReactNode;
fullWidth?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
{
variant = 'primary',
size = 'md',
loading = false,
iconLeft,
iconRight,
fullWidth = false,
disabled,
className,
children,
...rest
},
ref,
) {
const classes = [
'ui-btn',
`ui-btn--${variant}`,
`ui-btn--${size}`,
fullWidth ? 'ui-btn--full' : '',
loading ? 'ui-btn--loading' : '',
className ?? '',
]
.filter(Boolean)
.join(' ');
return (
<button
ref={ref}
className={classes}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...rest}
>
{loading ? <span className="ui-btn__spinner" aria-hidden="true" /> : null}
{!loading && iconLeft ? <span className="ui-btn__icon">{iconLeft}</span> : null}
<span className="ui-btn__label">{children}</span>
{!loading && iconRight ? <span className="ui-btn__icon">{iconRight}</span> : null}
</button>
);
});

View File

@ -0,0 +1,33 @@
.ui-card {
display: flex;
flex-direction: column;
border-radius: var(--radius-lg);
}
.ui-card--default {
background: var(--color-bg-surface-1);
}
.ui-card--elevated {
background: var(--color-bg-surface-2);
box-shadow: var(--shadow-2);
}
.ui-card--outlined {
background: transparent;
border: 1px solid var(--color-border-default);
}
.ui-card--p-sm { padding: var(--space-6); }
.ui-card--p-md { padding: var(--space-9); }
.ui-card--p-lg { padding: var(--space-11) var(--space-10); }
.ui-card__header {
margin-bottom: var(--space-6);
}
.ui-card__footer {
margin-top: var(--space-6);
padding-top: var(--space-6);
border-top: 1px solid var(--color-border-default);
}

View File

@ -0,0 +1,38 @@
import type { HTMLAttributes, ReactNode } from 'react';
import './Card.css';
type Variant = 'default' | 'elevated' | 'outlined';
interface Props extends HTMLAttributes<HTMLDivElement> {
variant?: Variant;
header?: ReactNode;
footer?: ReactNode;
padding?: 'sm' | 'md' | 'lg';
}
export function Card({
variant = 'default',
header,
footer,
padding = 'md',
className,
children,
...rest
}: Props) {
const classes = [
'ui-card',
`ui-card--${variant}`,
`ui-card--p-${padding}`,
className ?? '',
]
.filter(Boolean)
.join(' ');
return (
<div className={classes} {...rest}>
{header ? <div className="ui-card__header">{header}</div> : null}
<div className="ui-card__body">{children}</div>
{footer ? <div className="ui-card__footer">{footer}</div> : null}
</div>
);
}

View File

@ -0,0 +1,44 @@
.ui-field {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.ui-field__label {
font-size: 13px;
font-weight: 500;
color: var(--color-fg-muted);
letter-spacing: 0.1px;
}
.ui-input {
background: var(--color-bg-input);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
padding: 11px 14px;
color: var(--color-fg-default);
font-size: 15px;
font-family: var(--font-sans);
outline: none;
transition: border-color var(--duration-base) var(--ease-out);
}
.ui-input:focus {
border-color: var(--color-action-primary);
}
.ui-input--error {
border-color: var(--color-feedback-error);
}
.ui-field__helper {
font-size: 12px;
color: var(--color-fg-subtle);
margin: 0;
}
.ui-field__error {
font-size: 12px;
color: var(--color-feedback-error);
margin: 0;
}

View File

@ -0,0 +1,46 @@
import { forwardRef, useId, type InputHTMLAttributes } from 'react';
import './Input.css';
interface Props extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
helper?: string;
error?: string;
}
export const Input = forwardRef<HTMLInputElement, Props>(function Input(
{ label, helper, error, id: providedId, className, ...rest },
ref,
) {
const generatedId = useId();
const id = providedId ?? generatedId;
const helperId = helper ? `${id}-helper` : undefined;
const errorId = error ? `${id}-error` : undefined;
return (
<div className={['ui-field', className ?? ''].filter(Boolean).join(' ')}>
{label ? (
<label htmlFor={id} className="ui-field__label">
{label}
</label>
) : null}
<input
ref={ref}
id={id}
className={['ui-input', error ? 'ui-input--error' : ''].filter(Boolean).join(' ')}
aria-invalid={error ? 'true' : undefined}
aria-describedby={errorId ?? helperId}
{...rest}
/>
{helper && !error ? (
<p id={helperId} className="ui-field__helper">
{helper}
</p>
) : null}
{error ? (
<p id={errorId} className="ui-field__error" role="alert">
{error}
</p>
) : null}
</div>
);
});

View File

@ -0,0 +1,3 @@
export { Button } from './Button';
export { Card } from './Card';
export { Input } from './Input';

View File

@ -1,39 +1,20 @@
/* ── Global Apple Design System ────────────────────────── */
/* Velxio global styles.
*
* Tokens live in ./tokens/. Component utilities (.ap-*) below depend on
* those tokens. Legacy aliases (--accent, --bg, etc.) are kept inside
* tokens/colors.css so existing components keep working until they migrate
* to the typed primitives in ./components/ui/.
*/
@import './tokens/index.css';
:root {
/* Colors */
--accent: #0071e3;
--accent-hover: #0077ed;
--bg: #000000;
--bg-card: #141416;
--bg-elevated: #2c2c2e;
--bg-input: #1c1c1e;
--border: rgba(255, 255, 255, 0.09);
--border-hi: rgba(255, 255, 255, 0.16);
--text: #f5f5f7;
--text-muted: #86868b;
--text-dim: #6e6e73;
/* Typography */
--font:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Arial,
sans-serif;
--mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
/* Radii */
--radius: 18px;
--radius-sm: 10px;
--radius-pill: 100px;
/* Shadows */
--shadow: 0 4px 32px rgba(0, 0, 0, 0.45);
--shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.3);
font-family: var(--font);
font-family: var(--font-sans);
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: var(--text);
background-color: var(--bg);
color: var(--color-fg-default);
background-color: var(--color-bg-canvas);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@ -51,9 +32,9 @@ body {
display: flex;
min-width: 320px;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: var(--font);
background: var(--color-bg-canvas);
color: var(--color-fg-default);
font-family: var(--font-sans);
}
#root {
@ -61,24 +42,21 @@ body {
min-height: 100vh;
}
/* ── Shared auth / page utility classes ─────────────────── */
/* ── Auth / page utility classes (legacy — migrate to <Button>, <Card>, <Input>) ─ */
/* Full-page centered layout */
.ap-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
padding: 32px 16px;
font-family: var(--font);
background: var(--color-bg-canvas);
padding: var(--space-9) var(--space-6);
}
/* Auth card */
.ap-card {
background: var(--bg-card);
border-radius: 20px;
padding: 44px 40px;
background: var(--color-bg-surface-1);
border-radius: var(--radius-xl);
padding: var(--space-11) var(--space-10);
width: 100%;
max-width: 380px;
display: flex;
@ -87,79 +65,77 @@ body {
}
.ap-card-title {
margin: 0 0 8px;
margin: 0 0 var(--space-4);
font-size: 28px;
font-weight: 700;
color: var(--text);
letter-spacing: -0.8px;
color: var(--color-fg-default);
letter-spacing: var(--tracking-tight);
text-align: center;
}
.ap-card-sub {
margin: 0 0 32px;
margin: 0 0 var(--space-9);
font-size: 15px;
color: var(--text-muted);
color: var(--color-fg-muted);
text-align: center;
}
/* Form fields */
.ap-form {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 20px;
gap: var(--space-5);
margin-bottom: var(--space-7);
}
.ap-field {
display: flex;
flex-direction: column;
gap: 6px;
gap: var(--space-3);
}
.ap-label {
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
color: var(--color-fg-muted);
letter-spacing: 0.1px;
}
.ap-input {
background: var(--bg-input);
border: 1px solid var(--border-hi);
border-radius: 10px;
background: var(--color-bg-input);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
padding: 11px 14px;
color: var(--text);
color: var(--color-fg-default);
font-size: 15px;
font-family: var(--font);
font-family: var(--font-sans);
outline: none;
transition: border-color 0.2s;
transition: border-color var(--duration-base) var(--ease-out);
}
.ap-input:focus {
border-color: var(--accent);
border-color: var(--color-action-primary);
}
/* Buttons */
.ap-btn-primary {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
gap: var(--space-4);
width: 100%;
padding: 13px;
background: var(--accent);
color: #fff;
background: var(--color-action-primary);
color: var(--color-action-primary-fg);
border: none;
border-radius: var(--radius-pill);
font-size: 15px;
font-weight: 500;
font-family: var(--font);
font-family: var(--font-sans);
cursor: pointer;
transition:
filter 0.2s,
transform 0.15s;
filter var(--duration-base) var(--ease-out),
transform var(--duration-fast) var(--ease-out);
letter-spacing: -0.1px;
margin-top: 6px;
margin-top: var(--space-3);
}
.ap-btn-primary:hover:not(:disabled) {
@ -176,25 +152,25 @@ body {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
gap: var(--space-4);
width: 100%;
padding: 13px;
background: var(--bg-elevated);
color: var(--text);
background: var(--color-action-secondary);
color: var(--color-fg-default);
border: none;
border-radius: var(--radius-pill);
font-size: 15px;
font-weight: 500;
font-family: var(--font);
font-family: var(--font-sans);
cursor: pointer;
transition:
background 0.2s,
transform 0.15s;
background var(--duration-base) var(--ease-out),
transform var(--duration-fast) var(--ease-out);
letter-spacing: -0.1px;
}
.ap-btn-secondary:hover {
background: #3a3a3c;
background: var(--color-action-secondary-hover);
transform: scale(1.01);
}
@ -202,7 +178,7 @@ body {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
gap: var(--space-5);
width: 100%;
padding: 13px;
background: #ffffff;
@ -211,22 +187,21 @@ body {
border-radius: var(--radius-pill);
font-size: 15px;
font-weight: 500;
font-family: var(--font);
font-family: var(--font-sans);
cursor: pointer;
transition: filter 0.2s;
transition: filter var(--duration-base) var(--ease-out);
}
.ap-btn-white:hover {
filter: brightness(0.95);
}
/* Divider */
.ap-divider {
display: flex;
align-items: center;
gap: 12px;
margin: 16px 0;
color: var(--text-dim);
gap: var(--space-5);
margin: var(--space-6) 0;
color: var(--color-fg-subtle);
font-size: 13px;
}
@ -235,30 +210,28 @@ body {
content: '';
flex: 1;
height: 1px;
background: var(--border-hi);
background: var(--color-border-strong);
}
/* Error */
.ap-error {
background: #2d0e0e;
border: 1px solid #7f1d1d;
border-radius: 10px;
border-radius: var(--radius-sm);
color: #f87171;
padding: 10px 14px;
font-size: 13px;
margin-bottom: 16px;
margin-bottom: var(--space-6);
}
/* Footer link */
.ap-footer {
color: var(--text-muted);
color: var(--color-fg-muted);
font-size: 14px;
text-align: center;
margin: 20px 0 0;
margin: var(--space-7) 0 0;
}
.ap-link {
color: var(--accent);
color: var(--color-action-primary);
text-decoration: none;
}

View File

@ -1,5 +1,13 @@
import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
Cpu as IcoChip,
CircuitBoard as IcoCpu,
Code2 as IcoCode,
Zap as IcoZap,
Layers as IcoLayers,
Monitor as IcoMonitor,
} from 'lucide-react';
import { useAuthStore } from '../store/useAuthStore';
import { trackVisitGitHub, trackClickCTA } from '../utils/analytics';
import { AppHeader } from '../components/layout/AppHeader';
@ -12,94 +20,7 @@ const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio';
const PAYPAL_URL = 'https://paypal.me/odoonext';
const GITHUB_SPONSORS_URL = 'https://github.com/sponsors/davidmonterocrespo24';
/* ── Icons ───────────────────────────────────────────── */
const IcoChip = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="5" y="5" width="14" height="14" rx="2" />
<rect x="9" y="9" width="6" height="6" />
<path d="M9 1v4M15 1v4M9 19v4M15 19v4M1 9h4M1 15h4M19 9h4M19 15h4" />
</svg>
);
const IcoCpu = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="4" y="4" width="16" height="16" rx="2" />
<rect x="8" y="8" width="8" height="8" />
<path d="M10 2v2M14 2v2M10 20v2M14 20v2M2 10h2M2 14h2M20 10h2M20 14h2" />
</svg>
);
const IcoCode = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
);
const IcoZap = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
);
const IcoLayers = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
);
const IcoMonitor = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
);
/* GitHub keeps a custom (filled) glyph — Lucide's outline doesn't match the brand. */
const IcoGitHub = () => (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.385-1.335-1.755-1.335-1.755-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 21.795 24 17.295 24 12c0-6.63-5.37-12-12-12z" />
@ -962,25 +883,66 @@ export const LandingPage: React.FC = () => {
<div className="boards-row">
<div className="board-card-sm">
<div className="board-img-box">
<img src="/boards/arduino-uno.svg" alt="Arduino Uno" className="board-img-sm" />
<picture>
<source
type="image/webp"
srcSet="/boards/arduino-uno.webp 1x, /boards/arduino-uno@2x.webp 2x"
/>
<source
type="image/png"
srcSet="/boards/arduino-uno.png 1x, /boards/arduino-uno@2x.png 2x"
/>
<img
src="/boards/arduino-uno.svg"
alt="Arduino Uno"
className="board-img-sm"
loading="lazy"
/>
</picture>
</div>
<span className="board-name-sm">Arduino Uno</span>
<span className="board-chip-sm">ATmega328p · 32 KB</span>
</div>
<div className="board-card-sm">
<div className="board-img-box">
<img src="/boards/arduino-nano.svg" alt="Arduino Nano" className="board-img-sm" />
<picture>
<source
type="image/webp"
srcSet="/boards/arduino-nano.webp 1x, /boards/arduino-nano@2x.webp 2x"
/>
<source
type="image/png"
srcSet="/boards/arduino-nano.png 1x, /boards/arduino-nano@2x.png 2x"
/>
<img
src="/boards/arduino-nano.svg"
alt="Arduino Nano"
className="board-img-sm"
loading="lazy"
/>
</picture>
</div>
<span className="board-name-sm">Arduino Nano</span>
<span className="board-chip-sm">ATmega328p · 32 KB</span>
</div>
<div className="board-card-sm">
<div className="board-img-box">
<img
src="/boards/arduino-mega.svg"
alt="Arduino Mega 2560"
className="board-img-sm"
/>
<picture>
<source
type="image/webp"
srcSet="/boards/arduino-mega.webp 1x, /boards/arduino-mega@2x.webp 2x"
/>
<source
type="image/png"
srcSet="/boards/arduino-mega.png 1x, /boards/arduino-mega@2x.png 2x"
/>
<img
src="/boards/arduino-mega.svg"
alt="Arduino Mega 2560"
className="board-img-sm"
loading="lazy"
/>
</picture>
</div>
<span className="board-name-sm">Arduino Mega 2560</span>
<span className="board-chip-sm">ATmega2560 · 256 KB</span>
@ -1007,18 +969,44 @@ export const LandingPage: React.FC = () => {
<div className="boards-row">
<div className="board-card-sm">
<div className="board-img-box">
<img src="/boards/pi-pico.svg" alt="Raspberry Pi Pico" className="board-img-sm" />
<picture>
<source
type="image/webp"
srcSet="/boards/pi-pico.webp 1x, /boards/pi-pico@2x.webp 2x"
/>
<source
type="image/png"
srcSet="/boards/pi-pico.png 1x, /boards/pi-pico@2x.png 2x"
/>
<img
src="/boards/pi-pico.svg"
alt="Raspberry Pi Pico"
className="board-img-sm"
loading="lazy"
/>
</picture>
</div>
<span className="board-name-sm">Raspberry Pi Pico</span>
<span className="board-chip-sm">RP2040 · 264 KB RAM</span>
</div>
<div className="board-card-sm">
<div className="board-img-box">
<img
src="/boards/pi-pico-w.svg"
alt="Raspberry Pi Pico W"
className="board-img-sm"
/>
<picture>
<source
type="image/webp"
srcSet="/boards/pi-pico-w.webp 1x, /boards/pi-pico-w@2x.webp 2x"
/>
<source
type="image/png"
srcSet="/boards/pi-pico-w.png 1x, /boards/pi-pico-w@2x.png 2x"
/>
<img
src="/boards/pi-pico-w.svg"
alt="Raspberry Pi Pico W"
className="board-img-sm"
loading="lazy"
/>
</picture>
</div>
<span className="board-name-sm">Raspberry Pi Pico W</span>
<span className="board-chip-sm">RP2040 + WiFi</span>
@ -1038,7 +1026,22 @@ export const LandingPage: React.FC = () => {
<div className="boards-row">
<div className="board-card-sm">
<div className="board-img-box">
<img src="/boards/esp32-c3.svg" alt="ESP32-C3" className="board-img-sm" />
<picture>
<source
type="image/webp"
srcSet="/boards/esp32-c3.webp 1x, /boards/esp32-c3@2x.webp 2x"
/>
<source
type="image/png"
srcSet="/boards/esp32-c3.png 1x, /boards/esp32-c3@2x.png 2x"
/>
<img
src="/boards/esp32-c3.svg"
alt="ESP32-C3"
className="board-img-sm"
loading="lazy"
/>
</picture>
</div>
<span className="board-name-sm">ESP32-C3 DevKit</span>
<span className="board-chip-sm">RV32IMC · 4 MB flash</span>
@ -1083,11 +1086,22 @@ export const LandingPage: React.FC = () => {
<div className="boards-row">
<div className="board-card-sm">
<div className="board-img-box">
<img
src="/boards/esp32-devkit-c-v4.svg"
alt="ESP32 DevKit V1"
className="board-img-sm"
/>
<picture>
<source
type="image/webp"
srcSet="/boards/esp32-devkit-v1.webp 1x, /boards/esp32-devkit-v1@2x.webp 2x"
/>
<source
type="image/png"
srcSet="/boards/esp32-devkit-v1.png 1x, /boards/esp32-devkit-v1@2x.png 2x"
/>
<img
src="/boards/esp32-devkit-v1.svg"
alt="ESP32 DevKit V1"
className="board-img-sm"
loading="lazy"
/>
</picture>
</div>
<span className="board-name-sm">ESP32 DevKit V1</span>
<span className="board-chip-sm">LX6 · 4 MB flash</span>
@ -1119,7 +1133,22 @@ export const LandingPage: React.FC = () => {
</div>
<div className="board-card-sm">
<div className="board-img-box">
<img src="/boards/xiao-esp32-s3.svg" alt="XIAO ESP32-S3" className="board-img-sm" />
<picture>
<source
type="image/webp"
srcSet="/boards/xiao-esp32-s3.webp 1x, /boards/xiao-esp32-s3@2x.webp 2x"
/>
<source
type="image/png"
srcSet="/boards/xiao-esp32-s3.png 1x, /boards/xiao-esp32-s3@2x.png 2x"
/>
<img
src="/boards/xiao-esp32-s3.svg"
alt="XIAO ESP32-S3"
className="board-img-sm"
loading="lazy"
/>
</picture>
</div>
<span className="board-name-sm">XIAO ESP32-S3</span>
<span className="board-chip-sm">LX7 · compact</span>

View File

@ -443,15 +443,27 @@ export const Velxio2Page: React.FC = () => {
</div>
<div className="v2-boards-row">
<div className="v2-board-card">
<img src="/boards/arduino-uno.svg" alt="Arduino Uno" />
<picture>
<source type="image/webp" srcSet="/boards/arduino-uno.webp 1x, /boards/arduino-uno@2x.webp 2x" />
<source type="image/png" srcSet="/boards/arduino-uno.png 1x, /boards/arduino-uno@2x.png 2x" />
<img src="/boards/arduino-uno.svg" alt="Arduino Uno" loading="lazy" />
</picture>
<span>Arduino Uno</span>
</div>
<div className="v2-board-card">
<img src="/boards/arduino-nano.svg" alt="Arduino Nano" />
<picture>
<source type="image/webp" srcSet="/boards/arduino-nano.webp 1x, /boards/arduino-nano@2x.webp 2x" />
<source type="image/png" srcSet="/boards/arduino-nano.png 1x, /boards/arduino-nano@2x.png 2x" />
<img src="/boards/arduino-nano.svg" alt="Arduino Nano" loading="lazy" />
</picture>
<span>Arduino Nano</span>
</div>
<div className="v2-board-card">
<img src="/boards/arduino-mega.svg" alt="Arduino Mega 2560" />
<picture>
<source type="image/webp" srcSet="/boards/arduino-mega.webp 1x, /boards/arduino-mega@2x.webp 2x" />
<source type="image/png" srcSet="/boards/arduino-mega.png 1x, /boards/arduino-mega@2x.png 2x" />
<img src="/boards/arduino-mega.svg" alt="Arduino Mega 2560" loading="lazy" />
</picture>
<span>Mega 2560</span>
</div>
</div>
@ -465,11 +477,19 @@ export const Velxio2Page: React.FC = () => {
</div>
<div className="v2-boards-row">
<div className="v2-board-card">
<img src="/boards/pi-pico.svg" alt="Raspberry Pi Pico" />
<picture>
<source type="image/webp" srcSet="/boards/pi-pico.webp 1x, /boards/pi-pico@2x.webp 2x" />
<source type="image/png" srcSet="/boards/pi-pico.png 1x, /boards/pi-pico@2x.png 2x" />
<img src="/boards/pi-pico.svg" alt="Raspberry Pi Pico" loading="lazy" />
</picture>
<span>Pi Pico</span>
</div>
<div className="v2-board-card">
<img src="/boards/pi-pico-w.svg" alt="Raspberry Pi Pico W" />
<picture>
<source type="image/webp" srcSet="/boards/pi-pico-w.webp 1x, /boards/pi-pico-w@2x.webp 2x" />
<source type="image/png" srcSet="/boards/pi-pico-w.png 1x, /boards/pi-pico-w@2x.png 2x" />
<img src="/boards/pi-pico-w.svg" alt="Raspberry Pi Pico W" loading="lazy" />
</picture>
<span>Pi Pico W</span>
</div>
</div>
@ -483,7 +503,11 @@ export const Velxio2Page: React.FC = () => {
</div>
<div className="v2-boards-row">
<div className="v2-board-card">
<img src="/boards/esp32-c3.svg" alt="ESP32-C3" />
<picture>
<source type="image/webp" srcSet="/boards/esp32-c3.webp 1x, /boards/esp32-c3@2x.webp 2x" />
<source type="image/png" srcSet="/boards/esp32-c3.png 1x, /boards/esp32-c3@2x.png 2x" />
<img src="/boards/esp32-c3.svg" alt="ESP32-C3" loading="lazy" />
</picture>
<span>ESP32-C3</span>
</div>
<div className="v2-board-card">
@ -517,7 +541,11 @@ export const Velxio2Page: React.FC = () => {
<span>ESP32-CAM</span>
</div>
<div className="v2-board-card">
<img src="/boards/xiao-esp32-s3.svg" alt="XIAO ESP32-S3" />
<picture>
<source type="image/webp" srcSet="/boards/xiao-esp32-s3.webp 1x, /boards/xiao-esp32-s3@2x.webp 2x" />
<source type="image/png" srcSet="/boards/xiao-esp32-s3.png 1x, /boards/xiao-esp32-s3@2x.png 2x" />
<img src="/boards/xiao-esp32-s3.svg" alt="XIAO ESP32-S3" loading="lazy" />
</picture>
<span>XIAO S3</span>
</div>
<div className="v2-board-card">

View File

@ -0,0 +1,92 @@
/* Color tokens — primitive palette + semantic mappings.
*
* Layers:
* 1. Primitives (--gray-*, --blue-*, etc.) — raw scale, never used directly
* in components.
* 2. Semantics (--color-bg-*, --color-fg-*, --color-action-*) — used by
* components. Swap themes by remapping semantics to different primitives.
* 3. Legacy aliases (--accent, --bg, etc.) — kept temporarily so existing
* components keep working. Remove after full migration.
*/
:root {
/* Primitives — gray scale */
--gray-0: #ffffff;
--gray-50: #fafafa;
--gray-100: #f5f5f7;
--gray-200: #e5e5ea;
--gray-300: #d1d1d6;
--gray-400: #aeaeb2;
--gray-500: #86868b;
--gray-600: #6e6e73;
--gray-700: #48484a;
--gray-800: #2c2c2e;
--gray-850: #1c1c1e;
--gray-900: #141416;
--gray-950: #0a0a0c;
--gray-1000: #000000;
/* Primitives — accents */
--blue-400: #4ea8ff;
--blue-500: #0a84ff;
--blue-600: #0071e3;
--blue-700: #0058b3;
--red-500: #ff453a;
--red-600: #d70015;
--green-500: #30d158;
--green-600: #248a3d;
--amber-500: #ff9f0a;
--amber-600: #c93400;
/* Semantic — backgrounds (dark theme baseline) */
--color-bg-canvas: var(--gray-1000);
--color-bg-surface-1: var(--gray-900);
--color-bg-surface-2: var(--gray-850);
--color-bg-surface-3: var(--gray-800);
--color-bg-input: var(--gray-850);
--color-bg-overlay: rgba(0, 0, 0, 0.6);
/* Semantic — foreground */
--color-fg-default: var(--gray-100);
--color-fg-muted: var(--gray-500);
--color-fg-subtle: var(--gray-600);
--color-fg-on-action: var(--gray-0);
/* Semantic — borders */
--color-border-default: rgba(255, 255, 255, 0.09);
--color-border-strong: rgba(255, 255, 255, 0.16);
/* Semantic — actions */
--color-action-primary: var(--blue-600);
--color-action-primary-hover: var(--blue-500);
--color-action-primary-fg: var(--gray-0);
--color-action-secondary: var(--gray-800);
--color-action-secondary-hover: var(--gray-700);
/* Semantic — feedback */
--color-feedback-error: var(--red-500);
--color-feedback-success: var(--green-500);
--color-feedback-warning: var(--amber-500);
/* Focus ring — used by *:focus-visible across all interactives */
--color-focus-ring: 0 0 0 2px var(--color-action-primary);
/* Tier badges (pro overlay reads these) */
--color-tier-free: var(--gray-500);
--color-tier-pro: var(--blue-600);
--color-tier-team: var(--amber-500);
/* Legacy aliases — DO NOT use in new code. Remove after migration. */
--accent: var(--color-action-primary);
--accent-hover: var(--color-action-primary-hover);
--bg: var(--color-bg-canvas);
--bg-card: var(--color-bg-surface-1);
--bg-elevated: var(--color-bg-surface-3);
--bg-input: var(--color-bg-input);
--border: var(--color-border-default);
--border-hi: var(--color-border-strong);
--text: var(--color-fg-default);
--text-muted: var(--color-fg-muted);
--text-dim: var(--color-fg-subtle);
}

View File

@ -0,0 +1,27 @@
/* Elevation tokens — 5-step shadow scale.
*
* shadow-flat — no elevation, used for inline content
* shadow-1 — cards at rest (hover/static)
* shadow-2 — dropdowns, popovers, raised cards
* shadow-3 — modals, sheets
* shadow-4 — critical popovers, attention-needed surfaces
*
* Tuned for dark theme. When light mode lands, override these inside
* [data-theme=light] to use lower-opacity black.
*/
:root {
--shadow-flat: none;
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.30),
0 2px 6px rgba(0, 0, 0, 0.20);
--shadow-2: 0 2px 8px rgba(0, 0, 0, 0.35),
0 8px 16px rgba(0, 0, 0, 0.25);
--shadow-3: 0 4px 16px rgba(0, 0, 0, 0.40),
0 16px 40px rgba(0, 0, 0, 0.35);
--shadow-4: 0 8px 24px rgba(0, 0, 0, 0.45),
0 24px 64px rgba(0, 0, 0, 0.50);
/* Legacy aliases */
--shadow: var(--shadow-3);
--shadow-sm: var(--shadow-1);
}

View File

@ -0,0 +1,21 @@
/* Design tokens — single import surface for the foundation.
*
* Import this from src/index.css before any other CSS. Tokens cascade so
* order matters: primitives must load before semantics that reference them.
*/
@import './colors.css';
@import './spacing.css';
@import './typography.css';
@import './radius.css';
@import './elevation.css';
@import './motion.css';
@import './z-index.css';
/* Global focus ring — applied to every interactive element. WCAG 2.4.7 +
* Apple HIG focus-state guidance. Override per-component if needed.
*/
*:focus-visible {
box-shadow: var(--color-focus-ring);
outline: none;
}

View File

@ -0,0 +1,39 @@
/* Motion tokens — durations and easings.
*
* Three durations for three intents:
* fast — micro-interactions (hover, click feedback)
* base — standard transitions (color, bg, border)
* slow — meaningful state changes (modal open/close, tab switch)
*
* Easings:
* ease-out — entries (modal opening, tooltip showing)
* ease-in — exits (modal closing)
* ease-in-out — symmetric movements (tab switch, accordion)
* spring — drag/drop in canvas, playful bounces
*/
:root {
--duration-fast: 120ms;
--duration-base: 200ms;
--duration-slow: 320ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Common shortcuts */
--transition-color: color var(--duration-base) var(--ease-out),
background-color var(--duration-base) var(--ease-out),
border-color var(--duration-base) var(--ease-out);
--transition-transform: transform var(--duration-base) var(--ease-out);
--transition-opacity: opacity var(--duration-base) var(--ease-out);
}
@media (prefers-reduced-motion: reduce) {
:root {
--duration-fast: 1ms;
--duration-base: 1ms;
--duration-slow: 1ms;
}
}

View File

@ -0,0 +1,25 @@
/* Radius tokens — corner radius scale.
*
* Use --radius-pill for fully-round buttons/badges. Use the others by
* matching the component's role:
* xs — code chips, tags
* sm — inputs, small buttons
* md — cards, dropdowns
* lg — modals, large surfaces
* xl — feature cards, hero panels
* 2xl — full-bleed marketing tiles
*/
:root {
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-2xl: 28px;
--radius-pill: 9999px;
/* Legacy aliases */
--radius: var(--radius-xl); /* 18px ~ 20px in old system */
--radius-pill-old: var(--radius-pill);
}

View File

@ -0,0 +1,29 @@
/* Spacing tokens — 4-pt base scale.
*
* Use these for padding, margin, gap. Hard-coded pixel values in components
* are a smell — replace with the closest token.
*
* Naming: --space-N where N maps to the rough order of magnitude. Not a
* literal multiplier of 4 because the scale uses tighter steps at the small
* end where visual differences matter most.
*/
:root {
--space-0: 0;
--space-1: 2px;
--space-2: 4px;
--space-3: 6px;
--space-4: 8px;
--space-5: 12px;
--space-6: 16px;
--space-7: 20px;
--space-8: 24px;
--space-9: 32px;
--space-10: 40px;
--space-11: 48px;
--space-12: 64px;
--space-13: 80px;
--space-14: 96px;
--space-15: 128px;
--space-16: 160px;
}

View File

@ -0,0 +1,52 @@
/* Typography tokens — type ramp inspired by Apple HIG.
*
* Self-hosted Inter (variable) + JetBrains Mono (variable) with system-stack
* fallbacks. Mac users still get SF Pro because Inter is listed first but
* -apple-system catches before Inter ever loads.
*
* Each --text-* token bundles size + line-height. Use `font: var(--text-body)`
* or split with separate properties if needed.
*/
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter.var.woff2') format('woff2-variations'),
url('/fonts/Inter.var.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono.var.woff2') format('woff2-variations'),
url('/fonts/JetBrainsMono.var.woff2') format('woff2');
font-weight: 100 800;
font-style: normal;
font-display: swap;
}
:root {
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text',
'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', Consolas,
'Liberation Mono', monospace;
/* Type ramp (size / line-height pairs in the same token) */
--text-caption2: 400 11px/13px var(--font-sans);
--text-caption1: 400 12px/16px var(--font-sans);
--text-footnote: 400 13px/18px var(--font-sans);
--text-subheadline: 400 15px/20px var(--font-sans);
--text-callout: 400 16px/21px var(--font-sans);
--text-body: 400 17px/22px var(--font-sans);
--text-headline: 600 17px/22px var(--font-sans);
--text-title3: 600 20px/25px var(--font-sans);
--text-title2: 600 22px/28px var(--font-sans);
--text-title1: 700 28px/34px var(--font-sans);
--text-largetitle: 700 34px/41px var(--font-sans);
/* Letter-spacing tokens for tight headlines */
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-loose: 0.02em;
}

View File

@ -0,0 +1,15 @@
/* Z-index tokens — explicit stacking layer scale.
*
* Always use a token, never a magic number. Add a new layer here if needed.
*/
:root {
--z-base: 0;
--z-dropdown: 10;
--z-sticky: 20;
--z-overlay: 30;
--z-modal: 40;
--z-popover: 50;
--z-toast: 60;
--z-tooltip: 70;
}