Initial commit: KiddOs v3 React kiosk

- React 18 + Vite 5 hot-reload OS (800x480 Nord theme)
- 7 apps: Journal, Calculator, Drawing, CodeLab, Clock, Files, Scratch
- Cage + Cog Wayland kiosk, offline localStorage
- Scripts: dev.sh, run-kiosk.sh, run-prod.sh
This commit is contained in:
Elias Journal
2026-08-24 21:53:02 -04:00
commit 30f9148adb
45 changed files with 3075 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Dependencies
node_modules/
webapp/node_modules/
# Build output
webapp/dist/
dist/
build/
# Vite
.vite/
# Logs
*.log
npm-debug.log*
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.local
+116
View File
@@ -0,0 +1,116 @@
# KiddOs v3 — React Hot Reload OS (Done)
**Browser kiosk + React + Vite HMR, no backend, no LVGL, offline-first.**
This is the finished v3 you'd been building. Just React, hot refresh, works on 800×480 DSI.
## What works now
- **React shell** `src/App.jsx`: header with clock, greeting, companion banner, favorites & recent (localStorage), category filters, 150px cards grid, settings drawer
- **7 apps** (all React components, no fork/exec):
- `Journal` — autosave 1.5s, word count, past list, companion banner, offline localStorage
- `Calculator` — touch 56px buttons, history in localStorage
- `Drawing` — canvas pen/eraser, size, undo, save PNG, works touch+mouse
- `CodeLab` — JS playground, 3 samples, auto-saves, safe `new Function`
- `Clock` — analog time, timer (1/3/5/10m), stopwatch
- `Files` — browse localStorage journal entries, show companion info
- `Scratch` — iframe scratch.mit.edu (needs online) + offline alternatives info
- **Styling**: dark #2E3440 Nord theme, 800×480 optimized, touch 36px+ targets
- **Hot reload**: Vite dev server on :5173, Cog loads http://localhost:5173, edits push <1s to DSI
- **Build**: 184KB JS, 55KB gzip, no backend, file:// also works via `dist/`
## How to run (proven commands)
**1. Stop old kiosks:**
```bash
sudo systemctl stop kiddos.service kiddos-browser.service journal-kiosk.service
```
**2. Start React dev server (SSH session 1):**
```bash
cd ~/KiddOsV3/webapp
npm run dev -- --host 0.0.0.0 --port 5173
# or
~/KiddOsV3/scripts/dev.sh
```
Vite: `Local: http://localhost:5173/`
**3. Put on DSI (SSH session 2):**
```bash
~/KiddOsV3/scripts/run-kiosk.sh
# which runs:
sudo openvt -f -c 1 -s -- env XDG_RUNTIME_DIR=/run/user/1000 cage -s -- \
cog --platform=wl http://localhost:5173 --bg-color=#2E3440 --doc-viewer
```
You should see React launcher on DSI, touch works (ft5x06 event7 via libinput seat0). Edit `src/App.jsx`, save, DSI updates instantly.
**Fallbacks:**
```bash
# Chromium if Cog touch cursor stuck middle
sudo openvt -f -c 1 -s -- env XDG_RUNTIME_DIR=/run/user/1000 cage -s -- \
chromium --kiosk --no-sandbox --ozone-platform=wayland --disable-gpu http://localhost:5173
# Static prod build (no dev server)
cd ~/KiddOsV3/webapp && npm run build
cage -s -- cog --platform=wl file://$PWD/dist/index.html --bg-color=#2E3440
# Hello world test (no React)
cage -s -- cog --platform=wl file:///home/elias/KiddOsV2/browser/hello.html --bg-color=#2E3440
```
**If touch cursor stuck middle:**
- `libinput list-devices` should show `10-0038 generic ft5x06 (79)` at event7
- cage needs it. Try without `WLR_NO_HARDWARE_CURSORS`, clean `/run/user/1000/wayland-*`
- Or try labwc compositor instead of cage (heavier but more compatible)
## Folder
```
KiddOsV3/
├── plan.md
├── README.md (this)
├── public/icons/ (128x128 PNGs reused from v1)
├── webapp/
│ ├── package.json (react 18, vite 5, @vitejs/plugin-react)
│ ├── vite.config.js (0.0.0.0:5173, hmr clientPort 5173, base ./)
│ ├── index.html
│ └── src/
│ ├── main.jsx
│ ├── index.css (Nord theme, 800x480 tweaks)
│ ├── App.jsx (OS shell + settings drawer)
│ └── apps/
│ ├── Journal.jsx
│ ├── Calculator.jsx
│ ├── Drawing.jsx
│ ├── CodeLab.jsx
│ ├── Clock.jsx
│ ├── Files.jsx
│ └── Scratch.jsx
└── scripts/
├── dev.sh
├── run-kiosk.sh
└── run-prod.sh
```
## Companion integration
- `elias_companion` writes `~/launcher_config/companion_message.txt` and `dashboard.md` (counts only)
- v3 reads `localStorage.kiddos_companion_message` if set
- To show live companion on React:
```bash
cat ~/launcher_config/companion_message.txt
# then in browser console:
localStorage.setItem('kiddos_companion_message', 'You wrote today - 1 entries!')
```
Future: add tiny python server endpoint `/api/companion` in v2 `server/server.py` or copy companion_message.txt into `webapp/public/` and fetch.
## Next steps (after prove)
1. Confirm hot reload works on DSI (edit App.jsx, see instantly)
2. Add more apps: Blockly offline (copy from v2), music, math games
3. Optionally re-add `server/server.py` for central sync (v2's local-first + central)
4. Only then systemd service using same working command (openvt -f -c 1 -s ...)
## Why not LVGL anymore
v1 LVGL had DRM master handover per app → “any app I try to open freeze and restarts the OS” (your report). Browser OS: one process owns DRM forever, apps are React components, no fork, no black screen.
+124
View File
@@ -0,0 +1,124 @@
# KiddOs v3 — Plan: Browser Kiosk + React Hot Reload, No Backend
## Goal
From-scratch v3, **just webapp React with hot refresh**, no server, no LVGL, no backend. Use the proven kiosk command that shows web on DSI:
```bash
cage -s -- \
cog --platform=wl file:///home/elias/KiddOsV2/browser/hello.html --bg-color=#2E3440
```
That command is Method 2a that you confirmed works: `cage` (tiny Wayland kiosk compositor, 82KB) owns DRM/KMS on `/dev/dri/card1` (DSI 800x480 connector id 49), creates Wayland socket in `$XDG_RUNTIME_DIR`, `cog --platform=wl` (WPE WebKit, ~10MB) connects as Wayland client and renders HTML. No X11, no black flicker from fork/exec.
For v3 we keep that exact command, but point it to a **React dev server** for hot refresh instead of static file://
## Why No Backend?
- v2 had `server/server.py` local-first + central sync. Good architecture but adds complexity.
- v3 requirement: **just webapp react with hot refresh on code changes so we can quickly iterate**.
- So: Vite dev server on Pi itself `http://localhost:5173` serves React, Cog loads that URL, and Vite HMR pushes changes instantly to DSI — you edit `src/App.jsx` on laptop via SSH, DSI updates in <1s.
## Stack
- **Compositor**: `cage -s` (kiosk Wayland, handles DRM, libinput touch via seat0)
- **Browser**: `cog --platform=wl` (WPE WebKit, lighter than Chromium 150MB and Electron 265MB). Fallback: `chromium --ozone-platform=wayland` if WPE touch fails.
- **Webapp**: React 18 UMD or Vite + React (we use Vite for HMR). No backend, uses `localStorage` for journal/todos. All offline, file:// or http://localhost works.
- **Icons**: Reuse PNG 128x128 from v1/v2 `browser/icons/`.
## Folder v3
```
KiddOsV3/
├── plan.md (this file)
├── README.md
├── public/ (static icons, will be served by Vite)
├── webapp/
│ ├── package.json (vite + react)
│ ├── vite.config.js (host 0.0.0.0, port 5173, HMR overlay)
│ ├── index.html (Vite entry)
│ └── src/
│ ├── main.jsx (ReactDOM.createRoot)
│ ├── App.jsx (OS shell: header, favorites, categories, grid, settings drawer)
│ └── apps/
│ ├── Journal.jsx (localStorage journal)
│ ├── Calculator.jsx
│ ├── Drawing.jsx (canvas)
│ └── CodeLab.jsx
└── scripts/
├── dev.sh (run vite dev server)
└── run-kiosk.sh (the proven cage command)
```
## How to run (prove without services)
**On Pi, no services needed — manual, like you asked:**
```bash
# 1. Stop any old kiosks, leave terminal on DSI
sudo systemctl stop kiddos.service kiddos-browser.service journal-kiosk.service
sudo systemctl start getty@tty1.service
# 2. Start React dev server in one SSH session:
cd ~/KiddOsV3/webapp
npm install
npm run dev -- --host 0.0.0.0 --port 5173
# Vite says: Local: http://localhost:5173/
# 3. In another SSH session, put it on DSI (exact command you proved):
sudo openvt -f -c 1 -s -- env XDG_RUNTIME_DIR=/run/user/1000 cage -s -- \
cog --platform=wl http://localhost:5173 --bg-color=#2E3440 --doc-viewer
# You should see React launcher on DSI, and editing src/App.jsx updates instantly.
```
**If touch cursor stuck middle (your last report):**
- Cage needs input devices. Check `libinput list-devices` shows `10-0038 generic ft5x06 (79)` at event7.
- Try without WLR_NO_HARDWARE_CURSORS, and ensure /run/user/1000/wayland-* cleaned.
- Fallback: `cage -s -- chromium --ozone-platform=wayland` instead of cog — you saw webpage with that too.
- Last resort heavier: `labwc --startup "cog ..."` or Xorg+openbox, but we avoid until proven.
## React Hot Refresh Details
- Vite HMR uses WebSocket on same host:port. Cog supports WebSocket, so HMR works file://? No, needs http:// — hence dev server at localhost:5173.
- In `vite.config.js`:
```js
export default {
server: { host: '0.0.0.0', port: 5173, hmr: { clientPort: 5173 } },
clearScreen: false
}
```
- Edit `src/App.jsx`, save, DSI refreshes.
## Next Steps After Prove
Once hello world React works reliably on DSI with touch:
1. Add more React apps (Scratch via iframe?).
2. Optionally re-add local server later for central sync (v2's server.py) — but keep v3 frontend-only for fast iteration.
3. Only then create systemd service using same working command (openvt -f -c 1 -s ...).
## Commands Reference (the one you asked to keep)
Base proven command (no backend, just file:// hello world):
```bash
cage -s -- \
cog --platform=wl file:///home/elias/KiddOsV2/browser/hello.html --bg-color=#2E3440
```
For v3 React with HMR:
```bash
cage -s -- \
cog --platform=wl http://localhost:5173 --bg-color=#2E3440
```
If Cog touch fails, try Chromium:
```bash
cage -s -- \
chromium --kiosk --no-sandbox --ozone-platform=wayland --disable-gpu \
http://localhost:5173
```
Electron alternative (heavier, same as Chromium + Node, you asked about):
```bash
cage -s -- \
./node_modules/.bin/electron --no-sandbox --ozone-platform=wayland --disable-gpu .
```
## Why not LVGL anymore?
- v1 LVGL had to fork/exec + DRM master handover for each app → “any app I try to open freeze and restarts the OS” (your report). Non-DRM apps (gnome-calculator) crashed.
- Browser OS: one process owns DRM forever, apps are just React components / pages, no fork, no black screen.
Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Executable
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# v3 dev - localhost only, no external IPs per safety boundary
# This script enforces localhost + file:// only.
set -e
cd "$(dirname "$0")/../webapp"
echo "🔒 KiddOs v3 — LOCALHOST ONLY MODE"
echo " Allowed: http://localhost:5173, http://127.0.0.1:5173, file://dist/index.html"
echo " Blocked: any https:// external (scratch.mit.edu etc removed)"
echo ""
# Check no external URLs in actual code (ignore comments that say blocked)
if grep -R "https://scratch.mit.edu\|https://turbowarp\|https://machinelearningforkids" src/ --include="*.jsx" | grep -v "Blocked\|blocked\|❌" ; then
echo "❌ Found external URL in code - remove per safety boundary!"
exit 1
else
echo "✅ No external URLs in App code (localhost only lock OK)"
fi
if [ ! -d node_modules ]; then
npm install
fi
# Kill old cage/cog if still from v2 test
sudo pkill -9 cage cog 2>/dev/null || true
sudo rm -f /run/user/1000/wayland-* 2>/dev/null || true
sleep 1
echo ""
echo "🚀 Starting Vite dev server on localhost:5173..."
echo " Edit src/App.jsx -> DSI hot reloads in <1s (still localhost WebSocket)"
echo " DSI command (in another SSH):"
echo " sudo openvt -f -c 1 -s -- env XDG_RUNTIME_DIR=/run/user/1000 cage -s -- cog --platform=wl http://localhost:5173 --bg-color=#2E3440"
echo ""
./node_modules/.bin/vite --host 0.0.0.0 --port 5173
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
# Proven kiosk - LOCALHOST ONLY per safety boundary
set -e
MODE="${1:-dev}" # dev | prod | hello (hello only for local file test)
URL="http://localhost:5173"
BG="#2E3440"
if [ "$MODE" = "prod" ]; then
URL="file:///home/elias/KiddOsV3/webapp/dist/index.html"
elif [ "$MODE" = "hello" ]; then
URL="file:///home/elias/KiddOsV2/browser/hello.html"
fi
# Safety check: only allow localhost or file://
if [[ "$URL" != http://localhost* && "$URL" != http://127.0.0.1* && "$URL" != file://* ]]; then
echo "❌ Blocked: Only localhost:5173 or file:// allowed per parent boundary! Tried: $URL"
exit 1
fi
echo "🔒 KiddOs v3 kiosk — localhost/file only"
echo " URL: $URL (allowed)"
echo " BG: $BG"
echo " Stopping old v2/v1 kiosks..."
sudo systemctl stop kiddos.service kiddos-browser.service journal-kiosk.service kiddos-v3.service 2>/dev/null || true
sudo pkill -9 cage cog 2>/dev/null || true
sudo rm -f /run/user/1000/wayland-* 2>/dev/null || true
sleep 1
if [ "$MODE" = "dev" ]; then
if ! curl -s --connect-timeout 2 http://localhost:5173/ | grep -q "KiddOs" ; then
echo "⚠️ Vite dev server not running on :5173!"
echo " Start in another SSH: cd ~/KiddOsV3/webapp && npm run dev"
echo " Falling back to prod file://"
URL="file:///home/elias/KiddOsV3/webapp/dist/index.html"
fi
fi
echo " Starting: cage -s -- cog --platform=wl $URL"
sudo openvt -f -c 1 -s -- env XDG_RUNTIME_DIR=/run/user/1000 cage -s -- cog --platform=wl "$URL" --bg-color="$BG"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# Dev helper: run both vite + quick test
set -e
cd "$(dirname "$0")/../webapp"
echo "Checking vite build..."
npm run build
echo "Build ok -> dist/"
echo ""
echo "Preview static build on http://localhost:4173 to test without HMR"
npm run preview -- --host 0.0.0.0 --port 4173 &
PREVIEW_PID=$!
echo "Preview PID $PREVIEW_PID"
sleep 2
echo ""
echo "Try kiosk with prod file:"
echo "cage -s -- cog --platform=wl file://$PWD/dist/index.html --bg-color=#2E3440"
echo ""
echo "Press Ctrl+C to stop preview"
wait $PREVIEW_PID
+9
View File
@@ -0,0 +1,9 @@
# KiddOs v3 React - Hot Reload
Run dev server:
npm install
npm run dev -- --host 0.0.0.0 --port 5173
Then on Pi DSI:
cage -s -- cog --platform=wl http://localhost:5173 --bg-color=#2E3440
Or:
cage -s -- chromium --kiosk --ozone-platform=wayland http://localhost:5173
Edit src/App.jsx and see hot reload instantly on DSI.
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>KiddOs v3 React</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#2E3440; color:#ECEFF4; font-family: -apple-system, sans-serif; overflow:hidden}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1698
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "kiddos-v3-react",
"private": true,
"version": "3.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

+268
View File
@@ -0,0 +1,268 @@
import { useState, useEffect } from 'react'
import Journal from './apps/Journal.jsx'
import Calculator from './apps/Calculator.jsx'
import Drawing from './apps/Drawing.jsx'
import CodeLab from './apps/CodeLab.jsx'
import Clock from './apps/Clock.jsx'
import Files from './apps/Files.jsx'
import Scratch from './apps/Scratch.jsx'
// LOCAL ONLY - per parent boundary: no external IPs, only localhost + file://
// Allowed: http://localhost:5173, http://127.0.0.1:5173, file://
// Blocked: https://scratch.mit.edu etc. Scratch app now offline-only.
const APPS = [
{ name: 'Journal', icon: '/icons/journal.png', emoji:'📓', color: '#88C0D0', category: 'Create', desc: 'Write daily adventure', comp: 'journal', order: 10 },
{ name: 'Code', icon: '/icons/code.png', emoji:'💻', color: '#5E81AC', category: 'Create', desc: 'Make something', comp: 'code', order: 15 },
{ name: 'Scratch', icon: '/icons/scratch.png', emoji:'🧱', color: '#88C0D0', category: 'Create', desc: 'Blocks • offline', comp: 'scratch', order: 16 },
{ name: 'Calculator', icon: '/icons/calculator.png', emoji:'🧮', color: '#A3BE8C', category: 'Learn', desc: 'Do math', comp: 'calculator', order: 20 },
{ name: 'Clock', icon: '/icons/clock.png', emoji:'⏰', color: '#EBCB8B', category: 'System', desc: 'Time & timer', comp: 'clock', order: 22 },
{ name: 'Drawing', icon: '/icons/play.png', emoji:'🎨', color: '#9D7BD8', category: 'Play', desc: 'Draw & paint', comp: 'drawing', order: 25 },
{ name: 'Play', icon: '/icons/play.png', emoji:'🎮', color: '#9D7BD8', category: 'Play', desc: 'Play ground', comp: 'drawing', order: 26 },
{ name: 'Files', icon: '/icons/files.png', emoji:'📁', color: '#5E81AC', category: 'System', desc: 'Browse creations', comp: 'files', order: 30 },
]
const CATS = ['All','Create','Learn','Play','System']
const CAT_COLORS = {All:'#D8DEE9', Create:'#88C0D0', Learn:'#A3BE8C', Play:'#9D7BD8', System:'#5E81AC'}
const CAT_ICONS = {All:'☰', Create:'✏', Learn:'✈', Play:'▶', System:'⚙'}
const COMPS = {
journal: Journal,
calculator: Calculator,
drawing: Drawing,
code: CodeLab,
clock: Clock,
files: Files,
scratch: Scratch,
}
export default function App(){
const [cat, setCat] = useState(()=> localStorage.getItem('kiddos_cat') || 'All')
const [clock, setClock] = useState(new Date())
const [showSettings, setShowSettings] = useState(false)
const [currentApp, setCurrentApp] = useState(null)
const [recents, setRecents] = useState(()=> JSON.parse(localStorage.getItem('kiddos_recents')||'[]'))
const [companionMsg, setCompanionMsg] = useState(()=> localStorage.getItem('kiddos_companion_message')||'')
const [stats, setStats] = useState(()=> {
const keys = Object.keys(localStorage).filter(k=>k.startsWith('journal_'))
return {entries: keys.length}
})
useEffect(()=>{
const t=setInterval(()=>setClock(new Date()),1000)
return ()=>clearInterval(t)
},[])
// Local-only stats polling. Companion is local file ~/launcher_config/companion_message.txt
// We mirror via localStorage key kiddos_companion_message if parent sets it.
// No external fetch.
useEffect(()=>{
const id=setInterval(()=>{
const keys = Object.keys(localStorage).filter(k=>k.startsWith('journal_'))
setStats({entries: keys.length})
const msg = localStorage.getItem('kiddos_companion_message')
if(msg) setCompanionMsg(msg)
},3000)
return ()=>clearInterval(id)
},[])
useEffect(()=>{
localStorage.setItem('kiddos_cat', cat)
},[cat])
const saveRecent = (name)=>{
const r=[name, ...recents.filter(n=>n!==name)].slice(0,8)
setRecents(r)
localStorage.setItem('kiddos_recents', JSON.stringify(r))
}
const openApp = (app)=>{
saveRecent(app.name)
setCurrentApp(app)
}
const filtered = (cat==='All'? APPS : APPS.filter(a=>a.category===cat)).sort((a,b)=>a.order-b.order)
const favs = JSON.parse(localStorage.getItem('kiddos_favs')||'["Journal","Code","Calculator"]')
const favApps = APPS.filter(a=>favs.includes(a.name))
const recentApps = recents.map(n=>APPS.find(a=>a.name===n)).filter(Boolean).filter(a=>!favs.includes(a.name))
const favToShow = [...favApps, ...recentApps].slice(0,10)
if(currentApp){
const Comp = COMPS[currentApp.comp]
return (
<div style={{height:'100vh', display:'flex', flexDirection:'column', background:'#2E3440'}}>
<header style={{height:'52px', background:'#1E222E', borderBottom:'1px solid #4C566A', display:'flex', alignItems:'center', justifyContent:'space-between', padding:'0 12px', flexShrink:0}}>
<div style={{display:'flex', gap:'10px', alignItems:'center'}}>
<button onClick={()=>setCurrentApp(null)} style={{background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4', borderRadius:'8px', padding:'0 10px', height:'36px', cursor:'pointer'}}>🏠 KiddOs</button>
<span style={{fontSize:'16px'}}>{currentApp.emoji}</span>
<strong style={{fontSize:'14px'}}>{currentApp.name}</strong>
<span style={{fontSize:'11px', color:'#8E99A8', background:'#3B4252', padding:'2px 6px', borderRadius:'8px', border:'1px solid #4C566A'}}>{currentApp.category}</span>
</div>
<div style={{display:'flex', gap:'8px', alignItems:'center'}}>
<span style={{fontSize:'15px', color:'#88C0D0', fontFamily:'ui-monospace, SFMono-Regular, Menlo, monospace', fontWeight:'800', background:'#232A36', padding:'4px 10px', borderRadius:'10px', border:'1px solid #88C0D0', letterSpacing:'0.5px'}}>{clock.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} • {stats.entries} entries</span>
<button onClick={()=>setShowSettings(true)} style={{background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4', borderRadius:'8px', width:'40px', height:'36px', cursor:'pointer'}}>⚙</button>
</div>
</header>
<div style={{flex:1, overflow:'hidden', position:'relative'}}>
{Comp ? <Comp /> : <div style={{padding:20, color:'#ECEFF4'}}>App {currentApp.name} coming soon</div>}
</div>
{showSettings && <div onClick={()=>setShowSettings(false)} style={{position:'fixed', inset:0, background:'rgba(0,0,0,0.6)', zIndex:40}}></div>}
<SettingsDrawer open={showSettings} onClose={()=>setShowSettings(false)} stats={stats} favs={favs} onFavsChange={(f)=>{localStorage.setItem('kiddos_favs', JSON.stringify(f))}} recents={recents} />
</div>
)
}
return (
<div style={{display:'flex', flexDirection:'column', height:'100vh', background:'#2E3440', overflow:'hidden'}}>
<header style={{height:'56px', background:'#1E222E', borderBottom:'1px solid #4C566A', display:'flex', alignItems:'center', justifyContent:'space-between', padding:'0 12px', flexShrink:0}}>
<div style={{display:'flex', gap:'10px', alignItems:'center'}}>
<div style={{width:'36px', height:'36px', background:'#434C5E', borderRadius:'10px', display:'flex', alignItems:'center', justifyContent:'center', fontWeight:'bold', fontSize:'16px', border:'1px solid #4C566A'}}>E</div>
<div>
<div style={{fontWeight:'600', fontSize:'14px', display:'flex', alignItems:'center', gap:'6px'}}>Hey, Elias! <span style={{background:'#88C0D0', color:'#000', padding:'2px 6px', borderRadius:'10px', fontSize:'10px'}}>React v3 ⚡</span> <span style={{fontSize:'10px', color:'#8E99A8', background:'#232A36', border:'1px solid #4C566A', padding:'2px 6px', borderRadius:'8px'}}>{stats.entries} entries</span></div>
<div style={{fontSize:'11px', color:'#8E99A8', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis', maxWidth:'320px'}}>{companionMsg ? `✨ ${companionMsg}` : 'Offline • localhost:5173 only • No internet • Hot reload'}</div>
</div>
</div>
<div style={{color:'#88C0D0', fontSize:'18px', fontWeight:'800', fontFamily:'ui-monospace, SFMono-Regular, Menlo, monospace', textAlign:'center', minWidth:'175px', background:'#232A36', border:'1px solid #88C0D0', borderRadius:'10px', padding:'5px 10px', letterSpacing:'0.5px', boxShadow:'0 0 0 2px rgba(136,192,208,0.15)' }}>{clock.toLocaleString(undefined,{weekday:'short', month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'})}</div>
<div style={{display:'flex', gap:'8px'}}>
<button onClick={()=>location.reload()} style={{background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4', borderRadius:'8px', width:'40px', height:'36px', cursor:'pointer'}}>↻</button>
<button onClick={()=>setShowSettings(true)} style={{background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4', borderRadius:'8px', width:'40px', height:'36px', cursor:'pointer'}}>⚙</button>
</div>
</header>
<main style={{padding:'12px', display:'flex', flexDirection:'column', gap:'12px', height:'calc(100vh - 56px)', overflowY:'auto'}}>
<section>
<div style={{fontSize:'12px', marginBottom:'6px', display:'flex', justifyContent:'space-between', alignItems:'center'}}>
<span>⭐ Favorites & Recent (tap → hot reload on save)</span>
<span style={{fontSize:'10px', color:'#A3BE8C'}}>🔒 localhost only • file:// • offline</span>
</div>
<div style={{display:'flex', gap:'10px', overflowX:'auto', paddingBottom:'4px'}}>
{favToShow.map(app=>(
<div key={app.name} onClick={()=>openApp(app)} style={{minWidth:'110px', height:'80px', background:'#232A36', border:'1px solid #EBCB8B', borderRadius:'12px', display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:'4px', cursor:'pointer', flexShrink:0}}>
<img src={app.icon} alt="" onError={e=>{e.target.style.display='none'; e.target.nextSibling.style.display='block'}} style={{width:'32px', height:'32px', borderRadius:'6px'}}/>
<span style={{display:'none', fontSize:'20px'}}>{app.emoji}</span>
<span style={{fontSize:'12px', color:'#ECEFF4'}}>{app.name}</span>
</div>
))}
{favToShow.length===0 && <span style={{fontSize:'12px', color:'#8E99A8'}}>No favorites yet — tap apps below</span>}
</div>
</section>
<section style={{display:'flex', gap:'8px', overflowX:'auto'}}>
{CATS.map(c=>(
<button key={c} onClick={()=>setCat(c)} style={{padding:'6px 14px', borderRadius:'16px', border:'1px solid #4C566A', background: cat===c ? CAT_COLORS[c] : '#3B4252', color: cat===c ? '#000' : '#ECEFF4', fontSize:'12px', whiteSpace:'nowrap', cursor:'pointer', fontWeight: cat===c ? '700' : '400'}}>
{CAT_ICONS[c]} {c}
</button>
))}
</section>
<section style={{display:'flex', flexWrap:'wrap', gap:'10px'}}>
{filtered.map(app=>(
<div key={app.name} onClick={()=>openApp(app)} style={{width:'150px', minHeight:'132px', background:'#3B4252', border:'1px solid #4C566A', borderRadius:'16px', padding:'10px', display:'flex', flexDirection:'column', alignItems:'center', gap:'6px', cursor:'pointer'}}>
<div style={{width:'100%', height:'4px', borderRadius:'2px', background: CAT_COLORS[app.category] || '#4C566A'}}></div>
<div style={{position:'relative', width:'48px', height:'48px', display:'flex', alignItems:'center', justifyContent:'center'}}>
<img src={app.icon} alt="" onError={e=>{e.target.style.display='none'; e.target.parentElement.querySelector('.emoji-fallback').style.display='block'}} style={{width:'48px', height:'48px', borderRadius:'8px'}}/>
<span className="emoji-fallback" style={{display:'none', fontSize:'32px'}}>{app.emoji}</span>
</div>
<div style={{fontSize:'13px', fontWeight:'600', textAlign:'center', color:'#ECEFF4'}}>{app.name}</div>
<div style={{fontSize:'11px', color:'#8E99A8', textAlign:'center'}}>{app.desc}</div>
</div>
))}
</section>
<section style={{display:'flex', gap:'10px', flexWrap:'wrap', marginTop:'8px'}}>
<div style={{background:'#232A36', border:'1px solid #5E81AC', borderRadius:'10px', padding:'8px 10px', fontSize:'11px', color:'#8E99A8'}}>
<strong style={{color:'#D8DEE9'}}>DSI 800×480</strong> • cage + cog wl • FT5x06 touch • Vite HMR • 🔒 localhost:5173 only
</div>
<div style={{background:'#232A36', border:'1px solid #A3BE8C', borderRadius:'10px', padding:'8px 10px', fontSize:'11px', color:'#A3BE8C'}}>
✅ Offline • No external URLs • localStorage • file:// fallback works
</div>
</section>
</main>
{showSettings && <div onClick={()=>setShowSettings(false)} style={{position:'fixed', inset:0, background:'rgba(0,0,0,0.6)', zIndex:10, display:'block'}}></div>}
<SettingsDrawer open={showSettings} onClose={()=>setShowSettings(false)} stats={stats} favs={favs} onFavsChange={(f)=>{localStorage.setItem('kiddos_favs', JSON.stringify(f)); location.reload()}} recents={recents} />
</div>
)
}
function SettingsDrawer({open, onClose, stats, favs, onFavsChange, recents}){
const [allApps] = useState(()=>APPS.map(a=>a.name))
return (
<div style={{position:'fixed', top:0, right:0, width:'360px', maxWidth:'85vw', height:'100%', background:'#3B4252', borderLeft:'1px solid #4C566A', transform: open ? 'translateX(0)' : 'translateX(100%)', transition:'transform 0.25s ease', overflowY:'auto', zIndex:50, display:'flex', flexDirection:'column'}}>
<div style={{height:'52px', background:'#1E222E', borderBottom:'1px solid #4C566A', display:'flex', alignItems:'center', justifyContent:'space-between', padding:'0 12px', flexShrink:0}}>
<strong>⚙ KiddOs v3 React</strong>
<button onClick={onClose} style={{background:'#434C5E', border:'1px solid #4C566A', color:'#ECEFF4', borderRadius:'8px', width:'32px', height:'32px', cursor:'pointer'}}>✕</button>
</div>
<div style={{padding:'12px', display:'flex', flexDirection:'column', gap:'12px', overflowY:'auto'}}>
<div style={{background:'#434C5E', border:'1px solid #4C566A', borderRadius:'12px', padding:'10px'}}>
<h3 style={{fontSize:'13px', marginBottom:'6px'}}>📊 Stats (local)</h3>
<div style={{fontSize:'11px', color:'#D8DEE9', lineHeight:'1.6'}}>
<div>Journal entries: {stats.entries}</div>
<div>Recents: {recents.join(', ')||'none'}</div>
<div>Touch: {'ontouchstart' in window ? 'Yes ✅' : 'No — try cage may need libinput debug'}</div>
<div>DSI: 800×480 • DRM card1 connector 49</div>
<div>UserAgent: {navigator.userAgent.slice(0,80)}</div>
</div>
</div>
<div style={{background:'#434C5E', border:'1px solid #4C566A', borderRadius:'12px', padding:'10px'}}>
<h3 style={{fontSize:'13px', marginBottom:'6px'}}>⭐ Favorites (local only)</h3>
<div style={{display:'flex', flexDirection:'column', gap:'6px'}}>
{allApps.map(name=>{
const isFav = favs.includes(name)
return (
<label key={name} style={{display:'flex', alignItems:'center', gap:'8px', fontSize:'12px', cursor:'pointer'}}>
<input type="checkbox" checked={isFav} onChange={()=>{
const next = isFav ? favs.filter(f=>f!==name) : [...favs, name]
onFavsChange(next)
}} />
<span>{name}</span>
</label>
)
})}
</div>
<div style={{fontSize:'10px', color:'#8E99A8', marginTop:'6px'}}>Offline • Saved in localStorage</div>
</div>
<div style={{background:'#232A36', border:'1px solid #5E81AC', borderRadius:'12px', padding:'10px'}}>
<h3 style={{fontSize:'13px', marginBottom:'6px'}}>🔒 Network lock — localhost only</h3>
<p style={{fontSize:'11px', color:'#D8DEE9', lineHeight:'1.4'}}>Per safety boundary: browser may only load localhost or file:// — no external IPs. This means:</p>
<ul style={{fontSize:'11px', color:'#8E99A8', marginTop:'6px', paddingLeft:'16px', lineHeight:'1.5'}}>
<li>✅ http://localhost:5173 (Vite HMR dev)</li>
<li>✅ http://127.0.0.1:5173</li>
<li>✅ file:///home/elias/KiddOsV3/webapp/dist/index.html (prod)</li>
<li>❌ https://scratch.mit.edu (blocked — now offline Scratch app)</li>
</ul>
<p style={{fontSize:'10px', color:'#8E99A8', marginTop:'6px'}}>Cog launched with --doc-viewer but no external browse. All app content is local React.</p>
</div>
<div style={{background:'#232A36', border:'1px solid #4C566A', borderRadius:'12px', padding:'10px'}}>
<h3 style={{fontSize:'13px', marginBottom:'6px'}}>🖥️ Kiosk commands (localhost only)</h3>
<pre style={{fontSize:'10px', whiteSpace:'pre-wrap', color:'#8E99A8', background:'#000', padding:'8px', borderRadius:'6px', overflow:'auto'}}>
{`# dev server (SSH 1) — localhost only
cd ~/KiddOsV3/webapp
npm run dev -- --host 0.0.0.0 --port 5173
# DSI (SSH 2) — only localhost, no external
sudo openvt -f -c 1 -s -- env XDG_RUNTIME_DIR=/run/user/1000 cage -s -- \\
cog --platform=wl http://localhost:5173 --bg-color=#2E3440
# prod file:// (no network at all)
cd ~/KiddOsV3/webapp && npm run build
cage -s -- cog --platform=wl file://$PWD/dist/index.html
`}
</pre>
</div>
<div style={{background:'#434C5E', border:'1px solid #4C566A', borderRadius:'12px', padding:'10px'}}>
<h3 style={{fontSize:'13px', marginBottom:'6px'}}>⚡ Hot reload test</h3>
<p style={{fontSize:'11px', color:'#D8DEE9', lineHeight:'1.4'}}>Edit src/App.jsx on laptop via SSH, save, DSI updates in &lt;1s via Vite WebSocket (same host:port, still localhost). Try changing greeting text or card color.</p>
<pre style={{fontSize:'10px', color:'#88C0D0', background:'#000', padding:'6px', borderRadius:'4px', marginTop:'6px'}}>Test: change "Hey, Elias!" to "Hey, Elias! 🔥 HMR works!" in App.jsx and save → watch DSI</pre>
</div>
</div>
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { useState } from 'react'
export default function Calculator(){
const [expr, setExpr] = useState('')
const [history, setHistory] = useState(()=> JSON.parse(localStorage.getItem('calc_history')||'[]'))
const press = (c)=> setExpr(e=>e+c)
const eq = ()=>{
try{
// safe-ish: only allow math chars
if(!/^[0-9+\-*/().% ]+$/.test(expr)) throw new Error('Invalid')
const res = String(Function('"use strict";return ('+expr+')')())
const entry = expr+' = '+res
const h = [entry, ...history].slice(0,20)
setHistory(h)
localStorage.setItem('calc_history', JSON.stringify(h))
setExpr(res)
}catch{
setExpr('Error')
}
}
const buttons = [
[{l:'C', a:()=>setExpr(''), s:{background:'#D08770', color:'#000'}}, {l:'÷', a:()=>press('/'), s:{background:'#434C5E'}}, {l:'×', a:()=>press('*'), s:{background:'#434C5E'}}, {l:'⌫', a:()=>setExpr(e=>e.slice(0,-1)), s:{}}],
[{l:'7', a:()=>press('7')}, {l:'8', a:()=>press('8')}, {l:'9', a:()=>press('9')}, {l:'-', a:()=>press('-'), s:{background:'#434C5E'}}],
[{l:'4', a:()=>press('4')}, {l:'5', a:()=>press('5')}, {l:'6', a:()=>press('6')}, {l:'+', a:()=>press('+'), s:{background:'#434C5E'}}],
[{l:'1', a:()=>press('1')}, {l:'2', a:()=>press('2')}, {l:'3', a:()=>press('3')}, {l:'=', a:eq, s:{background:'#A3BE8C', color:'#000', fontWeight:'700'}}],
[{l:'0', a:()=>press('0'), span:2}, {l:'.', a:()=>press('.')}, {l:'(', a:()=>press('(')}],
]
return (
<div style={{display:'flex', height:'100%', background:'#2E3440'}}>
<div style={{flex:1, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:'14px', padding:'16px'}}>
<div style={{width:'300px', background:'#232A36', border:'2px solid #4C566A', borderRadius:'16px', padding:'4px', display:'flex', flexDirection:'column', gap:'8px'}}>
<div style={{background:'#3B4252', border:'1px solid #4C566A', borderRadius:'12px', padding:'14px 12px', fontSize:'26px', textAlign:'right', minHeight:'60px', fontVariantNumeric:'tabular-nums', wordBreak:'break-all', color:'#ECEFF4'}}>{expr||'0'}</div>
<div style={{display:'flex', flexDirection:'column', gap:'10px', padding:'8px'}}>
{buttons.map((row,i)=>(
<div key={i} style={{display:'grid', gridTemplateColumns:'repeat(4,1fr)', gap:'10px'}}>
{row.map((b,j)=>{
const style = {height:'56px', borderRadius:'12px', border:'1px solid #4C566A', background:'#3B4252', color:'#ECEFF4', fontSize:'20px', cursor:'pointer', gridColumn: b.span ? `span ${b.span}` : 'auto', ...(b.s||{})}
return <button key={j} onClick={b.a} style={style}>{b.l}</button>
})}
</div>
))}
</div>
</div>
<div style={{fontSize:'11px', color:'#8E99A8'}}>Touch friendly • Offline • localStorage history • React v3</div>
</div>
<div style={{width:'30%', borderLeft:'1px solid #4C566A', background:'#232A36', display:'flex', flexDirection:'column'}}>
<div style={{padding:'10px', borderBottom:'1px solid #4C566A', display:'flex', justifyContent:'space-between', alignItems:'center'}}>
<strong style={{fontSize:'13px'}}>History</strong>
<button onClick={()=>{setHistory([]); localStorage.removeItem('calc_history')}} style={{fontSize:'11px', padding:'4px 8px', borderRadius:'6px'}}>Clear</button>
</div>
<div style={{flex:1, overflowY:'auto', padding:'8px', display:'flex', flexDirection:'column', gap:'6px'}}>
{history.length===0 && <div style={{fontSize:'11px', color:'#8E99A8'}}>No calculations yet</div>}
{history.map((h,i)=>(
<div key={i} onClick={()=>setExpr(h.split(' = ')[0])} style={{background:'#3B4252', border:'1px solid #4C566A', borderRadius:'8px', padding:'8px', cursor:'pointer'}}>
<div style={{fontSize:'12px', color:'#ECEFF4', wordBreak:'break-all'}}>{h}</div>
</div>
))}
</div>
</div>
</div>
)
}
+90
View File
@@ -0,0 +1,90 @@
import { useState, useEffect, useRef } from 'react'
export default function Clock(){
const [time, setTime] = useState(new Date())
const [timerSec, setTimerSec] = useState(0)
const [timerRunning, setTimerRunning] = useState(false)
const [mode, setMode] = useState('clock') // clock | timer | stopwatch
const [stopwatchMs, setStopwatchMs] = useState(0)
const [swRunning, setSwRunning] = useState(false)
const intervalRef = useRef(null)
useEffect(()=>{
const t=setInterval(()=>setTime(new Date()),1000)
return ()=>clearInterval(t)
},[])
useEffect(()=>{
if(timerRunning && timerSec>0){
intervalRef.current=setInterval(()=>setTimerSec(s=>{ if(s<=1){ setTimerRunning(false); return 0 } return s-1 }),1000)
} else {
clearInterval(intervalRef.current)
}
return ()=>clearInterval(intervalRef.current)
},[timerRunning, timerSec])
useEffect(()=>{
if(!swRunning) return
const start = Date.now() - stopwatchMs
const id=setInterval(()=>setStopwatchMs(Date.now()-start), 50)
return ()=>clearInterval(id)
},[swRunning])
const fmt = (ms)=>{
const totalSec=Math.floor(ms/1000)
const m=Math.floor(totalSec/60)
const s=totalSec%60
const cs=Math.floor((ms%1000)/10)
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(cs).padStart(2,'0')}`
}
const fmtSec = (sec)=>{
const m=Math.floor(sec/60)
const s=sec%60
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
}
return (
<div style={{display:'flex', flexDirection:'column', height:'100%', background:'#2E3440', color:'#ECEFF4'}}>
<div style={{display:'flex', gap:'8px', padding:'8px', background:'#3B4252', borderBottom:'1px solid #4C566A'}}>
{['clock','timer','stopwatch'].map(m=>(
<button key={m} onClick={()=>setMode(m)} style={{padding:'6px 12px', borderRadius:'8px', border:'1px solid #4C566A', background: mode===m ? '#88C0D0' : '#434C5E', color: mode===m ? '#000':'#ECEFF4', fontWeight: mode===m?'600':'400'}}>{m}</button>
))}
</div>
<div style={{flex:1, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:'20px'}}>
{mode==='clock' && (
<>
<div style={{fontSize:'64px', fontWeight:'700'}}>{time.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}</div>
<div style={{fontSize:'18px', color:'#8E99A8'}}>{time.toLocaleDateString(undefined,{weekday:'long', month:'long', day:'numeric'})}</div>
<div style={{fontSize:'14px', color:'#8E99A8', marginTop:'20px', textAlign:'center', maxWidth:'300px'}}>DSI 800x480 • Offline • cage + cog • React v3 hot reload works</div>
</>
)}
{mode==='timer' && (
<>
<div style={{fontSize:'72px', fontWeight:'700', color: timerSec===0 && !timerRunning ? '#A3BE8C' : '#ECEFF4'}}>{fmtSec(timerSec)}</div>
<div style={{display:'flex', gap:'10px', flexWrap:'wrap', justifyContent:'center'}}>
{[1,3,5,10].map(m=>(
<button key={m} onClick={()=>{setTimerSec(m*60); setTimerRunning(false)}} style={{padding:'10px 16px', borderRadius:'10px', background:'#434C5E', border:'1px solid #4C566A', color:'#ECEFF4'}}>{m}m</button>
))}
<button onClick={()=>{setTimerSec(s=>s+30)}} style={{padding:'10px 16px', borderRadius:'10px'}}>+30s</button>
</div>
<div style={{display:'flex', gap:'12px'}}>
<button onClick={()=>setTimerRunning(r=>!r)} style={{padding:'14px 32px', borderRadius:'12px', background: timerRunning?'#D08770':'#A3BE8C', color:'#000', border:'none', fontSize:'18px', fontWeight:'700'}}>{timerRunning?'Pause':'Start'}</button>
<button onClick={()=>{setTimerRunning(false); setTimerSec(0)}} style={{padding:'14px 20px', borderRadius:'12px', background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4'}}>Reset</button>
</div>
{timerSec===0 && !timerRunning && <div style={{fontSize:'20px'}}>🎉 Time's up!</div>}
</>
)}
{mode==='stopwatch' && (
<>
<div style={{fontSize:'64px', fontWeight:'700', fontVariantNumeric:'tabular-nums'}}>{fmt(stopwatchMs)}</div>
<div style={{display:'flex', gap:'12px'}}>
<button onClick={()=>setSwRunning(r=>!r)} style={{padding:'14px 32px', borderRadius:'12px', background: swRunning?'#D08770':'#A3BE8C', color:'#000', border:'none', fontSize:'18px', fontWeight:'700'}}>{swRunning?'Stop':'Start'}</button>
<button onClick={()=>{setSwRunning(false); setStopwatchMs(0)}} style={{padding:'14px 20px', borderRadius:'12px', background:'#3B4252', border:'1px solid #4C566A'}}>Reset</button>
</div>
</>
)}
</div>
</div>
)
}
+93
View File
@@ -0,0 +1,93 @@
import { useState, useEffect } from 'react'
const SAMPLES = {
hello: `let name = "Elias";
console.log("Hey " + name + "!");
for(let i=1; i<=5; i++){
console.log(i + " x 7 = " + (i*7));
}
function rocket(n){
return "🚀".repeat(n) + " To the stars!";
}
console.log(rocket(5));`,
game: `// Tiny guessing game
let secret = Math.floor(Math.random()*10)+1;
let guesses = [];
for(let i=0;i<5;i++){
let g = Math.floor(Math.random()*10)+1;
guesses.push(g);
console.log("Guess " + g + (g===secret ? " 🎉 HIT!" : " ..nope"));
if(g===secret) break;
}
console.log("Secret was " + secret);`,
art: `// ASCII art loops
let w = 20;
for(let y=0;y<10;y++){
let line="";
for(let x=0;x<w;x++){
line += (x+y)%3===0 ? "█" : (x*y)%5===0 ? "▒" : " ";
}
console.log(line);
}`
}
export default function CodeLab(){
const [code, setCode] = useState(()=> localStorage.getItem('codelab_last') || SAMPLES.hello)
const [out, setOut] = useState('Click Run • Offline • React v3 hot reload \n\nEdit src/apps/CodeLab.jsx and DSI updates instantly!')
const [selectedSample, setSelectedSample] = useState('hello')
useEffect(()=>{
localStorage.setItem('codelab_last', code)
},[code])
const run=()=>{
let logs=[]
const customLog=(...a)=>logs.push(a.map(v=>{
if(typeof v==='object') return JSON.stringify(v, null, 2)
return String(v)
}).join(' '))
try{
const fn=new Function('console', '"use strict";\n'+code)
fn({log:customLog, warn:customLog, error:customLog})
setOut(logs.length ? logs.join('\\n') : '(no output — try console.log something)')
}catch(e){ setOut('Error: '+e.message+'\\n'+(e.stack||'')) }
}
return (
<div style={{display:'flex', height:'100%', background:'#2E3440'}}>
<div style={{flex:1, display:'flex', flexDirection:'column', padding:'8px', gap:'8px', borderRight:'1px solid #4C566A'}}>
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center'}}>
<div style={{fontSize:'12px', color:'#8E99A8'}}>JS playground • offline • Vite HMR • Edit saves to localStorage</div>
<div style={{display:'flex', gap:'6px'}}>
{Object.keys(SAMPLES).map(k=>(
<button key={k} onClick={()=>{setSelectedSample(k); setCode(SAMPLES[k])}} style={{padding:'4px 8px', borderRadius:'6px', fontSize:'11px', border:'1px solid #4C566A', background: selectedSample===k ? '#88C0D0':'#3B4252', color: selectedSample===k ? '#000':'#ECEFF4'}}>{k}</button>
))}
</div>
</div>
<textarea
value={code}
onChange={e=>setCode(e.target.value)}
style={{flex:1, background:'#232A36', border:'1px solid #4C566A', borderRadius:'8px', color:'#ECEFF4', padding:'12px', fontFamily:'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize:'13px', lineHeight:'1.5', resize:'none', outline:'none'}}
spellCheck={false}
/>
<div style={{display:'flex', gap:'8px'}}>
<button onClick={run} style={{flex:1, background:'#A3BE8C', color:'#000', padding:'10px', borderRadius:'8px', border:'none', fontWeight:'700', fontSize:'14px', cursor:'pointer'}}>▶ Run (Ctrl+Enter)</button>
<button onClick={()=>setCode('')} style={{padding:'10px 16px', borderRadius:'8px', background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4'}}>Clear</button>
</div>
</div>
<div style={{flex:1, borderLeft:'1px solid #4C566A', display:'flex', flexDirection:'column', background:'#0a0e14'}}>
<div style={{padding:'8px 10px', borderBottom:'1px solid #1E222E', display:'flex', justifyContent:'space-between', alignItems:'center', background:'#1E222E'}}>
<div style={{fontSize:'12px', color:'#8E99A8'}}>Output</div>
<button onClick={()=>setOut('')} style={{fontSize:'11px', padding:'3px 8px', borderRadius:'6px'}}>Clear</button>
</div>
<pre style={{flex:1, margin:0, padding:'12px', color:'#A3BE8C', fontSize:'13px', overflow:'auto', whiteSpace:'pre-wrap', wordBreak:'break-word', fontFamily:'ui-monospace, monospace'}}>{out}</pre>
<div style={{padding:'6px 10px', background:'#1E222E', borderTop:'1px solid #232A36', fontSize:'10px', color:'#4C566A'}}>Tip: This uses new Function(). Safe locally — no network. Try loops, functions, console.log. In v3, editing this file on laptop SSH hot-reloads on DSI in &lt;1s via Vite.</div>
</div>
</div>
)
}
+148
View File
@@ -0,0 +1,148 @@
import { useRef, useEffect, useState } from 'react'
export default function Drawing(){
const canvasRef = useRef(null)
const [color, setColor] = useState('#88C0D0')
const [size, setSize] = useState(8)
const [tool, setTool] = useState('pen')
const drawing = useRef(false)
const history = useRef([])
const [showSaveOk, setShowSaveOk] = useState(false)
useEffect(()=>{
const c = canvasRef.current
const resize = ()=>{
const parent = c.parentElement
const dpr = window.devicePixelRatio || 1
c.width = parent.clientWidth * dpr
c.height = parent.clientHeight * dpr
c.style.width = parent.clientWidth+'px'
c.style.height = parent.clientHeight+'px'
const ctx=c.getContext('2d')
ctx.scale(dpr,dpr)
ctx.fillStyle='#fff'
ctx.fillRect(0,0,parent.clientWidth,parent.clientHeight)
}
resize()
window.addEventListener('resize', resize)
return ()=>window.removeEventListener('resize', resize)
},[])
const getCtx = ()=>{
const c = canvasRef.current
const ctx = c.getContext('2d')
// reset transform that was scaled
return ctx
}
const pos = (e)=>{
const rect = canvasRef.current.getBoundingClientRect()
const t = e.touches ? e.touches[0] : e
return {x:t.clientX-rect.left, y:t.clientY-rect.top}
}
const saveState = ()=>{
try{
history.current.push(canvasRef.current.toDataURL('image/png'))
if(history.current.length>25) history.current.shift()
// save thumbnail for files app preview?
localStorage.setItem('drawing_last', canvasRef.current.toDataURL('image/png'))
}catch{}
}
const start = (e)=>{
drawing.current=true
const {x,y}=pos(e)
const ctx=canvasRef.current.getContext('2d')
ctx.beginPath()
ctx.moveTo(x,y)
saveState()
e.preventDefault()
}
const move = (e)=>{
if(!drawing.current) return
const {x,y}=pos(e)
const ctx=canvasRef.current.getContext('2d')
ctx.lineTo(x,y)
ctx.strokeStyle= tool==='eraser' ? '#ffffff' : color
ctx.lineWidth=size
ctx.lineCap='round'
ctx.lineJoin='round'
ctx.stroke()
ctx.beginPath()
ctx.moveTo(x,y)
e.preventDefault()
}
const end = ()=>{
if(!drawing.current) return
drawing.current=false
const ctx=canvasRef.current.getContext('2d')
ctx.beginPath()
}
const clearCanvas = ()=>{
saveState()
const c=canvasRef.current
const ctx=c.getContext('2d')
const rect=c.getBoundingClientRect()
ctx.fillStyle='#fff'
ctx.fillRect(0,0,rect.width,rect.height)
}
const undo = ()=>{
if(history.current.length===0) return
const data = history.current.pop()
const img=new Image()
img.src=data
img.onload=()=>{
const c=canvasRef.current
const ctx=c.getContext('2d')
const r=c.getBoundingClientRect()
ctx.clearRect(0,0,r.width,r.height)
ctx.drawImage(img,0,0,r.width,r.height)
}
}
const saveImg = ()=>{
const a=document.createElement('a')
a.download='kiddos-drawing-'+Date.now()+'.png'
a.href=canvasRef.current.toDataURL('image/png')
a.click()
setShowSaveOk(true)
setTimeout(()=>setShowSaveOk(false),2000)
// also save to localStorage for Files app
try{
localStorage.setItem('drawing_'+Date.now(), canvasRef.current.toDataURL())
}catch{}
}
return (
<div style={{display:'flex', flexDirection:'column', height:'100%', overflow:'hidden'}}>
<div style={{display:'flex', gap:'8px', padding:'8px', background:'#3B4252', borderBottom:'1px solid #4C566A', alignItems:'center', flexWrap:'wrap'}}>
<input type="color" value={color} onChange={e=>setColor(e.target.value)} style={{height:'36px', width:'36px', borderRadius:'8px', padding:'0', border:'1px solid #4C566A'}}/>
<input type="range" min="2" max="40" value={size} onChange={e=>setSize(e.target.value)} style={{width:'80px'}}/>
<span style={{fontSize:'11px', color:'#8E99A8', minWidth:'26px'}}>{size}px</span>
<div style={{width:'1px', height:'24px', background:'#4C566A', margin:'0 4px'}}/>
<button onClick={()=>setTool('pen')} style={{padding:'6px 12px', borderRadius:'8px', border:'1px solid #4C566A', background: tool==='pen' ? '#88C0D0' : '#434C5E', color: tool==='pen' ? '#000' : '#ECEFF4'}}>✏ Pen</button>
<button onClick={()=>setTool('eraser')} style={{padding:'6px 12px', borderRadius:'8px', border:'1px solid #4C566A', background: tool==='eraser' ? '#D8DEE9' : '#434C5E', color: tool==='eraser' ? '#000' : '#ECEFF4'}}>🧽 Eraser</button>
<div style={{width:'1px', height:'24px', background:'#4C566A', margin:'0 4px'}}/>
<button onClick={undo} style={{padding:'6px 12px', borderRadius:'8px', background:'#434C5E', border:'1px solid #4C566A', color:'#ECEFF4'}}>↩ Undo</button>
<button onClick={clearCanvas} style={{padding:'6px 12px', borderRadius:'8px', background:'#434C5E', border:'1px solid #4C566A', color:'#D08770'}}>Clear</button>
<button onClick={saveImg} style={{padding:'6px 14px', borderRadius:'8px', background:'#A3BE8C', border:'none', color:'#000', fontWeight:'700'}}>💾 Save</button>
{showSaveOk && <span style={{fontSize:'11px', color:'#A3BE8C'}}>Saved!</span>}
<div style={{marginLeft:'auto', fontSize:'11px', color:'#8E99A8'}}>Touch + mouse • 800×480 • localStorage</div>
</div>
<div style={{flex:1, background:'#777', position:'relative', overflow:'hidden'}}>
<canvas
ref={canvasRef}
onMouseDown={start} onMouseMove={move} onMouseUp={end} onMouseLeave={end}
onTouchStart={start} onTouchMove={move} onTouchEnd={end}
style={{display:'block', background:'#fff', touchAction:'none', cursor:'crosshair', width:'100%', height:'100%'}}
/>
</div>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { useState, useEffect } from 'react'
export default function Files(){
const [entries, setEntries] = useState([])
const [selected, setSelected] = useState(null)
const [preview, setPreview] = useState('')
const [dash, setDash] = useState('')
useEffect(()=>{
const keys = Object.keys(localStorage).filter(k=>k.startsWith('journal_')).sort().reverse()
setEntries(keys.map(k=>({id:k, text: localStorage.getItem(k)})))
const d = localStorage.getItem('kiddos_dashboard_fallback')
if(d) setDash(d)
// Try companion files via fetch if served from same origin? On dev we can't read file://, so show localStorage only.
// If user wants real files, they can cat ~/journal_entries via terminal.
},[])
const open = (e)=>{
setSelected(e)
setPreview(e.text.slice(0,2000))
}
return (
<div style={{display:'flex', height:'100%', background:'#2E3440'}}>
<div style={{width:'38%', borderRight:'1px solid #4C566A', display:'flex', flexDirection:'column'}}>
<div style={{padding:'10px', borderBottom:'1px solid #4C566A', background:'#3B4252'}}>
<strong style={{fontSize:'14px'}}>📁 Files • {entries.length} journal entries</strong>
<div style={{fontSize:'11px', color:'#8E99A8', marginTop:'2px'}}>localStorage • Offline • Private</div>
</div>
<div style={{flex:1, overflowY:'auto', padding:'6px', display:'flex', flexDirection:'column', gap:'6px'}}>
<div style={{background:'#232A36', border:'1px solid #4C566A', borderRadius:'8px', padding:'8px'}}>
<div style={{fontSize:'12px', fontWeight:'600'}}>journal_entries/ (local)</div>
<div style={{fontSize:'11px', color:'#8E99A8'}}>{entries.length} files • plain text</div>
</div>
{entries.map(e=>{
const wordCount = e.text.trim().split(/\s+/).filter(Boolean).length
return (
<div key={e.id} onClick={()=>open(e)} style={{background: selected?.id===e.id ? '#434C5E' : '#3B4252', border:'1px solid #4C566A', borderRadius:'8px', padding:'8px', cursor:'pointer'}}>
<div style={{fontSize:'12px', fontWeight:'600', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}}>{e.id}</div>
<div style={{fontSize:'11px', color:'#8E99A8'}}>{wordCount} words • {e.text.slice(0,40)}...</div>
</div>
)
})}
{entries.length===0 && <div style={{fontSize:'12px', color:'#8E99A8', padding:'12px'}}>No entries yet. Go write in Journal!</div>}
</div>
</div>
<div style={{flex:1, padding:'12px', display:'flex', flexDirection:'column', gap:'8px', overflow:'hidden'}}>
{!selected ? (
<div style={{display:'flex', flexDirection:'column', gap:'12px'}}>
<div style={{background:'#3B4252', border:'1px solid #4C566A', borderRadius:'12px', padding:'12px'}}>
<h3 style={{fontSize:'14px', marginBottom:'6px'}}>💾 Your creations are local</h3>
<p style={{fontSize:'12px', color:'#D8DEE9', lineHeight:'1.4'}}>All journal entries live in browser localStorage in v3. In v2 they were in ~/KiddOsV2/data/journal/*.json plain files. Try TTY2: <code style={{background:'#000', padding:'2px 4px', borderRadius:'4px'}}>ls ~/journal_entries/</code></p>
</div>
<div style={{background:'#3B4252', border:'1px solid #4C566A', borderRadius:'12px', padding:'12px'}}>
<h3 style={{fontSize:'14px', marginBottom:'6px'}}>Companion dashboard (if available)</h3>
<p style={{fontSize:'11px', color:'#8E99A8'}}>The companion writes ~/elias_companion/dashboard.md counts only, no text leaks.</p>
<pre style={{background:'#000', color:'#0f0', padding:'8px', borderRadius:'6px', fontSize:'10px', maxHeight:'200px', overflow:'auto', marginTop:'6px'}}>{dash || 'Open dashboard.md in TTY2: cat ~/elias_companion/dashboard.md'}</pre>
</div>
</div>
) : (
<>
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center'}}>
<strong style={{fontSize:'13px'}}>{selected.id}</strong>
<button onClick={()=>setSelected(null)} style={{padding:'4px 10px', borderRadius:'6px'}}>✕</button>
</div>
<pre style={{flex:1, background:'#434C5E', border:'1px solid #4C566A', borderRadius:'8px', padding:'10px', overflow:'auto', whiteSpace:'pre-wrap', fontSize:'13px', color:'#ECEFF4'}}>{preview}</pre>
<div style={{fontSize:'11px', color:'#8E99A8'}}>{selected.text.length} chars</div>
</>
)}
</div>
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { useState, useEffect, useRef } from 'react'
export default function Journal(){
const [text, setText] = useState('')
const [list, setList] = useState([])
const [status, setStatus] = useState('Auto-save on')
const [currentId, setCurrentId] = useState(null)
const [companionMsg, setCompanionMsg] = useState('')
const saveTimer = useRef(null)
const loadList = ()=>{
const keys = Object.keys(localStorage).filter(k=>k.startsWith('journal_')).sort().reverse()
setList(keys.map(k=>{
const t = localStorage.getItem(k) || ''
return {id:k, date: k.replace('journal_','').slice(0,16), preview: t.slice(0,80), full:t, words: t.trim().split(/\s+/).filter(Boolean).length}
}))
}
useEffect(()=>{
loadList()
const today = 'journal_'+new Date().toISOString().slice(0,10)
const legacy = Object.keys(localStorage).filter(k=>k.startsWith('journal_') && k.includes(new Date().toISOString().slice(0,10))).sort().reverse()[0]
const key = legacy || today
if(localStorage.getItem(key)) {
setText(localStorage.getItem(key))
setCurrentId(key)
setStatus('Restored today • '+localStorage.getItem(key).split(/\s+/).filter(Boolean).length+' words')
}
// Try load companion message (from dashboard if fetched? local fallback)
const msg = localStorage.getItem('kiddos_companion_message') || ''
if(msg) setCompanionMsg(msg)
},[])
const save = (forceText)=>{
const content = typeof forceText === 'string' ? forceText : text
if(!content.trim()) return
const id = currentId || 'journal_'+new Date().toISOString()
localStorage.setItem(id, content)
localStorage.setItem('journal_'+new Date().toISOString().slice(0,10), content)
localStorage.setItem('journal_'+new Date().toISOString().slice(0,10)+'_latest', content)
setCurrentId(id)
setStatus('Saved '+new Date().toLocaleTimeString()+' • '+content.trim().split(/\s+/).filter(Boolean).length+' words')
loadList()
}
const onChange = (v)=>{
setText(v)
setStatus('Typing... '+v.trim().split(/\s+/).filter(Boolean).length+' words')
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(()=>save(v), 1500)
}
const newEntry = ()=>{
if(text.trim()) save()
setText('')
setCurrentId(null)
setStatus('New adventure!')
}
return (
<div style={{display:'flex', height:'100%', background:'#2E3440', overflow:'hidden'}}>
<div style={{width:'34%', borderRight:'1px solid #4C566A', display:'flex', flexDirection:'column', background:'#232A36'}}>
<div style={{padding:'10px', borderBottom:'1px solid #4C566A', display:'flex', justifyContent:'space-between', alignItems:'center'}}>
<strong style={{fontSize:'13px'}}>📓 Past • {list.length}</strong>
<button onClick={loadList} style={{padding:'4px 10px', borderRadius:'6px', background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4', cursor:'pointer'}}>↻</button>
</div>
{companionMsg && (
<div style={{margin:'8px', background:'#3B4252', border:'1px solid #EBCB8B', borderRadius:'8px', padding:'8px', fontSize:'11px'}}>
<div style={{fontSize:'10px', color:'#EBCB8B', fontWeight:'700', marginBottom:'2px'}}>COMPANION</div>
<div style={{color:'#D8DEE9'}}>{companionMsg}</div>
</div>
)}
<div style={{flex:1, overflowY:'auto', padding:'6px', display:'flex', flexDirection:'column', gap:'6px'}}>
<button onClick={newEntry} style={{padding:'10px', borderRadius:'8px', background:'#88C0D0', border:'none', color:'#000', fontWeight:'700', cursor:'pointer', fontSize:'13px'}}>+ New Entry</button>
{list.map(item=>(
<div key={item.id} onClick={()=>{ setText(item.full); setCurrentId(item.id); setStatus('Reading • '+item.words+' words'); }} style={{background: currentId===item.id ? '#434C5E' : '#3B4252', border:`1px solid ${currentId===item.id ? '#88C0D0' : '#4C566A'}`, borderRadius:'8px', padding:'8px', cursor:'pointer'}}>
<div style={{fontSize:'11px', fontWeight:'600', display:'flex', justifyContent:'space-between'}}><span>{item.date}</span><span style={{color:'#88C0D0'}}>{item.words}w</span></div>
<div style={{fontSize:'11px', color:'#8E99A8', marginTop:'3px', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis'}}>{item.preview}...</div>
</div>
))}
{list.length===0 && <div style={{fontSize:'11px', color:'#8E99A8', padding:'12px', textAlign:'center'}}>No stories yet. Your first adventure starts here! ✨<br/><br/>Tip: Auto-saves while you type. Offline, private.</div>}
</div>
</div>
<div style={{flex:1, display:'flex', flexDirection:'column', padding:'12px', gap:'8px', background:'#2E3440'}}>
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center'}}>
<div style={{fontSize:'13px', color:'#D8DEE9'}}>What's on your mind today, explorer? <span style={{color:'#8E99A8', fontSize:'11px'}}>• React + localStorage + hot reload</span></div>
<div style={{fontSize:'11px', color:'#8E99A8'}}>{status}</div>
</div>
<textarea
value={text}
onChange={e=>onChange(e.target.value)}
placeholder={"Hey superstar! What adventure did you have today?\n\n- What made you smile?\n- What did you learn?\n- What do you want to build tomorrow?\n\nYour journal is private, offline, and stays on this Pi. No internet needed.\n\nStart typing... you got this! 💪"}
style={{flex:1, background:'#434C5E', border:'1px solid #4C566A', borderRadius:'12px', color:'#ECEFF4', padding:'14px', fontSize:'15px', lineHeight:'1.5', resize:'none', outline:'none', fontFamily:'system-ui, sans-serif'}}
autoFocus
/>
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center'}}>
<span style={{fontSize:'11px', color:'#8E99A8'}}>{text.trim().split(/\s+/).filter(Boolean).length} words • {text.length} chars • auto-save 1.5s</span>
<div style={{display:'flex', gap:'8px'}}>
<button onClick={()=>{setText(''); setCurrentId(null)}} style={{padding:'8px 14px', borderRadius:'8px', background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4', cursor:'pointer', fontSize:'12px'}}>Clear</button>
<button onClick={()=>save()} style={{padding:'8px 16px', borderRadius:'8px', background:'#88C0D0', border:'none', color:'#000', fontWeight:'700', cursor:'pointer', fontSize:'13px'}}>💾 Save now</button>
</div>
</div>
</div>
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { useState } from 'react'
const BLOCKS = [
{emoji:'🚀', name:'Move 10', code:'x += 10;'},
{emoji:'🔁', name:'Repeat', code:'for(let i=0;i<4;i++){\n console.log("step "+i);\n}'},
{emoji:'🎨', name:'Draw', code:'ctx.fillStyle="#88C0D0";\nctx.fillRect(x,y,40,40);'},
{emoji:'🔊', name:'Say', code:'console.log("Hello!");'},
]
export default function Scratch(){
const [running, setRunning] = useState(false)
const [log, setLog] = useState(['Scratch offline • No internet • Local only','Drag blocks is todo — for now edit JS below'])
const [code, setCode] = useState(`// Offline Scratch-like playground\n// No external URLs, localhost only\nlet x=20, y=20;\nfunction drawBlock(){\n console.log("Drawing at "+x+","+y);\n x+=30;\n}\nfor(let i=0;i<5;i++) drawBlock();\nconsole.log("Done! Try editing.\n");`)
const run = ()=>{
setRunning(true)
const out=[]
const c = {log: (...a)=> out.push(a.join(' '))}
try{
const fn = new Function('console','x','y','ctx','out', code+'\nreturn out;')
// dummy ctx for offline
const dummyCtx = {fillStyle:'', fillRect:()=>{}}
fn(c, 20,20, dummyCtx, out)
setLog([`▶ Ran at ${new Date().toLocaleTimeString()}`, ...out])
}catch(e){
setLog(['Error: '+e.message])
}
setRunning(false)
}
return (
<div style={{display:'flex', height:'100%', background:'#2E3440'}}>
<div style={{width:'32%', background:'#232A36', borderRight:'1px solid #4C566A', padding:'8px', display:'flex', flexDirection:'column', gap:'8px'}}>
<div style={{fontSize:'12px', fontWeight:'700', color:'#88C0D0'}}>🧱 Blocks • Offline only</div>
<div style={{fontSize:'10px', color:'#8E99A8'}}>No internet • localhost only • file:// only • Parent boundary respected</div>
{BLOCKS.map(b=>(
<div key={b.name} style={{background:'#3B4252', border:'1px solid #4C566A', borderRadius:'8px', padding:'8px', display:'flex', gap:'8px', alignItems:'center'}}>
<span style={{fontSize:'20px'}}>{b.emoji}</span>
<div style={{flex:1}}>
<div style={{fontSize:'12px', fontWeight:'600'}}>{b.name}</div>
<div style={{fontSize:'10px', color:'#8E99A8', fontFamily:'monospace'}}>{b.code.slice(0,20)}...</div>
</div>
<button onClick={()=>setCode(c=>c+'\n'+b.code)} style={{padding:'4px 8px', borderRadius:'6px', fontSize:'11px'}}>Add</button>
</div>
))}
<div style={{marginTop:'auto', background:'#3B4252', border:'1px dashed #4C566A', borderRadius:'8px', padding:'8px', fontSize:'11px', color:'#8E99A8'}}>
Future: real block drag-and-drop using Blockly local files (no CDN). Current: JS playground, offline.
</div>
</div>
<div style={{flex:1, display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', gap:'8px', padding:'8px', background:'#3B4252', borderBottom:'1px solid #4C566A'}}>
<button onClick={run} disabled={running} style={{background:'#A3BE8C', color:'#000', border:'none', padding:'8px 16px', borderRadius:'8px', fontWeight:'700'}}>{running?'Running...':'▶ Run'}</button>
<button onClick={()=>setCode('')} style={{background:'#434C5E', border:'1px solid #4C566A', color:'#ECEFF4', padding:'8px 12px', borderRadius:'8px'}}>Clear</button>
<span style={{fontSize:'11px', color:'#8E99A8', alignSelf:'center', marginLeft:'8px'}}>Offline • No external IP • Vite HMR localhost:5173 only</span>
</div>
<textarea value={code} onChange={e=>setCode(e.target.value)} style={{flex:1, background:'#1E222E', color:'#ECEFF4', border:'none', padding:'12px', fontFamily:'monospace', fontSize:'13px', resize:'none', outline:'none'}} spellCheck={false}/>
<div style={{height:'32%', background:'#000', borderTop:'1px solid #4C566A', padding:'8px', overflow:'auto'}}>
<div style={{fontSize:'11px', color:'#8E99A8', marginBottom:'4px'}}>Output:</div>
{log.map((l,i)=><div key={i} style={{fontFamily:'monospace', fontSize:'12px', color:'#A3BE8C', whiteSpace:'pre-wrap'}}>{l}</div>)}
</div>
</div>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
:root {
--bg: #2E3440;
--bg-deep: #232A36;
--header: #1E222E;
--surface: #3B4252;
--surface-light: #434C5E;
--border: #4C566A;
--text: #ECEFF4;
--text-sec: #D8DEE9;
--text-muted: #8E99A8;
--accent-blue: #5E81AC;
--accent-cyan: #88C0D0;
--accent-green: #A3BE8C;
--accent-yellow: #EBCB8B;
--accent-purple: #9D7BD8;
--accent-orange: #D08770;
}
* { box-sizing: border-box; }
html, body { margin:0; padding:0; height:100%; overflow:hidden; }
body { background:var(--bg); color:var(--text); font-family: system-ui, -apple-system, sans-serif; -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent; }
button { font-family: inherit; cursor:pointer; user-select:none; }
button:active { transform: scale(0.97); }
::-webkit-scrollbar { width:6px; height:6px; }
::-webkit-scrollbar-thumb { background:#4C566A; border-radius:3px; }
::-webkit-scrollbar-track { background:transparent; }
/* 800x480 specific */
@media (max-width: 800px), (max-height: 480px) {
header { height:48px !important; }
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
hmr: {
clientPort: 5173,
// overlay shows errors on DSI too
overlay: true
},
cors: true
},
preview: {
host: '0.0.0.0',
port: 4173
},
clearScreen: false,
// For file:// fallback if needed, built files use relative paths
base: './'
})