Add tutorial route; gate lib workspace for superusers; Py worker v4

- Serve /tutorial and add tutorial.html/tutorial.js assets
- Fetch auth role; hide lib from non-superusers in tree and restored tabs
- Cache workspace Python sources briefly for Py worker
- Pyodide worker and home/index links/styling tweaks

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-10 01:14:51 +12:00
parent 7d682cce8d
commit 6fc651ad72
8 changed files with 770 additions and 16 deletions

View File

@@ -16,6 +16,11 @@ async def serve_frontend():
return FileResponse(STATIC_DIR / "index.html")
@router.get("/tutorial")
async def serve_tutorial():
return FileResponse(STATIC_DIR / "tutorial.html")
@router.get("/login")
async def serve_login():
return FileResponse(STATIC_DIR / "login.html")

View File

@@ -116,7 +116,7 @@
<a class="btn btn-ghost hidden" id="link-register" href="/register">Register</a>
<button type="button" class="btn btn-ghost hidden" id="btn-logout">Sign out</button>
</div>
<p>Edit and store files on the server. Python runs in your browser with <a href="https://pyodide.org/" style="color:#93c5fd">Pyodide</a>.</p>
<p>Edit and store files on the server. Python runs in your browser with <a href="https://pyodide.org/" style="color:#93c5fd">Pyodide</a>. Choose Editor or the interactive Tutorial below.</p>
<div id="optional-api-key">
<p>If you use <code style="color:#fcd34d">EDITOR_API_KEY</code> (without user login), store it here for API calls from this browser tab:</p>
<label for="api-key">API key (optional)</label>
@@ -137,7 +137,10 @@
<strong>User management</strong>
<div id="users-list" class="users-list"></div>
</section>
<a class="btn btn-primary" href="/editor" id="open-editor">Open Editor</a>
<div class="nav">
<a class="btn btn-primary" href="/editor" id="open-editor">Open Editor</a>
<a class="btn btn-ghost" href="/tutorial" id="open-tutorial">Open Tutorial</a>
</div>
</main>
<script>
const storageKey = 'python-editor.api_key';

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Python Editor</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css?v=10">
<link rel="stylesheet" href="/static/styles.css?v=12">
</head>
<body>
<div class="container">
@@ -87,6 +87,6 @@
</div>
</div>
<script type="module" src="/static/script.js?v=23"></script>
<script type="module" src="/static/script.js?v=25"></script>
</body>
</html>

View File

