diff --git a/Dockerfile b/Dockerfile
index acafa03..8deaa78 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,6 +13,7 @@ RUN pipenv install --system --deploy
COPY src ./src
COPY lib ./lib
+RUN mkdir -p src/static/bundled-lib && cp -f lib/*.py src/static/bundled-lib/
COPY workspace ./workspace
EXPOSE 8080
diff --git a/Pipfile b/Pipfile
index 1ceb9d0..6da80b7 100644
--- a/Pipfile
+++ b/Pipfile
@@ -20,7 +20,7 @@ bcrypt = "*"
python_version = "3.12"
[scripts]
-dev = "uvicorn app:app --app-dir src --reload --port 8080"
+dev = "uvicorn app:app --app-dir src --reload --host 0.0.0.0 --port 8080"
test = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests'"
test-integration = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests -m integration'"
test-selenium = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests -m selenium -v'"
diff --git a/README.md b/README.md
index abfd6d7..8aeceba 100644
--- a/README.md
+++ b/README.md
@@ -90,17 +90,22 @@ Admins can open another user's workspace from the home page user management pane
The home page can store the API key in `sessionStorage` when you are not using cookie login, or use `?api_key=` on `/editor`.
+**Local mode (no login)** — Click *Use locally* on the home page (or open `/editor?local=1`) to run the editor without any FastAPI auth. The boot-time auth probe is skipped when local mode is active, so this works even on a host that has `AUTH_ENABLED=true`. Files default to the browser's **IndexedDB**; inside the editor the workspace badge has a **Folder…** button that opens `window.showDirectoryPicker()` so you can save straight to any folder on disk (Chromium-only — Firefox/Safari stay on IndexedDB). The picked directory handle is persisted across reloads in IndexedDB; if browser permission lapses a *Reconnect* button reappears in the badge. Nothing is sent to the server for file reads/writes. The MicroPython stubs are loaded from **`/static/bundled-lib/*.py`** (files under `src/static/bundled-lib/` in the repo) so a plain static file server is enough; if those requests fail, the app falls back to `GET /api/public/lib-bundle` when FastAPI is available. For static-only hosting, run `python scripts/serve_static_editor.py` from the repo root — it serves `src/static/` with the same `/static/…` URLs the HTML expects (it strips the `/static` prefix when resolving files), rewrites `/editor` → `index.html`, and sends the same COOP/COEP headers as the full app so **ADC sliders, pin toggles, and serial I/O** keep using `SharedArrayBuffer` on mobile Safari and Chrome where supported. An *Exit* button in the editor's workspace badge clears the local-mode flag (your IndexedDB files stay until you wipe browser storage).
+
## Layout
- `src/` — FastAPI app and static UI (`src/static/`)
-- `lib/` — bundled MicroPython stubs (copied into `WORKSPACE_ROOT/lib` when missing; read-only via API)
-- `workspace/` — default `WORKSPACE_ROOT`: `code/` samples (editable); runtime `lib/` is filled from `lib/` above
+- `lib/` — bundled MicroPython stubs, served read-only as `lib/` in the editor and merged into Pyodide at run time (single source of truth)
+- `workspace/` — default `WORKSPACE_ROOT`: `code/` samples and per-user folders (editable); the editor surfaces the repo `lib/` alongside it without copying anything to disk
## ESP32 / NeoPixel mock
The browser runtime ships MicroPython-style stubs in repo `lib/` (they appear as `lib/` in the editor and are read-only via the APIs):
-- `machine.Pin`, `machine.freq()`, `machine.unique_id()`, `machine.reset()` (no-op here)
+- `machine.Pin` — `value/on/off/toggle/high/low/init/__call__/irq` plus a live "Pins" panel: OUT pins show an indicator, IN pins expose a clickable toggle button (its value is what `Pin.value()` returns), `irq()` fires on rising / falling edges as you click
+- `machine.PWM` — `freq()` / `duty()` / `duty_u16()` / `duty_ns()` with a duty-cycle bar in the Pins panel
+- `machine.ADC` — backed by a live slider in the editor UI (one slider per pin, `read_u16()` returns 0..65535)
+- `machine.UART` — opens a Serial Monitor pane; `write()` text appears there, what you type is delivered via `read()` / `readline()`
- `neopixel.NeoPixel`
- `utime` — `ticks_ms`, `ticks_diff`, `ticks_add`, `sleep_ms`, `sleep_us`, `sleep`
- `micropython.const` — no-op helper for ported constant declarations
diff --git a/lib/machine.py b/lib/machine.py
index eb0a242..09b28f6 100644
--- a/lib/machine.py
+++ b/lib/machine.py
@@ -1,19 +1,445 @@
"""Minimal MicroPython-style machine module mock for browser simulation."""
+import json
+
+
+_PIN_MODE_OUT = 1
+_PIN_MODE_IN = 2
+_PIN_MODE_PWM = 4
+
+
+def _pin_view_get(name):
+ try:
+ import js
+
+ return getattr(js, name, None)
+ except Exception:
+ return None
+
+
+def _sab_isolated():
+ """True when the worker has a real SharedArrayBuffer for pin/ADC views.
+
+ Set by `pyodide-worker.js` at init time. When false, output updates
+ must travel through `print("[pin-out]…")` markers because the main
+ thread can't read worker-local arrays."""
+ try:
+ import js
+
+ return bool(getattr(js, "__sab_isolated", False))
+ except Exception:
+ return False
+
+
+def _pin_out_publish(pin_id, ui_mode, value):
+ """Pack [ui_mode (high byte) | value (low 24 bits)] for the UI panel."""
+ view = _pin_view_get("__pin_out_view")
+ if view is not None:
+ packed = ((int(ui_mode) & 0xFF) << 24) | (int(value) & 0x00FFFFFF)
+ try:
+ import js
+
+ js.Atomics.store(view, int(pin_id), packed)
+ except Exception:
+ try:
+ view[int(pin_id)] = packed
+ except Exception:
+ pass
+ if not _sab_isolated():
+ # Fallback for non-isolated contexts (e.g. mobile over LAN IP):
+ # the main thread reads `[pin-out]` lines from stdout and updates
+ # the Pins panel from there. Slower than SAB but works everywhere.
+ try:
+ print(
+ "[pin-out]"
+ + json.dumps(
+ {"pin": int(pin_id), "mode": int(ui_mode), "value": int(value)}
+ )
+ )
+ except Exception:
+ pass
+
+
+def _pin_in_read(pin_id):
+ view = _pin_view_get("__pin_in_view")
+ if view is None:
+ return 0
+ try:
+ import js
+
+ return int(js.Atomics.load(view, int(pin_id))) & 1
+ except Exception:
+ try:
+ return int(view[int(pin_id)]) & 1
+ except Exception:
+ return 0
+
+
+def _pin_register(pin_id, ui_mode, **extra):
+ payload = {"pin": int(pin_id), "mode": int(ui_mode)}
+ payload.update({k: v for k, v in extra.items() if v is not None})
+ try:
+ print("[pin-register]" + json.dumps(payload))
+ except Exception:
+ pass
+
class Pin:
+ """Browser-simulated `machine.Pin`.
+
+ A control row appears in the editor UI when a pin is constructed:
+ * `Pin.OUT` pins show a live indicator that mirrors `value()`.
+ * `Pin.IN` pins show a toggle button you can click — the next call
+ to `value()` returns 0 or 1 accordingly.
+ """
+
IN = 0
OUT = 1
PULL_UP = 2
PULL_DOWN = 3
+ OPEN_DRAIN = 7
- def __init__(self, pin_id: int, mode: int = OUT, value: int = 0):
+ IRQ_FALLING = 1
+ IRQ_RISING = 2
+
+ def __init__(self, pin_id, mode=OUT, pull=-1, value=None, **_kwargs):
self.id = int(pin_id)
self.mode = int(mode)
+ self.pull = int(pull) if pull != -1 else -1
self._value = 1 if value else 0
+ self._irq_handler = None
+ self._irq_trigger = 0
+ self._last_input = 0
+ self._publish()
+
+ def _ui_mode(self):
+ if self.mode == self.IN:
+ return _PIN_MODE_IN
+ return _PIN_MODE_OUT
+
+ def _publish(self):
+ ui_mode = self._ui_mode()
+ _pin_register(self.id, ui_mode, pull=self.pull if self.pull != -1 else None)
+ if ui_mode == _PIN_MODE_OUT:
+ _pin_out_publish(self.id, ui_mode, self._value)
+
+ def init(self, mode=-1, pull=-1, value=None):
+ if mode != -1:
+ self.mode = int(mode)
+ if pull != -1:
+ self.pull = int(pull)
+ if value is not None:
+ self._value = 1 if int(value) else 0
+ self._publish()
def value(self, new_value=None):
if new_value is None:
+ if self.mode == self.IN:
+ v = _pin_in_read(self.id)
+ if self._irq_handler is not None:
+ if v and not self._last_input and (self._irq_trigger & self.IRQ_RISING):
+ self._fire_irq()
+ elif not v and self._last_input and (self._irq_trigger & self.IRQ_FALLING):
+ self._fire_irq()
+ self._last_input = v
+ return v
return self._value
- self._value = 1 if int(new_value) else 0
- return self._value
+ v = 1 if int(new_value) else 0
+ self._value = v
+ if self._ui_mode() == _PIN_MODE_OUT:
+ _pin_out_publish(self.id, _PIN_MODE_OUT, v)
+ return v
+
+ def on(self):
+ return self.value(1)
+
+ def off(self):
+ return self.value(0)
+
+ def high(self):
+ return self.value(1)
+
+ def low(self):
+ return self.value(0)
+
+ def toggle(self):
+ return self.value(0 if (self._value if self.mode == self.OUT else _pin_in_read(self.id)) else 1)
+
+ def irq(self, handler=None, trigger=IRQ_RISING | IRQ_FALLING, **_kwargs):
+ self._irq_handler = handler
+ self._irq_trigger = int(trigger)
+ return self
+
+ def _fire_irq(self):
+ if self._irq_handler is None:
+ return
+ try:
+ self._irq_handler(self)
+ except Exception as exc:
+ try:
+ print(f"[pin] irq handler raised: {exc!r}")
+ except Exception:
+ pass
+
+ def __call__(self, *args):
+ return self.value(*args) if args else self.value()
+
+
+class PWM:
+ """Browser-simulated `machine.PWM`.
+
+ Visualises the configured duty cycle as a bar in the Pins panel.
+ `freq()` and `duty()`/`duty_u16()`/`duty_ns()` mirror MicroPython's API.
+ """
+
+ def __init__(self, pin, freq=1000, duty=None, duty_u16=None, duty_ns=None):
+ self._pin_id = _adc_pin_id(pin)
+ self._freq = int(freq) if freq else 1000
+ self._duty_u16 = 0
+ if duty_u16 is not None:
+ self._duty_u16 = max(0, min(65535, int(duty_u16)))
+ elif duty is not None:
+ self._duty_u16 = max(0, min(65535, int(duty) * 64))
+ elif duty_ns is not None:
+ self._set_duty_ns(int(duty_ns))
+ _pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
+ _pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
+
+ def _set_duty_ns(self, ns):
+ period_ns = 1_000_000_000 // max(1, self._freq)
+ if period_ns <= 0:
+ self._duty_u16 = 0
+ return
+ self._duty_u16 = max(0, min(65535, int(ns) * 65535 // period_ns))
+
+ def freq(self, value=None):
+ if value is None:
+ return self._freq
+ self._freq = max(1, int(value))
+ _pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
+
+ def duty(self, value=None):
+ if value is None:
+ return self._duty_u16 // 64
+ self._duty_u16 = max(0, min(65535, int(value) * 64))
+ _pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
+
+ def duty_u16(self, value=None):
+ if value is None:
+ return self._duty_u16
+ self._duty_u16 = max(0, min(65535, int(value)))
+ _pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
+
+ def duty_ns(self, value=None):
+ period_ns = 1_000_000_000 // max(1, self._freq)
+ if value is None:
+ return (self._duty_u16 * period_ns) // 65535
+ self._set_duty_ns(int(value))
+ _pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
+
+ def deinit(self):
+ _pin_out_publish(self._pin_id, 0, 0)
+
+
+def _adc_pin_id(pin):
+ if isinstance(pin, int):
+ return pin
+ return int(getattr(pin, "id", 0))
+
+
+def _adc_read_raw(pin_id: int) -> int:
+ """Read the live slider value (0..65535) shared from the editor UI.
+
+ Backed by a SharedArrayBuffer so updates from the browser slider propagate
+ instantly without the script having to yield. Falls back to 0 when the
+ runtime is not cross-origin isolated (e.g. older browsers).
+ """
+ try:
+ import js # only available inside Pyodide
+
+ view = getattr(js, "__adc_view", None)
+ if view is None:
+ return 0
+ try:
+ return int(js.Atomics.load(view, pin_id)) & 0xFFFF
+ except Exception:
+ return int(view[pin_id]) & 0xFFFF
+ except Exception:
+ return 0
+
+
+class ADC:
+ """Browser-simulated `machine.ADC`.
+
+ Exposes a slider in the editor UI for the given pin. `read_u16()` returns
+ the slider value in the 0..65535 range (matching MicroPython's API);
+ `read()` scales to the configured `width` (defaults to 12-bit, 0..4095).
+ """
+
+ WIDTH_9BIT = 9
+ WIDTH_10BIT = 10
+ WIDTH_11BIT = 11
+ WIDTH_12BIT = 12
+ ATTN_0DB = 0
+ ATTN_2_5DB = 1
+ ATTN_6DB = 2
+ ATTN_11DB = 3
+
+ def __init__(self, pin):
+ self._pin = _adc_pin_id(pin)
+ self._atten = self.ATTN_11DB
+ self._width = self.WIDTH_12BIT
+ try:
+ print("[adc-register]" + json.dumps({"pin": self._pin}))
+ except Exception:
+ pass
+
+ def atten(self, attn):
+ self._atten = int(attn)
+
+ def width(self, width):
+ self._width = int(width)
+
+ def read_u16(self):
+ return _adc_read_raw(self._pin)
+
+ def read(self):
+ bits = self._width if self._width in (9, 10, 11, 12) else 12
+ max_val = (1 << bits) - 1
+ return (_adc_read_raw(self._pin) * max_val) // 65535
+
+ def read_uv(self):
+ """Return microvolts assuming a 0..3.3V range (rough simulation)."""
+ return (_adc_read_raw(self._pin) * 3_300_000) // 65535
+
+
+def _serial_drain_pending(buf):
+ """Pull any bytes the editor's serial-monitor has pushed into the SAB ring."""
+ try:
+ import js
+ import base64 # noqa: F401 (kept for symmetry with write path)
+
+ indices = getattr(js, "__serial_in_indices", None)
+ data = getattr(js, "__serial_in_data", None)
+ if indices is None or data is None:
+ return
+ cap = int(getattr(js, "__serial_in_capacity", 0))
+ if cap <= 0:
+ return
+ r = int(js.Atomics.load(indices, 0))
+ w = int(js.Atomics.load(indices, 1))
+ while r != w:
+ buf.append(int(data[r]) & 0xFF)
+ r = (r + 1) % cap
+ js.Atomics.store(indices, 0, r)
+ except Exception:
+ pass
+
+
+class UART:
+ """Browser-simulated `machine.UART`.
+
+ A serial monitor pane appears in the editor when this is constructed.
+ `write()` text shows up there; characters typed into the input box arrive
+ via `read()` / `readline()` / `any()`.
+ """
+
+ INV_TX = 1
+ INV_RX = 2
+ INV_RTS = 4
+ INV_CTS = 8
+
+ def __init__(self, id=0, baudrate=115200, bits=8, parity=None, stop=1,
+ tx=None, rx=None, timeout=0, **_kwargs):
+ self.id = int(id) if id is not None else 0
+ self.baudrate = int(baudrate)
+ self.bits = int(bits)
+ self.parity = parity
+ self.stop = int(stop)
+ self.tx = tx
+ self.rx = rx
+ self.timeout = int(timeout) if timeout is not None else 0
+ self._buf = bytearray()
+ try:
+ print("[serial-register]" + json.dumps({
+ "id": self.id,
+ "baudrate": self.baudrate,
+ }))
+ except Exception:
+ pass
+
+ def init(self, *args, **kwargs):
+ if args:
+ try:
+ self.baudrate = int(args[0])
+ except Exception:
+ pass
+ if "baudrate" in kwargs:
+ self.baudrate = int(kwargs["baudrate"])
+
+ def deinit(self):
+ self._buf = bytearray()
+
+ def any(self):
+ _serial_drain_pending(self._buf)
+ return len(self._buf)
+
+ def read(self, nbytes=None):
+ _serial_drain_pending(self._buf)
+ if not self._buf:
+ return None
+ if nbytes is None:
+ out = bytes(self._buf)
+ self._buf = bytearray()
+ return out
+ n = min(int(nbytes), len(self._buf))
+ if n <= 0:
+ return None
+ out = bytes(self._buf[:n])
+ del self._buf[:n]
+ return out
+
+ def readinto(self, buf, nbytes=None):
+ n = int(nbytes) if nbytes is not None else len(buf)
+ data = self.read(n)
+ if not data:
+ return None
+ for i, b in enumerate(data):
+ buf[i] = b
+ return len(data)
+
+ def readline(self):
+ _serial_drain_pending(self._buf)
+ if not self._buf:
+ return None
+ nl = self._buf.find(b"\n")
+ if nl == -1:
+ return None
+ out = bytes(self._buf[: nl + 1])
+ del self._buf[: nl + 1]
+ return out
+
+ def write(self, data):
+ if isinstance(data, str):
+ buf = data.encode("utf-8")
+ elif isinstance(data, (bytes, bytearray, memoryview)):
+ buf = bytes(data)
+ else:
+ buf = bytes([int(data) & 0xFF])
+ try:
+ import base64
+
+ encoded = base64.b64encode(buf).decode("ascii")
+ print(f"[serial-out]{encoded}")
+ except Exception:
+ pass
+ return len(buf)
+
+ def sendbreak(self):
+ pass
+
+ def flush(self):
+ pass
+
+ def txdone(self):
+ return True
diff --git a/lib/neopixel.py b/lib/neopixel.py
index 9446b38..0f25f85 100644
--- a/lib/neopixel.py
+++ b/lib/neopixel.py
@@ -29,6 +29,17 @@ class NeoPixel:
self.bpp = int(bpp)
self.timing = int(timing)
self._buf = [tuple([0] * self.bpp) for _ in range(self.n)]
+ # Tell the editor UI that this pin is the NeoPixel data line so its
+ # row in the Pins panel goes away (it would just look noisy — every
+ # `pixels.write()` flips it).
+ try:
+ pin_id = getattr(self.pin, "id", self.pin)
+ print(
+ "[pin-claim]"
+ + json.dumps({"pin": int(pin_id), "by": "neopixel"})
+ )
+ except Exception:
+ pass
def __len__(self):
return self.n
diff --git a/scripts/serve_static_editor.py b/scripts/serve_static_editor.py
new file mode 100644
index 0000000..9a6faf7
--- /dev/null
+++ b/scripts/serve_static_editor.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+"""Serve only the contents of `src/static/` (HTML, CSS, JS, bundled stubs).
+
+Use this with **local mode** in the editor (`?local=1`): files live in IndexedDB,
+so no FastAPI file API is required. Maps `/` and `/editor` to `home.html` /
+`index.html` so links from the home page keep working.
+
+Sends ``Cross-Origin-Opener-Policy: same-origin`` and
+``Cross-Origin-Embedder-Policy: credentialless`` on every response so the page
+can become cross-origin isolated (SharedArrayBuffer for live ADC / pins /
+serial), matching the full FastAPI app.
+
+Example:
+
+ python scripts/serve_static_editor.py
+ # open http://127.0.0.1:8765/ then "Use locally" → /editor?local=1
+
+Note: Pyodide and CodeMirror still load from CDNs; you need network access.
+"""
+
+from __future__ import annotations
+
+import argparse
+from functools import partial
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
+ parser.add_argument("--host", default="127.0.0.1", help="Bind address (default 127.0.0.1)")
+ parser.add_argument("--port", type=int, default=8765, help="Port (default 8765)")
+ args = parser.parse_args()
+
+ static_root = (Path(__file__).resolve().parent.parent / "src" / "static").resolve()
+ if not static_root.is_dir():
+ raise SystemExit(f"Static directory not found: {static_root}")
+
+ class Handler(SimpleHTTPRequestHandler):
+ def __init__(self, *a, **kw):
+ super().__init__(*a, directory=str(static_root), **kw)
+
+ def end_headers(self):
+ self.send_header("Cross-Origin-Opener-Policy", "same-origin")
+ self.send_header("Cross-Origin-Embedder-Policy", "credentialless")
+ super().end_headers()
+
+ def translate_path(self, path: str) -> str:
+ clean = path.split("?", 1)[0].split("#", 1)[0]
+ if clean.startswith("/static/"):
+ clean = clean[len("/static") :] # e.g. /styles.css, /bundled-lib/machine.py
+ if clean in ("/", ""):
+ clean = "/home.html"
+ elif clean == "/editor" or clean.startswith("/editor/"):
+ clean = "/index.html"
+ elif clean == "/tutorial" or clean.startswith("/tutorial/"):
+ clean = "/tutorial.html"
+ elif clean == "/login" or clean.startswith("/login/"):
+ clean = "/login.html"
+ elif clean == "/register" or clean.startswith("/register/"):
+ clean = "/register.html"
+ return super().translate_path(clean)
+
+ httpd = ThreadingHTTPServer((args.host, args.port), Handler)
+ print(f"Serving {static_root} at http://{args.host}:{args.port}/")
+ print("Open / then use “Use locally” for IndexedDB workspace.")
+ try:
+ httpd.serve_forever()
+ except KeyboardInterrupt:
+ print("\nStopped.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/editor_app/main.py b/src/editor_app/main.py
index 8d00246..9705541 100644
--- a/src/editor_app/main.py
+++ b/src/editor_app/main.py
@@ -1,12 +1,12 @@
import os
from contextlib import asynccontextmanager
-from fastapi import Depends, FastAPI
+from fastapi import Depends, FastAPI, Request
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from sqlalchemy.orm import sessionmaker
-from editor_app.config import STATIC_DIR, WORKSPACE_ROOT
+from editor_app.config import STATIC_DIR
from editor_app.db.models import Base
from editor_app.db.session import get_engine
from editor_app.deps import require_api_access
@@ -14,13 +14,11 @@ from editor_app.routers.auth_routes import router as auth_router
from editor_app.routers.files import router as files_router
from editor_app.routers.frontend import router as frontend_router
from editor_app.routers.users_admin import router as users_admin_router
-from editor_app.services import accounts, user_workspace
+from editor_app.services import accounts
@asynccontextmanager
async def lifespan(_app: FastAPI):
- (WORKSPACE_ROOT / "lib").mkdir(parents=True, exist_ok=True)
- user_workspace.ensure_workspace_lib()
engine = get_engine()
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
@@ -47,6 +45,18 @@ async def lifespan(_app: FastAPI):
def create_app() -> FastAPI:
app = FastAPI(lifespan=lifespan)
+
+ @app.middleware("http")
+ async def cross_origin_isolation(request: Request, call_next):
+ """Turn on `crossOriginIsolated` so the editor worker can use SharedArrayBuffer
+ (powers the live ADC slider). `credentialless` keeps no-credential cross-origin
+ imports (esm.sh, jsdelivr) loading without requiring CORP everywhere.
+ """
+ response = await call_next(request)
+ response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
+ response.headers.setdefault("Cross-Origin-Embedder-Policy", "credentialless")
+ return response
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(frontend_router)
app.include_router(auth_router)
diff --git a/src/editor_app/routers/frontend.py b/src/editor_app/routers/frontend.py
index af13e26..cd42f90 100644
--- a/src/editor_app/routers/frontend.py
+++ b/src/editor_app/routers/frontend.py
@@ -1,11 +1,38 @@
from fastapi import APIRouter
from fastapi.responses import FileResponse
-from editor_app.config import STATIC_DIR
+from editor_app.config import PROJECT_ROOT, STATIC_DIR
router = APIRouter()
+@router.get("/api/public/lib-bundle")
+async def serve_lib_bundle():
+ """Public, unauthenticated dump of the read-only `lib/` stubs.
+
+ Local-mode browsers (no login, files in IndexedDB) need access to the
+ MicroPython mocks so completion, diagnostics, and `runpy` can resolve
+ `from machine import …`. Reading these is read-only and contains no
+ user data, so it is safe to expose without auth."""
+ bundle_root = (PROJECT_ROOT / "lib").resolve()
+ files: dict[str, str] = {}
+ if bundle_root.is_dir():
+ for path in sorted(bundle_root.rglob("*.py")):
+ if not path.is_file():
+ continue
+ try:
+ rel = path.relative_to(bundle_root)
+ except ValueError:
+ continue
+ if any(part.startswith(".") for part in rel.parts):
+ continue
+ try:
+ files[str(rel).replace("\\", "/")] = path.read_text(encoding="utf-8")
+ except (UnicodeDecodeError, OSError):
+ continue
+ return {"files": files}
+
+
@router.get("/")
async def serve_home():
return FileResponse(STATIC_DIR / "home.html")
@@ -29,3 +56,10 @@ async def serve_login():
@router.get("/register")
async def serve_register():
return FileResponse(STATIC_DIR / "register.html")
+
+
+@router.get("/users")
+async def serve_users():
+ """Admin panel for managing accounts and invites. Auth is enforced by
+ the underlying `/api/users*` endpoints, not the static page itself."""
+ return FileResponse(STATIC_DIR / "users.html")
diff --git a/src/editor_app/services/filesystem.py b/src/editor_app/services/filesystem.py
index 183f7cb..d7bd40c 100644
--- a/src/editor_app/services/filesystem.py
+++ b/src/editor_app/services/filesystem.py
@@ -15,12 +15,13 @@ def _workspace_root(workspace_root: Path | None = None) -> Path:
def _shared_lib_root() -> Path:
- """Shared MicroPython stubs live under WORKSPACE_ROOT/lib; seed from bundle if missing (e.g. volume wiped)."""
- lib = (config.WORKSPACE_ROOT.resolve() / LIB_DIR_NAME).resolve()
- from editor_app.services.user_workspace import ensure_workspace_lib
+ """Shared MicroPython stubs ship in the repo `lib/` directory and are read directly.
- ensure_workspace_lib()
- return lib
+ Treating the bundle as the single source of truth means there is no on-disk
+ `workspace/lib/` cache to keep in sync — updates to e.g. `machine.py` flow
+ straight through to Pyodide and the file tree.
+ """
+ return (config.PROJECT_ROOT / LIB_DIR_NAME).resolve()
def normalize_relative_path(relative_path: str) -> str:
@@ -55,16 +56,8 @@ def resolve_workspace_path(relative_path: str, workspace_root: Path | None = Non
def _is_path_in_lib(target_path: Path, workspace_root: Path | None = None) -> bool:
- workspace = _workspace_root(workspace_root)
- lib_root = (workspace / LIB_DIR_NAME).resolve()
- shared_lib_root = _shared_lib_root()
try:
- target_path.resolve().relative_to(lib_root)
- return True
- except ValueError:
- pass
- try:
- target_path.resolve().relative_to(shared_lib_root)
+ target_path.resolve().relative_to(_shared_lib_root())
return True
except ValueError:
return False
diff --git a/src/editor_app/services/user_workspace.py b/src/editor_app/services/user_workspace.py
index fe83c8f..3f3b6f7 100644
--- a/src/editor_app/services/user_workspace.py
+++ b/src/editor_app/services/user_workspace.py
@@ -16,22 +16,6 @@ _CANONICAL_DEMO_FILENAMES = (
)
-def ensure_workspace_lib(workspace_root: Path | None = None) -> None:
- """Copy shipped MicroPython stubs from the repo into WORKSPACE_ROOT/lib when each file is absent."""
- dst_root = (workspace_root or config.WORKSPACE_ROOT).resolve() / "lib"
- dst_root.mkdir(parents=True, exist_ok=True)
- src_root = config.PROJECT_ROOT.resolve() / "lib"
- if not src_root.is_dir():
- return
- for src in sorted(src_root.glob("*.py")):
- if not src.is_file():
- continue
- dst = dst_root / src.name
- if dst.exists():
- continue
- shutil.copy2(src, dst)
-
-
def safe_workspace_leaf(username: str, user_id: int) -> str:
base = re.sub(r"[^a-zA-Z0-9._-]+", "-", username.strip()).strip("-").lower() or "user"
return f"{base}-{user_id}"
diff --git a/src/static/bundled-lib/machine.py b/src/static/bundled-lib/machine.py
new file mode 100644
index 0000000..e95ef95
--- /dev/null
+++ b/src/static/bundled-lib/machine.py
@@ -0,0 +1,419 @@
+"""Minimal MicroPython-style machine module mock for browser simulation."""
+
+import json
+
+
+_PIN_MODE_OUT = 1
+_PIN_MODE_IN = 2
+_PIN_MODE_PWM = 4
+
+
+def _pin_view_get(name):
+ try:
+ import js
+
+ return getattr(js, name, None)
+ except Exception:
+ return None
+
+
+def _pin_out_publish(pin_id, ui_mode, value):
+ """Pack [ui_mode (high byte) | value (low 24 bits)] for the UI panel."""
+ view = _pin_view_get("__pin_out_view")
+ if view is None:
+ return
+ packed = ((int(ui_mode) & 0xFF) << 24) | (int(value) & 0x00FFFFFF)
+ try:
+ import js
+
+ js.Atomics.store(view, int(pin_id), packed)
+ except Exception:
+ try:
+ view[int(pin_id)] = packed
+ except Exception:
+ pass
+
+
+def _pin_in_read(pin_id):
+ view = _pin_view_get("__pin_in_view")
+ if view is None:
+ return 0
+ try:
+ import js
+
+ return int(js.Atomics.load(view, int(pin_id))) & 1
+ except Exception:
+ try:
+ return int(view[int(pin_id)]) & 1
+ except Exception:
+ return 0
+
+
+def _pin_register(pin_id, ui_mode, **extra):
+ payload = {"pin": int(pin_id), "mode": int(ui_mode)}
+ payload.update({k: v for k, v in extra.items() if v is not None})
+ try:
+ print("[pin-register]" + json.dumps(payload))
+ except Exception:
+ pass
+
+
+class Pin:
+ """Browser-simulated `machine.Pin`.
+
+ A control row appears in the editor UI when a pin is constructed:
+ * `Pin.OUT` pins show a live indicator that mirrors `value()`.
+ * `Pin.IN` pins show a toggle button you can click — the next call
+ to `value()` returns 0 or 1 accordingly.
+ """
+
+ IN = 0
+ OUT = 1
+ PULL_UP = 2
+ PULL_DOWN = 3
+ OPEN_DRAIN = 7
+
+ IRQ_FALLING = 1
+ IRQ_RISING = 2
+
+ def __init__(self, pin_id, mode=OUT, pull=-1, value=None, **_kwargs):
+ self.id = int(pin_id)
+ self.mode = int(mode)
+ self.pull = int(pull) if pull != -1 else -1
+ self._value = 1 if value else 0
+ self._irq_handler = None
+ self._irq_trigger = 0
+ self._last_input = 0
+ self._publish()
+
+ def _ui_mode(self):
+ if self.mode == self.IN:
+ return _PIN_MODE_IN
+ return _PIN_MODE_OUT
+
+ def _publish(self):
+ ui_mode = self._ui_mode()
+ _pin_register(self.id, ui_mode, pull=self.pull if self.pull != -1 else None)
+ if ui_mode == _PIN_MODE_OUT:
+ _pin_out_publish(self.id, ui_mode, self._value)
+
+ def init(self, mode=-1, pull=-1, value=None):
+ if mode != -1:
+ self.mode = int(mode)
+ if pull != -1:
+ self.pull = int(pull)
+ if value is not None:
+ self._value = 1 if int(value) else 0
+ self._publish()
+
+ def value(self, new_value=None):
+ if new_value is None:
+ if self.mode == self.IN:
+ v = _pin_in_read(self.id)
+ if self._irq_handler is not None:
+ if v and not self._last_input and (self._irq_trigger & self.IRQ_RISING):
+ self._fire_irq()
+ elif not v and self._last_input and (self._irq_trigger & self.IRQ_FALLING):
+ self._fire_irq()
+ self._last_input = v
+ return v
+ return self._value
+ v = 1 if int(new_value) else 0
+ self._value = v
+ if self._ui_mode() == _PIN_MODE_OUT:
+ _pin_out_publish(self.id, _PIN_MODE_OUT, v)
+ return v
+
+ def on(self):
+ return self.value(1)
+
+ def off(self):
+ return self.value(0)
+
+ def high(self):
+ return self.value(1)
+
+ def low(self):
+ return self.value(0)
+
+ def toggle(self):
+ return self.value(0 if (self._value if self.mode == self.OUT else _pin_in_read(self.id)) else 1)
+
+ def irq(self, handler=None, trigger=IRQ_RISING | IRQ_FALLING, **_kwargs):
+ self._irq_handler = handler
+ self._irq_trigger = int(trigger)
+ return self
+
+ def _fire_irq(self):
+ if self._irq_handler is None:
+ return
+ try:
+ self._irq_handler(self)
+ except Exception as exc:
+ try:
+ print(f"[pin] irq handler raised: {exc!r}")
+ except Exception:
+ pass
+
+ def __call__(self, *args):
+ return self.value(*args) if args else self.value()
+
+
+class PWM:
+ """Browser-simulated `machine.PWM`.
+
+ Visualises the configured duty cycle as a bar in the Pins panel.
+ `freq()` and `duty()`/`duty_u16()`/`duty_ns()` mirror MicroPython's API.
+ """
+
+ def __init__(self, pin, freq=1000, duty=None, duty_u16=None, duty_ns=None):
+ self._pin_id = _adc_pin_id(pin)
+ self._freq = int(freq) if freq else 1000
+ self._duty_u16 = 0
+ if duty_u16 is not None:
+ self._duty_u16 = max(0, min(65535, int(duty_u16)))
+ elif duty is not None:
+ self._duty_u16 = max(0, min(65535, int(duty) * 64))
+ elif duty_ns is not None:
+ self._set_duty_ns(int(duty_ns))
+ _pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
+ _pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
+
+ def _set_duty_ns(self, ns):
+ period_ns = 1_000_000_000 // max(1, self._freq)
+ if period_ns <= 0:
+ self._duty_u16 = 0
+ return
+ self._duty_u16 = max(0, min(65535, int(ns) * 65535 // period_ns))
+
+ def freq(self, value=None):
+ if value is None:
+ return self._freq
+ self._freq = max(1, int(value))
+ _pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
+
+ def duty(self, value=None):
+ if value is None:
+ return self._duty_u16 // 64
+ self._duty_u16 = max(0, min(65535, int(value) * 64))
+ _pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
+
+ def duty_u16(self, value=None):
+ if value is None:
+ return self._duty_u16
+ self._duty_u16 = max(0, min(65535, int(value)))
+ _pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
+
+ def duty_ns(self, value=None):
+ period_ns = 1_000_000_000 // max(1, self._freq)
+ if value is None:
+ return (self._duty_u16 * period_ns) // 65535
+ self._set_duty_ns(int(value))
+ _pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
+
+ def deinit(self):
+ _pin_out_publish(self._pin_id, 0, 0)
+
+
+def _adc_pin_id(pin):
+ if isinstance(pin, int):
+ return pin
+ return int(getattr(pin, "id", 0))
+
+
+def _adc_read_raw(pin_id: int) -> int:
+ """Read the live slider value (0..65535) shared from the editor UI.
+
+ Backed by a SharedArrayBuffer so updates from the browser slider propagate
+ instantly without the script having to yield. Falls back to 0 when the
+ runtime is not cross-origin isolated (e.g. older browsers).
+ """
+ try:
+ import js # only available inside Pyodide
+
+ view = getattr(js, "__adc_view", None)
+ if view is None:
+ return 0
+ try:
+ return int(js.Atomics.load(view, pin_id)) & 0xFFFF
+ except Exception:
+ return int(view[pin_id]) & 0xFFFF
+ except Exception:
+ return 0
+
+
+class ADC:
+ """Browser-simulated `machine.ADC`.
+
+ Exposes a slider in the editor UI for the given pin. `read_u16()` returns
+ the slider value in the 0..65535 range (matching MicroPython's API);
+ `read()` scales to the configured `width` (defaults to 12-bit, 0..4095).
+ """
+
+ WIDTH_9BIT = 9
+ WIDTH_10BIT = 10
+ WIDTH_11BIT = 11
+ WIDTH_12BIT = 12
+ ATTN_0DB = 0
+ ATTN_2_5DB = 1
+ ATTN_6DB = 2
+ ATTN_11DB = 3
+
+ def __init__(self, pin):
+ self._pin = _adc_pin_id(pin)
+ self._atten = self.ATTN_11DB
+ self._width = self.WIDTH_12BIT
+ try:
+ print("[adc-register]" + json.dumps({"pin": self._pin}))
+ except Exception:
+ pass
+
+ def atten(self, attn):
+ self._atten = int(attn)
+
+ def width(self, width):
+ self._width = int(width)
+
+ def read_u16(self):
+ return _adc_read_raw(self._pin)
+
+ def read(self):
+ bits = self._width if self._width in (9, 10, 11, 12) else 12
+ max_val = (1 << bits) - 1
+ return (_adc_read_raw(self._pin) * max_val) // 65535
+
+ def read_uv(self):
+ """Return microvolts assuming a 0..3.3V range (rough simulation)."""
+ return (_adc_read_raw(self._pin) * 3_300_000) // 65535
+
+
+def _serial_drain_pending(buf):
+ """Pull any bytes the editor's serial-monitor has pushed into the SAB ring."""
+ try:
+ import js
+ import base64 # noqa: F401 (kept for symmetry with write path)
+
+ indices = getattr(js, "__serial_in_indices", None)
+ data = getattr(js, "__serial_in_data", None)
+ if indices is None or data is None:
+ return
+ cap = int(getattr(js, "__serial_in_capacity", 0))
+ if cap <= 0:
+ return
+ r = int(js.Atomics.load(indices, 0))
+ w = int(js.Atomics.load(indices, 1))
+ while r != w:
+ buf.append(int(data[r]) & 0xFF)
+ r = (r + 1) % cap
+ js.Atomics.store(indices, 0, r)
+ except Exception:
+ pass
+
+
+class UART:
+ """Browser-simulated `machine.UART`.
+
+ A serial monitor pane appears in the editor when this is constructed.
+ `write()` text shows up there; characters typed into the input box arrive
+ via `read()` / `readline()` / `any()`.
+ """
+
+ INV_TX = 1
+ INV_RX = 2
+ INV_RTS = 4
+ INV_CTS = 8
+
+ def __init__(self, id=0, baudrate=115200, bits=8, parity=None, stop=1,
+ tx=None, rx=None, timeout=0, **_kwargs):
+ self.id = int(id) if id is not None else 0
+ self.baudrate = int(baudrate)
+ self.bits = int(bits)
+ self.parity = parity
+ self.stop = int(stop)
+ self.tx = tx
+ self.rx = rx
+ self.timeout = int(timeout) if timeout is not None else 0
+ self._buf = bytearray()
+ try:
+ print("[serial-register]" + json.dumps({
+ "id": self.id,
+ "baudrate": self.baudrate,
+ }))
+ except Exception:
+ pass
+
+ def init(self, *args, **kwargs):
+ if args:
+ try:
+ self.baudrate = int(args[0])
+ except Exception:
+ pass
+ if "baudrate" in kwargs:
+ self.baudrate = int(kwargs["baudrate"])
+
+ def deinit(self):
+ self._buf = bytearray()
+
+ def any(self):
+ _serial_drain_pending(self._buf)
+ return len(self._buf)
+
+ def read(self, nbytes=None):
+ _serial_drain_pending(self._buf)
+ if not self._buf:
+ return None
+ if nbytes is None:
+ out = bytes(self._buf)
+ self._buf = bytearray()
+ return out
+ n = min(int(nbytes), len(self._buf))
+ if n <= 0:
+ return None
+ out = bytes(self._buf[:n])
+ del self._buf[:n]
+ return out
+
+ def readinto(self, buf, nbytes=None):
+ n = int(nbytes) if nbytes is not None else len(buf)
+ data = self.read(n)
+ if not data:
+ return None
+ for i, b in enumerate(data):
+ buf[i] = b
+ return len(data)
+
+ def readline(self):
+ _serial_drain_pending(self._buf)
+ if not self._buf:
+ return None
+ nl = self._buf.find(b"\n")
+ if nl == -1:
+ return None
+ out = bytes(self._buf[: nl + 1])
+ del self._buf[: nl + 1]
+ return out
+
+ def write(self, data):
+ if isinstance(data, str):
+ buf = data.encode("utf-8")
+ elif isinstance(data, (bytes, bytearray, memoryview)):
+ buf = bytes(data)
+ else:
+ buf = bytes([int(data) & 0xFF])
+ try:
+ import base64
+
+ encoded = base64.b64encode(buf).decode("ascii")
+ print(f"[serial-out]{encoded}")
+ except Exception:
+ pass
+ return len(buf)
+
+ def sendbreak(self):
+ pass
+
+ def flush(self):
+ pass
+
+ def txdone(self):
+ return True
diff --git a/workspace/lib/neopixel.py b/src/static/bundled-lib/neopixel.py
similarity index 100%
rename from workspace/lib/neopixel.py
rename to src/static/bundled-lib/neopixel.py
diff --git a/src/static/home.html b/src/static/home.html
index bba1ebe..aa17f2c 100644
--- a/src/static/home.html
+++ b/src/static/home.html
@@ -15,570 +15,152 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a, #1e293b);
color: #e2e8f0;
+ padding: 1rem;
}
.home-card {
- width: min(560px, 92vw);
+ width: min(420px, 100%);
background: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 14px;
padding: 2rem;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
+ text-align: center;
+ }
+ h1 {
+ margin: 0 0 0.5rem 0;
+ font-size: 1.9rem;
+ color: #f8fafc;
+ letter-spacing: 0.02em;
+ }
+ p.tagline {
+ margin: 0 0 1.75rem 0;
+ color: #94a3b8;
+ font-size: 0.95rem;
+ }
+ .actions {
+ display: grid;
+ gap: 0.65rem;
}
- h1 { margin: 0 0 0.75rem 0; font-size: 1.8rem; color: #f8fafc; }
- p { margin: 0 0 1rem 0; color: #cbd5e1; line-height: 1.5; }
.btn {
- display: inline-block;
+ display: block;
+ width: 100%;
text-decoration: none;
- border-radius: 8px;
- padding: 0.65rem 1rem;
+ border-radius: 10px;
+ padding: 0.85rem 1rem;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
font-size: 1rem;
+ text-align: center;
}
.btn-primary { background: #3b82f6; color: #ffffff; }
- .btn-ghost { background: transparent; border-color: #64748b; color: #e2e8f0; }
- label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.35rem; }
- input[type="password"] {
- width: 100%;
- padding: 0.5rem 0.65rem;
- border-radius: 8px;
- border: 1px solid #64748b;
- background: #0f172a;
- color: #e2e8f0;
- margin-bottom: 0.75rem;
- }
- .note { font-size: 0.8rem; color: #94a3b8; margin-top: 1rem; }
- .nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
- .nav span { color: #94a3b8; font-size: 0.9rem; }
- .hidden { display: none !important; }
- .invite-panel {
- margin: 1rem 0;
- padding: 0.9rem;
- border: 1px solid rgba(148, 163, 184, 0.35);
- border-radius: 10px;
- background: rgba(30, 41, 59, 0.45);
- }
- .invite-row {
- display: flex;
- gap: 0.5rem;
- flex-wrap: wrap;
- margin-top: 0.4rem;
- }
- .invite-row input[type="email"] {
- flex: 1 1 260px;
- width: auto;
- margin: 0;
- }
- .invite-row button {
- margin: 0;
- }
- .invite-result {
- margin-top: 0.55rem;
- font-size: 0.85rem;
- color: #cbd5e1;
- word-break: break-all;
- }
- .users-panel {
- margin: 1rem 0;
- padding: 0.9rem;
- border: 1px solid rgba(148, 163, 184, 0.35);
- border-radius: 10px;
- background: rgba(30, 41, 59, 0.45);
- }
- .users-list {
- margin-top: 0.5rem;
- display: grid;
- gap: 0.45rem;
- }
- .user-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 0.5rem;
- font-size: 0.9rem;
- }
- .user-row a {
- color: #93c5fd;
- text-decoration: none;
- border: 1px solid rgba(147, 197, 253, 0.35);
- border-radius: 6px;
- padding: 0.25rem 0.5rem;
- }
- .user-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 0.35rem;
- align-items: center;
- justify-content: flex-end;
- }
- .btn-danger {
+ .btn-primary:hover { background: #2563eb; }
+ .btn-ghost {
background: transparent;
- border-color: #f87171;
- color: #fecaca;
- }
- .btn-danger:hover:not(:disabled) {
- background: rgba(248, 113, 113, 0.15);
- }
- .btn:disabled {
- opacity: 0.45;
- cursor: not-allowed;
- }
- #admin-users-feedback:not(:empty) {
- padding: 0.5rem 0.65rem;
- border-radius: 8px;
- font-size: 0.85rem;
- margin: 0.5rem 0;
- }
- #admin-users-feedback.ok {
- background: rgba(34, 197, 94, 0.12);
- border: 1px solid rgba(74, 222, 128, 0.35);
- color: #bbf7d0;
- }
- #admin-users-feedback.err {
- background: rgba(248, 113, 113, 0.1);
- border: 1px solid rgba(248, 113, 113, 0.35);
- color: #fecaca;
- }
- .user-edit-form {
- margin-top: 1rem;
- padding-top: 1rem;
- border-top: 1px solid rgba(148, 163, 184, 0.25);
- }
- .user-edit-form label {
- display: block;
- margin-bottom: 0.65rem;
- }
- .user-edit-form input[type='text'],
- .user-edit-form input[type='password'] {
- width: 100%;
- max-width: 320px;
- padding: 0.45rem 0.55rem;
- border-radius: 8px;
- border: 1px solid #64748b;
- background: #0f172a;
+ border-color: #475569;
color: #e2e8f0;
- margin-top: 0.25rem;
- box-sizing: border-box;
}
- .user-edit-form .edit-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- margin-top: 0.65rem;
- }
- .user-edit-form .super-line {
- display: flex;
- align-items: center;
- gap: 0.5rem;
+ .btn-ghost:hover { background: rgba(71, 85, 105, 0.25); }
+ .btn-link {
+ background: transparent;
+ border: none;
+ color: #93c5fd;
font-size: 0.85rem;
- color: #cbd5e1;
- margin-bottom: 0.65rem;
+ cursor: pointer;
+ padding: 0.4rem;
+ text-decoration: underline;
}
- .user-edit-form .super-line input {
- accent-color: #3b82f6;
+ .signed-in {
+ font-size: 0.85rem;
+ color: #94a3b8;
+ margin-bottom: 0.6rem;
}
+ .hidden { display: none !important; }
+ .footnote {
+ margin-top: 1.25rem;
+ font-size: 0.78rem;
+ color: #64748b;
+ line-height: 1.4;
+ }
+ .footnote a { color: #93c5fd; text-decoration: none; }
+ .footnote a:hover { text-decoration: underline; }
LED Editor
-
- Edit and store files on the server. Python runs in your browser with Pyodide . Choose Editor or the interactive Tutorial below.
-
-
If you use EDITOR_API_KEY (without user login), store it here for API calls from this browser tab:
-
API key (optional)
-
-
The key is kept in sessionStorage. You can also use ?api_key=… on the editor URL.
-
-
- User management
-
- New accounts sign up via an invite link below. Remove accounts here or open their workspace.
-
-
- Accounts
-
-
-
-
- Add users via invite link
-
- Each link lets one person create their own account at /register?invite=… with a password they choose. After it is used, create a new link for the next person.
-
-
-
- Email invite
- Invite link only
-
-
- Copy invite link
-
-
-
Open Editor
-
Open Tutorial
+
Run MicroPython in your browser. Drive simulated NeoPixels, pins, ADC, and serial.
+
+
+
+
+
+