@@ -52,7 +52,7 @@ self.onmessage = async (event) => {
p.globals.set('__cm_max', Math.min(100, Math.max(1, Number(payload.max_results) || 20)));
p.globals.set('__cm_extra_json', JSON.stringify(payload.extra_files || {}));
const raw = p.runPython(`
import json, os
import json, os, sys
import jedi
extra = json.loads(__cm_extra_json)
@@ -66,7 +66,13 @@ for rel_path, body in extra.items():
os.makedirs(os.path.dirname(__cm_path), exist_ok=True)
with open(__cm_path, "w", encoding="utf-8") as fh:
fh.write(__cm_code)
proj = jedi.Project("/workspace")
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
if entry not in sys.path:
sys.path.insert(0, entry)
proj = jedi.Project(
"/workspace",
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
)
s = jedi.Script(code=__cm_code, path=__cm_path, project=proj)
items = s.complete(line=__cm_line, column=__cm_col)
out = [{"name": i.name, "type": i.type, "complete": i.complete} for i in items[:__cm_max]]
@@ -84,7 +90,7 @@ json.dumps(out)
p.globals.set('__diag_path', vpath);
p.globals.set('__diag_extra_json', JSON.stringify(payload.extra_files || {}));
const raw = p.runPython(`
import json, os
import json, os, sys
import jedi
extra = json.loads(__diag_extra_json)
@@ -98,7 +104,13 @@ for rel_path, body in extra.items():
os.makedirs(os.path.dirname(__diag_path), exist_ok=True)
with open(__diag_path, "w", encoding="utf-8") as fh:
fh.write(__diag_code)
proj = jedi.Project("/workspace")
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
if entry not in sys.path:
sys.path.insert(0, entry)
proj = jedi.Project(
"/workspace",
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
)
s = jedi.Script(code=__diag_code, path=__diag_path, project=proj)
errs = s.get_syntax_errors()
out = [{"line": e.line, "column": e.column, "message": str(e.get_message())} for e in errs]

View File

@@ -31,6 +31,8 @@ class TextEditor {
this.completionRequestId = 0;
this.diagnosticsRequestId = 0;
this.diagnosticsTimer = null;
this.workspaceSourcesCache = null;
this.workspaceSourcesCacheAt = 0;
this.draggedItemPath = null;
this.draggedItemIsDirectory = false;
this.dragHoverExpandTimer = null;
@@ -43,10 +45,27 @@ class TextEditor {
this.lastLedFrame = null;
this.ledPanelWindow = null;
this.workspaceUserId = null;
this.isSuperuser = false;
this.init();
}
async getWorkspacePythonSources() {
const now = Date.now();
if (this.workspaceSourcesCache && (now - this.workspaceSourcesCacheAt) < 1200) {
return { ...this.workspaceSourcesCache };
}
const response = await this.apiFetch('/api/workspace/py-sources');
if (!response.ok) {
throw new Error('Failed to load workspace sources');
}
const payload = await response.json().catch(() => ({}));
const files = payload && typeof payload.files === 'object' ? payload.files : {};
this.workspaceSourcesCache = files;
this.workspaceSourcesCacheAt = now;
return { ...files };
}
init() {
try {
const params = new URLSearchParams(window.location.search);
@@ -69,7 +88,22 @@ class TextEditor {
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
this.updateWorkspaceBanner();
this.prewarmPyWorker();
this.loadInitialDirectoryState().then(() => this.restoreSessionTabs());
this.fetchViewerRole()
.finally(() => this.loadInitialDirectoryState().then(() => this.restoreSessionTabs()));
}
async fetchViewerRole() {
try {
const me = await fetch('/api/auth/me', { credentials: 'include' });
if (!me.ok) {
this.isSuperuser = false;
return;
}
const data = await me.json().catch(() => ({}));
this.isSuperuser = Boolean(data && data.user && data.user.is_superuser);
} catch (_error) {
this.isSuperuser = false;
}
}
updateWorkspaceBanner() {
@@ -152,7 +186,7 @@ class TextEditor {
ensurePyWorker() {
if (!this.pyWorker) {
const worker = new Worker('/static/pyodide-worker.js?v=3');
const worker = new Worker('/static/pyodide-worker.js?v=4');
this.pyWorker = worker;
worker.onmessage = (event) => this.handlePyWorkerMessage(event);
}
@@ -255,6 +289,9 @@ class TextEditor {
return;
}
for (const path of session.openTabPaths) {
if (!this.isSuperuser && typeof path === 'string' && path.startsWith('lib/')) {
continue;
}
await this.openFile(path);
}
if (session.activeTabPath && this.findTab(session.activeTabPath)) {
@@ -290,7 +327,7 @@ class TextEditor {
}
getVisibleTopLevelNames() {
return new Set(['code', 'lib']);
return this.isSuperuser ? new Set(['code', 'lib']) : new Set(['code']);
}
getDefaultEditableRoot() {
@@ -300,12 +337,14 @@ class TextEditor {
async loadInitialDirectoryState() {
await this.loadDirectory('');
await this.ensureFolderExists('code');
await this.ensureFolderExists('lib');
this.selectedPath = 'code';
this.selectedIsDirectory = true;
this.expandedDirs.add('code');
await this.loadDirectory('code', { suppressError: true });
await this.loadDirectory('lib', { suppressError: true });
if (this.isSuperuser) {
await this.ensureFolderExists('lib');
await this.loadDirectory('lib', { suppressError: true });
}
this.renderFileTree();
}
@@ -448,7 +487,7 @@ class TextEditor {
this.setLspStatus('LSP: checking...', 'Running Jedi syntax diagnostics');
try {
await this.ensurePyodideReady();
const extraFiles = {};
const extraFiles = await this.getWorkspacePythonSources();
for (const tab of this.openTabs) {
if (tab.path && tab.path.toLowerCase().endsWith('.py')) {
extraFiles[tab.path] = tab.content;
@@ -530,7 +569,7 @@ class TextEditor {
try {
await this.ensurePyodideReady();
const extraFiles = {};
const extraFiles = await this.getWorkspacePythonSources();
for (const tab of this.openTabs) {
if (tab.path && tab.path.toLowerCase().endsWith('.py')) {
extraFiles[tab.path] = tab.content;
@@ -667,9 +706,18 @@ class TextEditor {
window.addEventListener('beforeunload', persistSession);
window.addEventListener('pagehide', persistSession);
window.addEventListener('keydown', (event) => {
if (!this.completionOpen) return;
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
event.stopPropagation();
this.applySelectedCompletion();
}
}, true);
document.addEventListener('keydown', (event) => {
if (this.completionOpen) {
if (event.key === 'Enter') {
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
this.applySelectedCompletion();
return;

View File

@@ -245,6 +245,7 @@ body {
overflow: hidden;
}
.hidden {
display: none !important;
}

250
src/static/tutorial.html Normal file
View File

@@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Python Tutorial</title>
<link rel="icon" href="data:,">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100dvh;
}
.page {
max-width: 1100px;
margin: 0 auto;
padding: 1rem;
display: grid;
gap: 0.9rem;
}
.topbar {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
align-items: center;
}
.btn {
border: 1px solid #334155;
background: #1e293b;
color: #e2e8f0;
border-radius: 8px;
text-decoration: none;
padding: 0.45rem 0.7rem;
cursor: pointer;
font-size: 0.92rem;
}
.btn.primary {
background: #2563eb;
border-color: #2563eb;
}
.layout {
display: grid;
grid-template-columns: 1fr;
gap: 0.9rem;
min-height: 70dvh;
}
.card {
border: 1px solid #334155;
border-radius: 12px;
background: #111827;
padding: 0.8rem;
min-height: 0;
}
.hint { color: #94a3b8; font-size: 0.9rem; margin: 0.4rem 0 0.7rem; white-space: pre-wrap; }
.guided-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 0.8rem;
margin-bottom: 0.9rem;
}
.demo-code {
margin: 0.55rem 0 0;
border: 1px solid #334155;
background: #020617;
border-radius: 8px;
padding: 0.6rem;
min-height: 280px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.84rem;
line-height: 1.45;
white-space: pre;
}
.demo-line {
display: block;
padding: 0 0.2rem;
border-radius: 4px;
color: #cbd5e1;
}
.demo-line.active {
background: rgba(59, 130, 246, 0.18);
color: #dbeafe;
outline: 1px solid rgba(59, 130, 246, 0.35);
}
.demo-controls {
display: grid;
gap: 0.65rem;
margin-top: 0.65rem;
padding: 0.7rem;
border: 1px solid #334155;
border-radius: 10px;
background: #0b1220;
}
.demo-controls label {
display: grid;
gap: 0.2rem;
color: #e2e8f0;
font-size: 0.95rem;
font-weight: 600;
}
.demo-controls .control-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.demo-controls .value-badge {
min-width: 52px;
text-align: center;
background: #1d4ed8;
color: white;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 999px;
padding: 0.1rem 0.45rem;
font-size: 0.8rem;
}
.demo-controls input[type="range"] {
width: 100%;
accent-color: #3b82f6;
height: 1.1rem;
}
.demo-controls select {
width: 100%;
border: 1px solid #475569;
border-radius: 8px;
background: #020617;
color: #e2e8f0;
padding: 0.5rem 0.55rem;
font-size: 0.92rem;
}
.guide-explain {
border: 1px solid #334155;
background: #020617;
border-radius: 8px;
padding: 0.65rem;
color: #cbd5e1;
min-height: 120px;
white-space: pre-wrap;
}
.guide-step {
color: #93c5fd;
font-size: 0.9rem;
margin: 0.45rem 0;
}
.hidden { display: none !important; }
.led-demo-panel {
border: 1px solid #334155;
background: #020617;
border-radius: 8px;
padding: 0.65rem;
width: fit-content;
display: inline-block;
}
.led-meta {
color: #9ca3af;
font-size: 0.82rem;
margin-bottom: 0.45rem;
display: none;
}
.led-grid {
display: grid;
grid-template-columns: repeat(16, 24px);
grid-auto-rows: 24px;
gap: 6px;
max-height: none;
overflow: visible;
align-content: start;
justify-content: start;
width: fit-content;
}
.led {
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.45);
}
@media (max-width: 900px) {
.guided-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="page">
<div class="topbar">
<a class="btn" href="/">Home</a>
</div>
<div class="layout">
<section class="card">
<h3 style="margin:0">Guided LED Demo</h3>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
<button id="begin-demo-btn" class="btn primary" type="button">Let's Get Started</button>
<button id="toggle-breakdown-btn" class="btn hidden" type="button">Show Breakdown</button>
</div>
<div class="guided-grid">
<div>
<div class="led-demo-panel">
<div id="led-meta" class="led-meta">Press Begin Demo to render the panel animation.</div>
<div id="led-grid" class="led-grid"></div>
</div>
</div>
<div>
<div class="demo-controls">
<label>Pattern
<select id="pattern-select">
<option value="plasma">Plasma Swirl</option>
<option value="rainbow">Rainbow Orbit</option>
</select>
</label>
<label>
<span class="control-head">Speed <span id="speed-value" class="value-badge"></span></span>
<input id="speed-slider" type="range" min="0.8" max="3.2" step="0.1" value="1.6" />
</label>
<label>
<span class="control-head">Wave Width <span id="width-value" class="value-badge"></span></span>
<input id="width-slider" type="range" min="0.2" max="0.8" step="0.02" value="0.45" />
</label>
<label>
<span class="control-head">Brightness <span id="brightness-value" class="value-badge"></span></span>
<input id="brightness-slider" type="range" min="80" max="230" step="5" value="170" />
</label>
</div>
</div>
</div>
<div id="learning-content" class="hidden">
<div id="breakdown-panel" class="hidden">
<div style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.2rem;">
<button id="prev-part-btn" class="btn" type="button">Prev Part</button>
<button id="next-part-btn" class="btn" type="button">Next Part</button>
</div>
<div class="guided-grid" style="margin-top:0.55rem;">
<div>
<div id="guide-step" class="guide-step"></div>
<div id="demo-code" class="demo-code"></div>
</div>
<div>
<div id="guide-explain" class="guide-explain"></div>
</div>
</div>
</div>
</div>
</section>
</div>
</main>
<script type="module" src="/static/tutorial.js?v=2"></script>
</body>
</html>

435
src/static/tutorial.js Normal file
View File

@@ -0,0 +1,435 @@
const DEMO_PARTS = [
{
title: "Setup objects",
start: 1,
end: 6,
explain:
"We import timing/math and create a 16x16 NeoPixel object.\nThis gives us 256 LEDs to control every frame.",
},
{
title: "Serpentine mapping",
start: 8,
end: 10,
explain:
"The panel wiring zig-zags by row.\nEven rows run right-to-left, odd rows run left-to-right.",
},
{
title: "Frame loop",
start: 12,
end: 12,
explain:
"`for t in range(...)` is time.\nEach pass draws one frame of animation.",
},
{
title: "Pixel color math",
start: 13,
end: 17,
explain:
"For each (x,y), we compute a sine wave value.\nThat value controls brightness and color to create flowing motion.",
},
{
title: "Render + delay",
start: 18,
end: 19,
explain:
"`np.write()` pushes the full frame to LEDs.\nA tiny sleep controls animation speed.",
},
];
class TutorialApp {
constructor() {
this.pyWorker = null;
this.pyWorkerMsgId = 0;
this.pyWorkerHandlers = new Map();
this.pyodideInited = false;
this.outputBuffer = "";
this.introMode = true;
this.previewTimer = null;
this.previewTick = 0;
this.breakdownVisible = false;
this.demoPartIndex = 0;
this.demoVars = {
pattern: "plasma",
speed: 1.6,
width: 0.45,
brightness: 170,
};
this.bind();
this.renderDemoControls();
this.renderDemoCode();
this.renderDemoPart();
this.prewarm();
this.startIdlePreview();
}
bind() {
document.getElementById("toggle-breakdown-btn").addEventListener("click", () => {
this.breakdownVisible = !this.breakdownVisible;
const panel = document.getElementById("breakdown-panel");
const button = document.getElementById("toggle-breakdown-btn");
if (panel) panel.classList.toggle("hidden", !this.breakdownVisible);
if (button) button.textContent = this.breakdownVisible ? "Hide Breakdown" : "Show Breakdown";
});
document.getElementById("begin-demo-btn").addEventListener("click", async () => {
if (this.introMode) {
this.introMode = false;
this.stopIdlePreview();
const learning = document.getElementById("learning-content");
const breakdownToggle = document.getElementById("toggle-breakdown-btn");
if (learning) learning.classList.remove("hidden");
if (breakdownToggle) breakdownToggle.classList.remove("hidden");
}
const src = this.buildDemoCode();
await this.runSource(src, "code/tutorial_demo.py");
this.setStatus("Demo ran. Use Show Breakdown to step through code.", "info");
});
document.getElementById("prev-part-btn").addEventListener("click", () => {
this.demoPartIndex = (this.demoPartIndex - 1 + DEMO_PARTS.length) % DEMO_PARTS.length;
this.renderDemoPart();
});
document.getElementById("next-part-btn").addEventListener("click", () => {
this.demoPartIndex = (this.demoPartIndex + 1) % DEMO_PARTS.length;
this.renderDemoPart();
});
document.getElementById("pattern-select").addEventListener("change", (event) => {
this.demoVars.pattern = String(event.target.value || "plasma");
this.renderDemoCode();
this.renderDemoPart();
});
document.getElementById("speed-slider").addEventListener("input", (event) => {
this.demoVars.speed = Number(event.target.value);
this.renderDemoControls();
this.renderDemoCode();
this.renderDemoPart();
});
document.getElementById("width-slider").addEventListener("input", (event) => {
this.demoVars.width = Number(event.target.value);
this.renderDemoControls();
this.renderDemoCode();
this.renderDemoPart();
});
document.getElementById("brightness-slider").addEventListener("input", (event) => {
this.demoVars.brightness = Number(event.target.value);
this.renderDemoControls();
this.renderDemoCode();
this.renderDemoPart();
});
}
buildDemoCode() {
const pattern = this.demoVars.pattern;
const speed = this.demoVars.speed.toFixed(2);
const width = this.demoVars.width.toFixed(2);
const brightness = Math.round(this.demoVars.brightness);
const bodyLines =
pattern === "rainbow"
? [
" dx = x - 7.5",
" dy = y - 7.5",
" angle = math.atan2(dy, dx)",
" radius = math.sqrt(dx * dx + dy * dy)",
" hue = angle + (radius * WAVE_WIDTH) + (t * SPEED * 0.15)",
" pulse = (math.sin((radius * 0.65) - (t * 0.10)) + 1.0) * 0.5",
" scale = int((0.45 + pulse * 0.55) * BRIGHTNESS)",
" r = int((math.sin(hue + 0.0) * 0.5 + 0.5) * scale)",
" g = int((math.sin(hue + 2.09) * 0.5 + 0.5) * scale)",
" u = int((math.sin(hue + 4.18) * 0.5 + 0.5) * scale)",
" np[xy_to_index(x, y)] = (max(0,min(255,r)), max(0,min(255,g)), max(0,min(255,u)))",
]
: [
" dx = x - 7.5",
" dy = y - 7.5",
" dist = math.sqrt(dx * dx + dy * dy)",
" ring = math.sin(dist * (WAVE_WIDTH * 2.6) - t * (SPEED * 0.9))",
` swirl = math.sin((x * WAVE_WIDTH) + (y * ${Math.max(0.1, width * 0.8).toFixed(2)}) + (t * SPEED * 0.55))`,
" pulse = math.sin(t * SPEED * 0.12)",
" b = int((ring * 0.45 + swirl * 0.35 + pulse * 0.2 + 1.0) * (BRIGHTNESS / 2))",
" b = max(0, min(255, b))",
" r_wave = math.sin((x * 0.38) + (t * 0.12))",
" g_wave = math.sin((y * 0.41) - (t * 0.10))",
" u_wave = math.sin(((x + y) * 0.27) + (t * 0.08))",
" r = max(0, min(255, int(b * 0.45 + (r_wave + 1) * 80)))",
" g = max(0, min(255, int(b * 0.30 + (g_wave + 1) * 95)))",
" u = max(0, min(255, int(b * 0.55 + (u_wave + 1) * 85)))",
" np[xy_to_index(x, y)] = (r, g, u)",
];
return [
"from machine import Pin",
"import neopixel",
"import time",
"import math",
"",
"np = neopixel.NeoPixel(Pin(4), 16 * 16)",
`PATTERN = "${pattern}"`,
`SPEED = ${speed}`,
`WAVE_WIDTH = ${width}`,
`BRIGHTNESS = ${brightness}`,
"",
"def xy_to_index(x, y):",
" return y * 16 + (15 - x if y % 2 == 0 else x)",
"",
"frames = 130",
"for t in range(frames):",
" for y in range(16):",
" for x in range(16):",
...bodyLines,
" np.write()",
" time.sleep(0.03)",
"",
"print('DEMO_DONE')",
].join("\n");
}
escapeHtml(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
renderDemoControls() {
const patternSelect = document.getElementById("pattern-select");
if (patternSelect) patternSelect.value = this.demoVars.pattern;
document.getElementById("speed-value").textContent = this.demoVars.speed.toFixed(1);
document.getElementById("width-value").textContent = this.demoVars.width.toFixed(2);
document.getElementById("brightness-value").textContent = String(Math.round(this.demoVars.brightness));
}
renderDemoCode() {
const code = this.buildDemoCode();
const lines = code.split("\n");
const active = DEMO_PARTS[this.demoPartIndex];
const html = lines
.map((line, idx) => {
const n = idx + 1;
const activeClass = n >= active.start && n <= active.end ? " active" : "";
return `<span class="demo-line${activeClass}">${String(n).padStart(2, " ")} ${this.escapeHtml(line)}</span>`;
})
.join("");
document.getElementById("demo-code").innerHTML = html;
}
renderDemoPart() {
const part = DEMO_PARTS[this.demoPartIndex];
document.getElementById("guide-step").textContent = `Part ${this.demoPartIndex + 1}/${DEMO_PARTS.length}: ${part.title}`;
document.getElementById("guide-explain").textContent = part.explain;
this.renderDemoCode();
}
buildPreviewFrame(tick) {
const pixels = new Array(16 * 16).fill(0).map(() => [0, 0, 0]);
const pattern = this.demoVars.pattern;
const speed = this.demoVars.speed;
const width = this.demoVars.width;
const brightness = this.demoVars.brightness;
for (let y = 0; y < 16; y += 1) {
for (let x = 0; x < 16; x += 1) {
const ledIndex = y % 2 === 0 ? y * 16 + (15 - x) : y * 16 + x;
if (pattern === "rainbow") {
const dx = x - 7.5;
const dy = y - 7.5;
const angle = Math.atan2(dy, dx);
const radius = Math.sqrt(dx * dx + dy * dy);
const hue = angle + (radius * width) + (tick * speed * 0.012);
const pulse = (Math.sin((radius * 0.65) - (tick * 0.012)) + 1) * 0.5;
const scale = (0.45 + pulse * 0.55) * brightness;
const r = Math.max(0, Math.min(255, Math.floor((Math.sin(hue + 0.0) * 0.5 + 0.5) * scale)));
const g = Math.max(0, Math.min(255, Math.floor((Math.sin(hue + 2.09) * 0.5 + 0.5) * scale)));
const u = Math.max(0, Math.min(255, Math.floor((Math.sin(hue + 4.18) * 0.5 + 0.5) * scale)));
pixels[ledIndex] = [r, g, u];
} else {
const dx = x - 7.5;
const dy = y - 7.5;
const dist = Math.sqrt(dx * dx + dy * dy);
const ring = Math.sin(dist * (width * 2.6) - tick * speed * 0.072);
const swirl = Math.sin((x * width) + (y * Math.max(0.1, width * 0.8)) + (tick * speed * 0.044));
const pulse = Math.sin(tick * speed * 0.01);
const b = Math.max(0, Math.min(255, Math.floor((ring * 0.45 + swirl * 0.35 + pulse * 0.2 + 1) * (brightness / 2))));
const rWave = Math.sin((x * 0.38) + (tick * 0.12));
const gWave = Math.sin((y * 0.41) - (tick * 0.10));
const uWave = Math.sin(((x + y) * 0.27) + (tick * 0.08));
const r = Math.max(0, Math.min(255, Math.floor(b * 0.45 + (rWave + 1) * 80)));
const g = Math.max(0, Math.min(255, Math.floor(b * 0.30 + (gWave + 1) * 95)));
const u = Math.max(0, Math.min(255, Math.floor(b * 0.55 + (uWave + 1) * 85)));
pixels[ledIndex] = [r, g, u];
}
}
}
return { type: "neopixel", pin: 4, bpp: 3, pixels };
}
startIdlePreview() {
this.stopIdlePreview();
this.previewTick = 0;
this.previewTimer = setInterval(() => {
this.previewTick += 1;
this.renderLedSimulation(this.buildPreviewFrame(this.previewTick));
}, 50);
}
stopIdlePreview() {
if (this.previewTimer) {
clearInterval(this.previewTimer);
this.previewTimer = null;
}
}
setStatus(msg, kind = "error") {
const status = document.getElementById("status");
if (!status) return;
status.textContent = msg;
if (kind === "success") {
status.style.color = "#86efac";
} else if (kind === "info") {
status.style.color = "#93c5fd";
} else {
status.style.color = "#fca5a5";
}
}
ensurePyWorker() {
if (!this.pyWorker) {
this.pyWorker = new Worker("/static/pyodide-worker.js?v=4");
this.pyWorker.onmessage = (event) => this.onWorkerMessage(event);
}
return this.pyWorker;
}
onWorkerMessage(event) {
const data = event.data;
if (!data || typeof data !== "object") return;
if (data.type === "io") {
const text = data.text || "";
this.outputBuffer += text;
this.processSimulationLines(text.split(/\r?\n/));
return;
}
const pending = this.pyWorkerHandlers.get(data.id);
if (!pending) return;
this.pyWorkerHandlers.delete(data.id);
if (data.ok) pending.resolve(data);
else pending.reject(new Error(data.error || "Worker error"));
}
callWorker(type, payload) {
return new Promise((resolve, reject) => {
const id = ++this.pyWorkerMsgId;
this.pyWorkerHandlers.set(id, { resolve, reject });
this.ensurePyWorker().postMessage({ id, type, payload });
});
}
async ensurePyodideReady() {
if (this.pyodideInited) return;
await this.callWorker("init", {});
this.pyodideInited = true;
}
async prewarm() {
try {
await this.ensurePyodideReady();
} catch (_err) {
// ignore warm-up errors
}
}
renderLedSimulation(frame) {
if (!frame) return;
const meta = document.getElementById("led-meta");
const grid = document.getElementById("led-grid");
if (!meta || !grid) return;
const pixels = Array.isArray(frame.pixels) ? frame.pixels : [];
meta.textContent = `pin=${frame.pin ?? "?"} | leds=${pixels.length} | bpp=${frame.bpp ?? 3} | mode=16x16`;
grid.innerHTML = "";
const panelSize = 16 * 16;
for (let panelIndex = 0; panelIndex < panelSize; panelIndex += 1) {
const row = Math.floor(panelIndex / 16);
const col = panelIndex % 16;
const ledIndex = row % 2 === 0 ? row * 16 + (15 - col) : row * 16 + col;
const px = pixels[ledIndex] || [0, 0, 0];
const r = Number(px?.[0] ?? 0);
const g = Number(px?.[1] ?? 0);
const b = Number(px?.[2] ?? 0);
const div = document.createElement("div");
div.className = "led";
div.title = `panel(${row},${col}) -> #${ledIndex} (${r}, ${g}, ${b})`;
div.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
div.style.boxShadow = `0 0 10px rgba(${r}, ${g}, ${b}, 0.55), inset 0 0 8px rgba(0,0,0,0.45)`;
grid.appendChild(div);
}
}
processSimulationLines(lines) {
for (const line of lines || []) {
if (typeof line !== "string") continue;
const marker = "[neopixel-json]";
const idx = line.indexOf(marker);
if (idx === -1) continue;
const jsonPart = line.slice(idx + marker.length).trim();
if (!jsonPart) continue;
try {
const payload = JSON.parse(jsonPart);
if (payload && payload.type === "neopixel") {
this.renderLedSimulation(payload);
}
} catch (_err) {
// Ignore malformed simulation payloads.
}
}
}
async runSource(source, mainPath = "code/tutorial_challenge.py") {
this.outputBuffer = "";
const outputEl = document.getElementById("output");
if (outputEl) outputEl.textContent = "Running...\n";
try {
const bundleResp = await fetch("/api/workspace/py-sources", { credentials: "include" });
if (!bundleResp.ok) {
throw new Error("Failed to load workspace sources for tutorial runtime.");
}
const bundle = await bundleResp.json();
const files = { ...(bundle.files || {}) };
files[mainPath] = source;
await this.ensurePyodideReady();
await this.callWorker("run", {
files,
mainPath,
args: [],
});
const visible = this.outputBuffer
.split(/\r?\n/)
.filter((line) => line && !line.includes("[neopixel-json]"))
.join("\n");
if (outputEl) outputEl.textContent = visible || "[no output]";
return { ok: true, output: this.outputBuffer };
} catch (error) {
const out = `${this.outputBuffer}\n${error.message}`.trim();
if (outputEl) outputEl.textContent = out;
return { ok: false, output: out };
}
}
}
async function bootTutorial() {
try {
const st = await fetch("/api/auth/status");
if (st.ok) {
const status = await st.json();
if (status.auth_enabled) {
const me = await fetch("/api/auth/me", { credentials: "include" });
if (!me.ok) {
const next = encodeURIComponent(`${window.location.pathname}${window.location.search}`);
window.location.replace(`/login?next=${next}`);
return;
}
}
}
} catch (_err) {
// Continue even if auth check fails.
}
new TutorialApp();
}
document.addEventListener("DOMContentLoaded", () => {
bootTutorial();
});