+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/UI_REDESIGN_PROMPT.md b/UI_REDESIGN_PROMPT.md
new file mode 100644
index 0000000..09cea1d
--- /dev/null
+++ b/UI_REDESIGN_PROMPT.md
@@ -0,0 +1,81 @@
+# Prompt: Redesign the MicroOcppSimulator Web UI
+
+Copy everything below this line into a fresh Claude session opened at the repo root.
+
+---
+
+## Mission
+
+Redesign the web UI of this OCPP charge-point simulator from scratch. The current UI is a dated, form-heavy "debug dashboard" (Kube CSS, fieldsets, manual Fetch buttons, raw protocol booleans). I want a modern, intuitive, human-centred simulator UI that makes an EV-charging session feel tangible — plug in a car, tap a card, watch it charge — while the C++ simulator underneath stays exactly as it is.
+
+**Hard rule: NO CORE LOGIC CHANGES.** Everything in `lib/` (MicroOcpp, MicroOcppMongoose, mongoose, mbedtls, ArduinoJson) and the simulation logic in `src/evse.cpp`, `src/main.cpp`, `src/net_mongoose.cpp` is untouchable. The only C++ file you may lightly touch is `src/api.cpp` / `src/api.h` (the HTTP glue layer) — and only to rename/alias endpoints or JSON field names so the API reads more human, or to add a trivial aggregate GET. Never change simulation behaviour, OCPP behaviour, timing, or state machines.
+
+## Architecture you must preserve
+
+- The C++ binary (mongoose web server) serves **one single gzipped HTML file** — `public/bundle.html.gz` — at `/`, with `Content-Encoding: gzip`. All CSS and JS must be inlined into that one file. No external assets, no CDN, no separate JS/CSS files at runtime.
+- The webapp source lives in the `webapp-src/` git submodule (Preact + webpack). Build pipeline:
+ 1. `npm run build` (webpack prod → `dist/main.js` + `dist/main.css`)
+ 2. `npm run compress` (`deploy/bundle_all.js` inlines CSS+JS into `dist/bundle.html`, gzips it)
+ 3. `build-webapp/build_webapp.sh` moves `bundle.html.gz` → `../public/`
+ 4. Requires a `.env.production` file in `webapp-src/` defining `API_ROOT` (empty string = same origin).
+- Keep the stack lightweight: Preact is fine (3KB), plain CSS or a tiny utility layer is fine. **No React, no Tailwind-via-CDN, no heavy component libraries.** Target: final `bundle.html.gz` stays under ~150KB (it's currently ~36KB).
+- The UI talks to the backend via **HTTP polling only** (no browser websocket, no SSE). Current UI polls at 1s intervals on the Status page; that cadence is fine.
+- CORS is wide open on the server; optional HTTP Basic Auth may be configured but the UI doesn't handle it — ignore auth.
+
+## The API contract (current, verified from `src/api.cpp`)
+
+Legacy JSON API (what the UI uses today):
+
+| Endpoint | Methods | Body / Response |
+|---|---|---|
+| `/connectors` | GET | `["1","2"]` (2 simulated connectors, IDs are 1-indexed strings) |
+| `/connector/{id}/evse` | GET, POST | `{evPlugged, evsePlugged, evReady, evseReady: bool, chargePointStatus: string}` — status is an OCPP value: Available, Preparing, Charging, SuspendedEV, SuspendedEVSE, Finishing, Faulted, … |
+| `/connector/{id}/meter` | GET | `{energy: Wh int, power: W int, current: A float, voltage: V float}` |
+| `/connector/{id}/transaction` | GET, POST | GET → `{idTag: string, transactionId: int (-1 = none), authorizationStatus}`; POST `{idTag}` simulates presenting an RFID card |
+| `/connector/{id}/smartcharging` | GET | `{maxPower: W, maxCurrent: A}` |
+| `/api/websocket` | GET, POST | `{backendUrl, chargeBoxId, authorizationKey, pingInterval, reconnectInterval}` — configures the simulator's OCPP connection to a CSMS |
+
+Newer query-param API (plain-text responses, exists but unused by UI): `POST /api/plugin|plugout|end|state|authorize?evse_id=N&...`. You may use these or ignore them.
+
+Semantics cheat-sheet (J1772):
+- `evPlugged` = cable plugged into the car; `evReady` = car requests charge (State C); `evseReady` = charger contactor closed. Charging happens when all three true + transaction authorized.
+- Typical happy path: plug in → present idTag (POST transaction) → status Preparing → Charging → present same idTag again or unplug to stop.
+
+**Allowed API tweaks** (optional, in `src/api.cpp` only): add friendlier alias routes or field names (e.g. `carPlugged`, `carReady`, `chargerReady`, or a combined `GET /connector/{id}/summary` returning evse+meter+transaction in one response to reduce polling). If you rename anything, keep the old routes/fields working — other tools may depend on them.
+
+## What the current UI is (so you know what you're replacing)
+
+Six sidebar pages (`webapp-src/src/components/`):
+1. **Status** — live cards per connector (1s polling), gradient background by status, three toggle chips (Plugged / EV Ready / EVSE Ready), raw meter numbers.
+2. **OCPP 1.6 Connection** — form for backendUrl / chargeBoxId / authorizationKey with manual Fetch/Send buttons.
+3. **Station** — station-level config forms.
+4. **Connectors** — tabbed per-connector debug panels (EVSE booleans, meter, transaction idTag entry, smart charging), each section with its own Fetch button and success/error alert + timestamp.
+5. **Network** — **hardcoded fake demo content** (fake WiFi list).
+6. **Security** — CA-cert textarea (real, posts to backend) + **hardcoded fake cipher-suite table**.
+
+Known problems to NOT carry over:
+- Everything is manual-fetch forms; only Status is live. Feels like Postman with CSS.
+- Protocol jargon leaks everywhere ("evPlugged", "idTag", "OCPP 1.6 Websocket").
+- Fake demo pages (Network, most of Security) mislead users — drop them or clearly mark real vs. mock, keep only the real CA-cert feature somewhere in a settings area.
+- Existing bugs (don't replicate): `Gui.EvseLiveDisplay.js` uses `DateFormatter` without importing it (crashes on POST success path), and the POST omits `evsePlugged`, and compares the response against stale pre-update state.
+- 5100-line kube.css for a handful of controls.
+
+## Design brief
+
+Build a **single-page, real-time simulator cockpit**. Suggested shape (you have creative freedom, but hit these notes):
+
+1. **Connector-first layout.** One card/panel per connector showing a visual EV↔charger scene: a car, a cable, a charger. State is communicated visually (cable connected/disconnected, energy-flow animation while Charging, colour-coded status pill using human words: "Available", "Car connected", "Charging", "Paused by car", "Paused by charger", "Fault").
+2. **Actions as scenario verbs, not booleans.** Primary buttons like "Plug in car", "Tap RFID card", "Stop charging", "Unplug" that map onto the boolean POSTs / transaction POST under the hood. Keep an "Advanced" disclosure per connector exposing the three raw toggles and idTag field for power users.
+3. **Live telemetry.** Poll the meter and render power as a small live sparkline/chart (rolling ~60s window, canvas or inline SVG — no chart library), energy counter, current, voltage. Numbers formatted for humans (kW / kWh with sensible precision).
+4. **Connection status always visible.** A persistent header strip showing the OCPP backend connection (backend URL, charge box ID) with a settings drawer/modal to edit the `/api/websocket` fields and the CA cert. Use plain labels: "Backend server", "Station ID", "Password/Auth key".
+5. **Everything auto-refreshes.** No Fetch buttons anywhere. Poll continuously (1s for active connector state, slower for config), show a subtle "live" indicator, and surface network failures as one unobtrusive banner, not per-form alerts.
+6. **Look and feel:** modern, clean, dark-mode-friendly (dark default is fine for a tool like this), CSS custom properties for theming, good typography, subtle motion for state transitions. Responsive down to a phone. All icons as inline SVG.
+
+## Process requirements
+
+- Work only in `webapp-src/` (and optionally `src/api.cpp`/`api.h` per the rules above). Keep the webpack + `bundle_all.js` pipeline working — you may simplify it, but the output must remain a single `bundle.html.gz` that `build-webapp/build_webapp.sh` places into `public/`.
+- You may delete/replace all existing components, CSS, and the kube.css framework entirely.
+- After building, verify end-to-end: build the C++ sim (`cmake -S . -B build -DCMAKE_POLICY_VERSION_MINIMUM=3.5 && cmake --build build --target mo_simulator`), run it, open `http://localhost:8000`, and exercise the golden path in a browser: plug in → tap card → observe Charging + live meter → stop → unplug. Also verify both connectors, the settings drawer round-trips `/api/websocket` values, and the UI degrades gracefully when the backend is unreachable.
+- If you change `src/api.cpp`, prove old routes still respond identically (curl before/after).
+
+Start by reading `src/api.cpp`, `webapp-src/src/index.js`, and `webapp-src/deploy/bundle_all.js`, then propose the screen layout before writing code.
diff --git a/build-webapp/build_webapp.sh b/build-webapp/build_webapp.sh
old mode 100644
new mode 100755
diff --git a/deploy/staging/Dockerfile b/deploy/staging/Dockerfile
new file mode 100644
index 0000000..635fe83
--- /dev/null
+++ b/deploy/staging/Dockerfile
@@ -0,0 +1,42 @@
+# Staging-bundle image (deploy/staging/): rebuilds the simulator with the
+# runtime entrypoint layered on top, so the browser UI at :8000 always
+# targets whichever station+endpoint STATION_ID and WSS_URL name. Same
+# build recipe as the root Dockerfile -- kept separate so the upstream
+# image stays clean.
+
+FROM alpine:latest
+
+RUN apk update && \
+ apk add --no-cache \
+ git \
+ cmake \
+ openssl-dev \
+ build-base \
+ ca-certificates && \
+ update-ca-certificates
+
+# Clone fresh from GitHub with submodules recursively -- avoids relying on
+# either .git being COPYed (Render strips it) or the host performing a
+# recursive checkout. Rebuilds pick up the LATEST main; pin to a tag via
+# `--branch ` when a specific release matters.
+# NB: the parent's .gitmodules points at Pitvolt/* forks, so `--recursive`
+# fetches the exact submodules the staging bundle expects.
+ARG SIM_REF=main
+RUN git clone --depth 1 --branch "${SIM_REF}" --recurse-submodules \
+ --shallow-submodules \
+ https://github.com/Pitvolt/MicroOcppSimulator.git /MicroOcppSimulator
+
+WORKDIR /MicroOcppSimulator
+
+RUN cmake -S . -B ./build -DCMAKE_POLICY_VERSION_MINIMUM=3.5
+RUN cmake --build ./build -j 16 --target mo_simulator -j 16
+RUN chmod +x /MicroOcppSimulator/build/mo_simulator
+
+# The entrypoint is bundled with the freshly-cloned tree, but copy the
+# staging-bundle canonical version onto /usr/local/bin so any future edit
+# to the file on main takes effect on the next rebuild.
+RUN cp /MicroOcppSimulator/deploy/staging/entrypoint.sh /usr/local/bin/entrypoint.sh
+RUN chmod +x /usr/local/bin/entrypoint.sh
+
+EXPOSE 8000
+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
diff --git a/deploy/staging/README.md b/deploy/staging/README.md
new file mode 100644
index 0000000..2406244
--- /dev/null
+++ b/deploy/staging/README.md
@@ -0,0 +1,60 @@
+# Staging OCPP simulator bundle
+
+A small always-off Fly.io machine running MicroOcppSimulator, pre-pointed at
+Pitvolt staging (`wss://staging.ocpp.pitvolt.com`) and the e2e charger
+(`PVE2-VAJN`). Auto-starts on first request; idles for free.
+
+Runs the same binary as the upstream repo (built by `deploy/staging/Dockerfile`),
+just with a runtime entrypoint that writes `mo_store/ws-conn.jsn` from
+`STATION_ID` + `WSS_URL` env vars so one image works for any charger.
+
+## First-time bring-up (Render.com — no card required)
+
+Free web-service tier, 750 h/month, sleeps after 15 min idle, wakes in ~30s.
+
+1. Log in at (GitHub SSO — no card asked).
+2. **New → Blueprint → Connect** the `Pitvolt/MicroOcppSimulator` repo.
+3. Render reads `deploy/staging/render.yaml`, creates the service, and
+ deploys from `deploy/staging/Dockerfile`. First build takes ~5 min.
+4. Public URL: `https://pitvolt-ocpp-sim.onrender.com` — the simulator's
+ web UI. Drive charging sessions from the browser; staging
+ (`stg.api.pitvolt.com`) treats it as a real charger.
+
+Change the targeted charger from the Render dashboard → Environment →
+edit `STATION_ID` → save (Render redeploys automatically).
+
+## Alternative: Fly.io bring-up (requires a card)
+
+```bash
+brew install flyctl && flyctl auth login
+flyctl launch --no-deploy --copy-config --name pitvolt-ocpp-sim \
+ --org personal --region fra --config deploy/staging/fly.toml
+flyctl deploy --config deploy/staging/fly.toml \
+ --dockerfile deploy/staging/Dockerfile
+# URL: https://pitvolt-ocpp-sim.fly.dev
+```
+
+## Retarget a different charger
+
+- **Render**: Dashboard → Environment → edit `STATION_ID` (or `WSS_URL`) →
+ save. Redeploys automatically.
+- **Fly**: `flyctl secrets set STATION_ID=`.
+
+## Local dry-run without Fly
+
+```bash
+docker build -f deploy/staging/Dockerfile -t pitvolt/micro-ocpp-sim:staging .
+docker run -p 8000:8000 \
+ -e STATION_ID=PVE2-VAJN \
+ -e WSS_URL=wss://staging.ocpp.pitvolt.com \
+ pitvolt/micro-ocpp-sim:staging
+# UI at http://localhost:8000
+```
+
+## Cost + safety
+
+- Auto-stops when idle; a warm start is ~2s. Effectively free on Fly's
+ hobby tier.
+- Publicly reachable (no auth). Fine because it's a staging tool that can
+ only drive whatever charger `STATION_ID` names. **Never** point it at
+ production without adding a Fly access policy or basic auth in front.
diff --git a/deploy/staging/entrypoint.sh b/deploy/staging/entrypoint.sh
new file mode 100755
index 0000000..b9726ed
--- /dev/null
+++ b/deploy/staging/entrypoint.sh
@@ -0,0 +1,31 @@
+#!/bin/sh
+# Runtime OCPP config for the staging bundle. Overwrites mo_store/ws-conn.jsn
+# from env vars so ONE image can target any charger / any environment:
+#
+# docker run -p 8000:8000 \
+# -e STATION_ID=PVE2-VAJN \
+# -e WSS_URL=wss://staging.ocpp.pitvolt.com \
+# -e AUTH_KEY= \
+# pitvolt/micro-ocpp-sim:staging
+#
+# The simulator reads this file exactly once at startup; changing env at
+# runtime = restart container. The UI at :8000 lets you drive sessions.
+
+set -eu
+
+: "${STATION_ID:?STATION_ID env required (OCPP charge box id, e.g. PVE2-VAJN)}"
+: "${WSS_URL:?WSS_URL env required (e.g. wss://staging.ocpp.pitvolt.com)}"
+AUTH_KEY="${AUTH_KEY:-}"
+
+cat > /MicroOcppSimulator/mo_store/ws-conn.jsn <})"
+exec /MicroOcppSimulator/build/mo_simulator
diff --git a/deploy/staging/fly.toml b/deploy/staging/fly.toml
new file mode 100644
index 0000000..e772276
--- /dev/null
+++ b/deploy/staging/fly.toml
@@ -0,0 +1,40 @@
+# Fly.io config for the staging OCPP simulator (deploy/staging/README.md).
+# Small always-off machine that auto-starts on request; ~$0/month idle.
+#
+# Bring-up:
+# cd deploy/staging
+# flyctl launch --no-deploy --copy-config --name pitvolt-ocpp-sim --org personal --region fra
+# flyctl deploy --dockerfile Dockerfile --config fly.toml
+#
+# Override the target charger without redeploying:
+# flyctl secrets set STATION_ID=
+#
+# Public URL after deploy: https://pitvolt-ocpp-sim.fly.dev
+# WARNING: publicly reachable. Only use against staging chargers.
+
+app = "pitvolt-ocpp-sim"
+primary_region = "fra"
+
+[build]
+ # Build context is the REPO ROOT (fly deploy runs from deploy/staging/
+ # so we escape one dir up); the Dockerfile COPYs the whole repo in.
+ dockerfile = "Dockerfile"
+
+[env]
+ STATION_ID = "PVE2-VAJN"
+ WSS_URL = "wss://staging.ocpp.pitvolt.com"
+ # AUTH_KEY is deliberately empty: staging OCPP endpoint uses station-id
+ # auth only (see docs/architecture note, 2026-08-01). Set as a fly secret
+ # if the endpoint ever adds a password check.
+
+[http_service]
+ internal_port = 8000
+ force_https = true
+ auto_stop_machines = "stop"
+ auto_start_machines = true
+ min_machines_running = 0
+
+[[vm]]
+ cpu_kind = "shared"
+ cpus = 1
+ memory_mb = 512
diff --git a/deploy/staging/render.yaml b/deploy/staging/render.yaml
new file mode 100644
index 0000000..1c20326
--- /dev/null
+++ b/deploy/staging/render.yaml
@@ -0,0 +1,37 @@
+# Render.com config for the staging OCPP simulator (deploy/staging/README.md).
+# Zero-cost web service on the free tier: no card required, auto-sleeps at
+# 15 min idle, wakes on request in ~30s. Same STATION_ID / WSS_URL contract
+# as the Fly bundle; the Dockerfile is reused as-is.
+#
+# Bring-up (one-time in the Render dashboard):
+# 1. Log in at https://dashboard.render.com (GitHub SSO fine, no card)
+# 2. New -> Blueprint -> connect Pitvolt/MicroOcppSimulator repo
+# 3. Render reads this file and creates the service; deploy runs
+# automatically from the deploy/staging/Dockerfile
+# 4. Public URL after first deploy: https://pitvolt-ocpp-sim.onrender.com
+
+services:
+ - type: web
+ name: pitvolt-ocpp-sim
+ runtime: docker
+ plan: free
+ # Build context is the repo root (Dockerfile COPYs the whole tree in);
+ # dockerfilePath is relative to that root.
+ dockerfilePath: ./deploy/staging/Dockerfile
+ dockerContext: .
+ envVars:
+ # PVE2-3BYA is the "Simulator Playground" charger on the e2e tenant
+ # (spec 2026-08-02): a dedicated station id for user-driven simulator
+ # runs, kept SEPARATE from PVE2-VAJN which is reserved for the CI
+ # integration-test suite. OCPP allows only one live connection per
+ # station id, so sharing would race with every CI deploy.
+ - key: STATION_ID
+ value: PVE2-3BYA
+ - key: WSS_URL
+ value: wss://staging.ocpp.pitvolt.com
+ # AUTH_KEY deliberately empty: staging OCPP endpoint uses station-id
+ # auth only. Set via the Render dashboard if the endpoint ever adds a
+ # password check (keep it out of source control).
+ # Free tier sleeps after 15 min idle; wakes on the first request.
+ healthCheckPath: /
+ autoDeploy: true
diff --git a/docs/superpowers/plans/2026-07-30-cockpit-ui-port.md b/docs/superpowers/plans/2026-07-30-cockpit-ui-port.md
new file mode 100644
index 0000000..e8070ac
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-30-cockpit-ui-port.md
@@ -0,0 +1,1004 @@
+# Cockpit UI Port Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the MicroOcppSimulator web UI with the "OCPP Simulator Cockpit" design (dark HUD, connector bays, scenario verbs, wire trace), wired to the real simulator REST API, shipped through the existing single-file bundle pipeline.
+
+**Architecture:** Preact app in the `webapp-src/` submodule. A `CockpitStore` (dependency-injected API client, framework-free, unit-tested with `node --test`) polls the simulator every 1s and exposes actions; Preact components render from store state. Webpack inlines fonts as data URLs; `deploy/bundle_all.js` inlines JS+CSS into one `bundle.html`, gzipped to `public/bundle.html.gz` and served by the C++ mongoose server at `/`.
+
+**Tech Stack:** Preact 10 (already a dependency), webpack 5 (existing config), plain CSS with custom properties, `node:test` for unit tests. No new runtime dependencies.
+
+## Global Constraints
+
+- **NO C++ changes.** Nothing under `lib/`, and no edits to `src/*.cpp`, `src/*.h` in the main repo. The API already supports everything needed (partial `POST /connector/{id}/evse` bodies confirmed at `src/api.cpp:87-98`).
+- All UI work happens in `/Users/joojodontoh/Documents/PersonalProjects/MicroOcppSimulator/webapp-src/` (its own git repo; `origin` = `Pitvolt/micro-ocpp-dashboard`). Commit there. Main-repo commits only in Task 9.
+- Browser-facing API paths are prefixed `/api` (server strips it, `src/net_mongoose.cpp:122-148`). `API_ROOT=/api` in `.env.production`, `http://localhost:8000/api` in `.env.development`. Endpoints used: `GET /connectors`, `GET|POST /connector/{id}/evse`, `GET /connector/{id}/meter`, `GET|POST /connector/{id}/transaction`, `GET /connector/{id}/smartcharging`, `GET|POST /websocket` (full browser path `/api/websocket` — handled before the prefix-strip, same effective URL), `GET|POST /ca_cert` with body `{caCert}`.
+- `pingInterval` / `reconnectInterval` are integers in **seconds**.
+- `transactionId === -1` means no transaction. Presenting the same `idTag` again via `POST /connector/{id}/transaction` ends the session (server-side toggle).
+- There is **no fault-injection endpoint** — the prototype's "INJECT FAULT" button is dropped. `Faulted` status is still rendered if the server reports it.
+- Final `public/bundle.html.gz` must be ≤ 150 KB.
+- Design reference files (extracted prototype, read-only, delete in Task 9): `webapp-src/design-reference/{ds-components.js, tokens.css, app-shell.jsx, bay.jsx, settings-drawer.jsx, sim-engine.js, michroma-latin.woff2, jetbrains-mono-latin.woff2, ibm-plex-sans-latin.woff2}`.
+- **Canonical style source:** the prototype's tokens derive from the Alfred design system at `/Users/joojodontoh/Documents/PersonalProjects/project-alfred/apps/dashboard-web/styles/tokens/{colors,typography,spacing,effects}.css`. Colors and fonts must match it exactly (verified: `design-reference/tokens.css` values are identical). Font stack is non-negotiable: display `Michroma`, body `IBM Plex Sans`, data/readouts `JetBrains Mono` — all three ship inline.
+- JSX pragma is `h` / `Fragment` (configured in `webpack_config/webpack.common.js` babel-loader options). Every component file needs `import { h } from "preact"` (plus `Fragment` if used).
+- Unit tests run with `node --test tests/` (Node 22 available). Pure logic lives in `.mjs` files with **no JSX and no imports from Preact or DataService** so Node can run them natively.
+
+## File Structure (end state of `webapp-src/src/`)
+
+```
+index.js entry — renders into #app
+DataService.js KEEP UNCHANGED (fetch wrapper, API_ROOT prefix)
+constants.js trimmed to API_ROOT / NODE_ENV exports only
+css/tokens.css design tokens + hud utility classes + @font-face (replaces kube/custom/layout/vars.css)
+fonts/michroma-latin.woff2 display font (11.6 KB)
+fonts/jetbrains-mono-latin.woff2 mono font (31 KB)
+fonts/ibm-plex-sans-latin.woff2 body font (40 KB)
+lib/status.mjs OCPP status → label/tone mapping (pure)
+lib/format.mjs number/time formatting (pure)
+store.mjs CockpitStore: state, polling, actions, wire trace (pure, DI)
+components/ds.js Button, CornerBracketCard, SectionLabel, TwoPartPrompt, Chip, ConnectionDot, MonoStat, StatusPill, Spark
+components/TopStrip.js sticky header strip
+components/Trace.js wire-trace event log
+components/Bay.js connector bay card (FlowScene, stats, verbs, advanced)
+components/SettingsDrawer.js backend link + CA cert drawer
+components/App.js cockpit assembly + poll timers
+components/icons/ KEEP directory (SVGR imports; unused files add no bundle weight)
+```
+
+Deleted: `css/kube.css`, `css/custom.css`, `css/layout.css`, `css/vars.css`, `HtmlBuilder.js`, `StyleBuilder.js`, `DateFormatter.js`, `DataService_wasm.js.template` stays (used by wasm build script), all old `components/*.js` except the `icons/` directory, `components/component_styles/`.
+
+---
+
+### Task 1: Workspace reset + design tokens + build plumbing
+
+**Files:**
+- Create: `webapp-src/src/css/tokens.css`, `webapp-src/src/fonts/michroma-latin.woff2`, `webapp-src/src/fonts/jetbrains-mono-latin.woff2`, `webapp-src/src/fonts/ibm-plex-sans-latin.woff2`
+- Modify: `webapp-src/webpack_config/webpack.common.js`, `webapp-src/public/index.html`, `webapp-src/src/index.js`, `webapp-src/src/constants.js`, `webapp-src/package.json`
+- Delete: old css files, old components (see File Structure above)
+
+**Interfaces:**
+- Produces: `css/tokens.css` custom properties (`--bg-0`, `--gold`, `--font-mono`, `--sp-1..8`, `--fs-micro/caption/small/body/lead/hero/metric`, `--dur-fast`, `--ease-hud`, `--glow-gold/red/ice`, `--strip-h`, `--bracket`, `--ls-label`, `--fw-medium/semibold`) and classes `.hud-clip`, `.hud-display`, `.hud-decode`, `.hud-grid-bg`, `.flow-bar`, `.live-dot`, `.trace` — all later components rely on these names exactly.
+
+- [ ] **Step 1: Create a work branch in webapp-src**
+
+```bash
+cd webapp-src && git checkout -b cockpit-redesign
+```
+
+- [ ] **Step 2: Delete old UI files**
+
+```bash
+cd webapp-src
+git rm src/css/kube.css src/css/custom.css src/css/layout.css src/css/vars.css \
+ src/HtmlBuilder.js src/StyleBuilder.js src/DateFormatter.js
+git rm -r src/components/component_styles
+cd src/components && git rm $(ls *.js) && cd ../..
+```
+(Keep `src/components/icons/`. Keep `DataService.js`, `DataService_wasm.js.template`.)
+
+- [ ] **Step 3: Create tokens.css**
+
+Copy the entire contents of `webapp-src/design-reference/tokens.css` (the four `:root` blocks and `.hud-*` classes extracted from the prototype) into `webapp-src/src/css/tokens.css`, then **prepend** these `@font-face` rules and **append** the app-level styles:
+
+Prepend:
+```css
+@font-face {
+ font-family: "Michroma";
+ font-style: normal; font-weight: 400; font-display: swap;
+ src: url("../fonts/michroma-latin.woff2") format("woff2");
+}
+@font-face {
+ font-family: "JetBrains Mono";
+ font-style: normal; font-weight: 400 700; font-display: swap;
+ src: url("../fonts/jetbrains-mono-latin.woff2") format("woff2");
+}
+@font-face {
+ font-family: "IBM Plex Sans";
+ font-style: normal; font-weight: 400 600; font-display: swap;
+ src: url("../fonts/ibm-plex-sans-latin.woff2") format("woff2");
+}
+```
+
+Append:
+```css
+html, body { height: 100%; }
+body { margin: 0; background: var(--bg-0); overflow-x: hidden; }
+#app { min-height: 100vh; }
+a { color: var(--ice); text-decoration: none; }
+a:hover { color: var(--gold); }
+@keyframes flow { 0% { opacity: .18 } 45% { opacity: 1 } 100% { opacity: .18 } }
+.flow-bar { animation: flow 1.15s var(--ease-hud) infinite; }
+@keyframes livepulse { 0%, 100% { opacity: .35 } 50% { opacity: 1 } }
+.live-dot { animation: livepulse 1.6s ease-in-out infinite; }
+@media (prefers-reduced-motion: reduce) { .flow-bar, .live-dot { animation: none } }
+@media (max-width: 1000px) { .strip-min { display: none !important } }
+.trace::-webkit-scrollbar { width: 8px; }
+.trace::-webkit-scrollbar-thumb { background: var(--hairline-strong); }
+button { font: inherit; }
+```
+
+Do **not** alter any token values — fonts and colors must stay exactly as in the reference: `--font-display: "Michroma", "Arial Black", sans-serif`, `--font-body: "IBM Plex Sans", system-ui, sans-serif`, `--font-mono: "JetBrains Mono", "SFMono-Regular", monospace`. Remove any `@font-face` blocks that came along in the copy (the reference file should include none — verify).
+
+Fidelity check against the canonical Alfred tokens:
+```bash
+diff <(grep -o '\-\-[a-z0-9-]*:[^;]*;' src/css/tokens.css | sort -u) \
+ <(cat "/Users/joojodontoh/Documents/PersonalProjects/project-alfred/apps/dashboard-web/styles/tokens/"{colors,typography,spacing,effects}.css | grep -o '\-\-[a-z0-9-]*:[^;]*;' | sort -u)
+```
+Expected: only additions on the Alfred side (extended categorical palette `--coral/--teal/--violet/--magenta/--lime/--steel/--rose`, `--fs-data`, `--hud-panel-h`) and the prototype-local `--bk` helper plus the three `@font-face` families on ours. **No value differences on any shared variable.**
+
+- [ ] **Step 4: Add fonts**
+
+```bash
+cd webapp-src && mkdir -p src/fonts
+cp design-reference/michroma-latin.woff2 src/fonts/
+cp design-reference/jetbrains-mono-latin.woff2 src/fonts/
+cp design-reference/ibm-plex-sans-latin.woff2 src/fonts/
+```
+
+- [ ] **Step 5: Inline fonts in webpack**
+
+In `webapp-src/webpack_config/webpack.common.js`, add this rule **before** the existing `file-loader` rule (which matches woff but not woff2):
+
+```js
+{
+ test: /\.woff2$/,
+ type: "asset/inline",
+},
+```
+
+- [ ] **Step 6: Update the HTML shell**
+
+Replace the `` and theme-color lines in `webapp-src/public/index.html`:
+
+```html
+MicroOCPP Simulator — Cockpit
+
+```
+(Remove both old `theme-color` lines; keep ``, the `main.js` script tag, and both `` comments exactly as they are — `bundle_all.js` depends on them.)
+
+- [ ] **Step 7: Stub the entry point**
+
+Replace `webapp-src/src/index.js` with:
+
+```js
+import { h, render } from "preact";
+import "./css/tokens.css";
+
+render(
COCKPIT BOOT
, document.getElementById("app"));
+```
+
+Replace `webapp-src/src/constants.js` with:
+
+```js
+const API_ROOT = process.env.API_ROOT;
+const NODE_ENV = process.env.NODE_ENV;
+
+export { API_ROOT, NODE_ENV };
+```
+
+- [ ] **Step 8: Add unit-test script**
+
+In `webapp-src/package.json` scripts, replace `"test": "jest --watch"` with:
+
+```json
+"test": "node --test tests/"
+```
+(jest was never installed; no tests exist yet — `tests/` arrives in Task 2.)
+
+- [ ] **Step 9: Verify production build compiles**
+
+Run: `cd webapp-src && npm install && npm run build && npm run compress`
+Expected: webpack exits 0, `dist/bundle.html.gz` produced. Open `dist/bundle.html` and confirm it contains `COCKPIT BOOT` and an inlined `data:font/woff2` URL.
+
+- [ ] **Step 10: Commit**
+
+```bash
+cd webapp-src && git add -A && git commit -m "feat: reset UI shell — cockpit tokens, inline fonts, new entry"
+```
+
+---
+
+### Task 2: Pure logic — status mapping and formatting
+
+**Files:**
+- Create: `webapp-src/src/lib/status.mjs`, `webapp-src/src/lib/format.mjs`
+- Test: `webapp-src/tests/status.test.mjs`, `webapp-src/tests/format.test.mjs`
+
+**Interfaces:**
+- Produces: `statusLabel(status) -> [text, tone]`, `PILL_TONE` map, `isActiveStatus(status) -> bool`; `kw(watts) -> string`, `kwh(wattHours) -> string`, `amps(a) -> string`, `volts(v) -> string`, `pad2(n) -> string`, `stamp() -> "HH:MM:SS"`. Tones are one of `"text" | "green" | "amber" | "red" | "ice"`.
+
+- [ ] **Step 1: Write failing tests**
+
+`webapp-src/tests/status.test.mjs`:
+```js
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { statusLabel, PILL_TONE, isActiveStatus } from "../src/lib/status.mjs";
+
+test("known OCPP statuses map to human labels and tones", () => {
+ assert.deepEqual(statusLabel("Available"), ["AVAILABLE", "text"]);
+ assert.deepEqual(statusLabel("Preparing"), ["CAR CONNECTED", "ice"]);
+ assert.deepEqual(statusLabel("Charging"), ["CHARGING", "green"]);
+ assert.deepEqual(statusLabel("SuspendedEV"), ["PAUSED BY CAR", "amber"]);
+ assert.deepEqual(statusLabel("SuspendedEVSE"), ["PAUSED BY CHARGER", "amber"]);
+ assert.deepEqual(statusLabel("Finishing"), ["SESSION ENDING", "ice"]);
+ assert.deepEqual(statusLabel("Faulted"), ["FAULT", "red"]);
+});
+
+test("unknown status falls back to uppercase text tone", () => {
+ assert.deepEqual(statusLabel("Reserved"), ["RESERVED", "text"]);
+ assert.deepEqual(statusLabel(""), ["UNKNOWN", "text"]);
+ assert.deepEqual(statusLabel(undefined), ["UNKNOWN", "text"]);
+});
+
+test("tone → StatusPill palette mapping is total", () => {
+ for (const tone of ["text", "green", "amber", "red", "ice"]) {
+ assert.ok(PILL_TONE[tone], `missing ${tone}`);
+ }
+});
+
+test("isActiveStatus true only while energy can flow", () => {
+ assert.equal(isActiveStatus("Charging"), true);
+ assert.equal(isActiveStatus("Available"), false);
+ assert.equal(isActiveStatus("Faulted"), false);
+});
+```
+
+`webapp-src/tests/format.test.mjs`:
+```js
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { kw, kwh, amps, volts, pad2, stamp } from "../src/lib/format.mjs";
+
+test("power formats W → kW with 2 decimals", () => {
+ assert.equal(kw(7400), "7.40");
+ assert.equal(kw(0), "0.00");
+});
+
+test("energy formats Wh → kWh with 3 decimals", () => {
+ assert.equal(kwh(1234), "1.234");
+});
+
+test("current and voltage format with 1 decimal", () => {
+ assert.equal(amps(16.04), "16.0");
+ assert.equal(volts(229.96), "230.0");
+});
+
+test("pad2 zero-pads", () => {
+ assert.equal(pad2(3), "03");
+ assert.equal(pad2(12), "12");
+});
+
+test("stamp is HH:MM:SS", () => {
+ assert.match(stamp(), /^\d{2}:\d{2}:\d{2}$/);
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd webapp-src && npm test`
+Expected: FAIL — cannot find `../src/lib/status.mjs`.
+
+- [ ] **Step 3: Implement**
+
+`webapp-src/src/lib/status.mjs`:
+```js
+export const STATUS_LABEL = {
+ Available: ["AVAILABLE", "text"],
+ Preparing: ["CAR CONNECTED", "ice"],
+ Charging: ["CHARGING", "green"],
+ SuspendedEV: ["PAUSED BY CAR", "amber"],
+ SuspendedEVSE: ["PAUSED BY CHARGER", "amber"],
+ Finishing: ["SESSION ENDING", "ice"],
+ Faulted: ["FAULT", "red"],
+};
+
+// tone key → StatusPill palette name (ds.js StatusPill TONES)
+export const PILL_TONE = { text: "OFFLINE", green: "ONLINE", amber: "PENDING", red: "ERROR", ice: "APPROVED" };
+
+export function statusLabel(status) {
+ if (!status) return ["UNKNOWN", "text"];
+ return STATUS_LABEL[status] || [String(status).toUpperCase(), "text"];
+}
+
+export function isActiveStatus(status) {
+ return status === "Charging";
+}
+```
+
+`webapp-src/src/lib/format.mjs`:
+```js
+export const kw = (w) => (w / 1000).toFixed(2);
+export const kwh = (wh) => (wh / 1000).toFixed(3);
+export const amps = (a) => Number(a).toFixed(1);
+export const volts = (v) => Number(v).toFixed(1);
+export const pad2 = (n) => String(n).padStart(2, "0");
+
+export function stamp() {
+ const d = new Date();
+ return [d.getHours(), d.getMinutes(), d.getSeconds()].map(pad2).join(":");
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd webapp-src && npm test`
+Expected: all tests PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd webapp-src && git add src/lib tests && git commit -m "feat: status/format pure modules with node:test coverage"
+```
+
+---
+
+### Task 3: CockpitStore — polling, actions, wire trace
+
+**Files:**
+- Create: `webapp-src/src/store.mjs`
+- Test: `webapp-src/tests/store.test.mjs`
+
+**Interfaces:**
+- Consumes: `stamp` from `lib/format.mjs`. API client shape `{ get(path) -> Promise, post(path, body) -> Promise }` (matches `DataService.js`).
+- Produces: `class CockpitStore { constructor(api); state; subscribe(fn); init(); pollFast(); pollSlow(); plugIn(id); unplug(id); tapCard(id, idTag); endSession(id); toggle(id, key); saveBackend(form); loadCert(); saveCert(pem); }`
+ - `state = { connectors: [...], backend: {backendUrl, chargeBoxId, authorizationKey, pingInterval, reconnectInterval}, link: "init"|"ok"|"down", log: [{t, cid, line, tone}] }`
+ - Connector shape: `{ id: string, evPlugged, evsePlugged, evReady, evseReady: bool, chargePointStatus: string, transactionId: number, idTag: string, energy, power, current, voltage: number, maxPower, maxCurrent: number, history: number[60] }`
+
+- [ ] **Step 1: Write failing tests**
+
+`webapp-src/tests/store.test.mjs`:
+```js
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { CockpitStore } from "../src/store.mjs";
+
+function fakeApi(routes = {}) {
+ const calls = [];
+ return {
+ calls,
+ get(path) {
+ calls.push(["GET", path]);
+ if (path in routes) return Promise.resolve(routes[path]);
+ return Promise.reject(new Error("no route " + path));
+ },
+ post(path, body) {
+ calls.push(["POST", path, body]);
+ if (path in routes) return Promise.resolve({ ...routes[path], ...body });
+ return Promise.reject(new Error("no route " + path));
+ },
+ };
+}
+
+const baseRoutes = () => ({
+ "/connectors": ["1", "2"],
+ "/websocket": { backendUrl: "ws://x", chargeBoxId: "CB01", authorizationKey: "", pingInterval: 5, reconnectInterval: 30 },
+ "/connector/1/evse": { evPlugged: false, evsePlugged: true, evReady: false, evseReady: false, chargePointStatus: "Available" },
+ "/connector/2/evse": { evPlugged: false, evsePlugged: true, evReady: false, evseReady: false, chargePointStatus: "Available" },
+ "/connector/1/meter": { energy: 100, power: 7400, current: 10.7, voltage: 230.1 },
+ "/connector/2/meter": { energy: 0, power: 0, current: 0, voltage: 229.8 },
+ "/connector/1/transaction": { idTag: "", transactionId: -1 },
+ "/connector/2/transaction": { idTag: "", transactionId: -1 },
+ "/connector/1/smartcharging": { maxPower: 11000, maxCurrent: 16 },
+ "/connector/2/smartcharging": { maxPower: 11000, maxCurrent: 16 },
+});
+
+test("init seeds connectors from /connectors and loads backend config", async () => {
+ const store = new CockpitStore(fakeApi(baseRoutes()));
+ await store.init();
+ assert.equal(store.state.connectors.length, 2);
+ assert.equal(store.state.connectors[0].id, "1");
+ assert.equal(store.state.backend.chargeBoxId, "CB01");
+});
+
+test("pollFast merges evse/meter/transaction, pushes power history, sets link ok", async () => {
+ const store = new CockpitStore(fakeApi(baseRoutes()));
+ await store.init();
+ await store.pollFast();
+ const c = store.state.connectors[0];
+ assert.equal(c.power, 7400);
+ assert.equal(c.chargePointStatus, "Available");
+ assert.equal(c.history.length, 60);
+ assert.equal(c.history[59], 7400);
+ assert.equal(store.state.link, "ok");
+});
+
+test("pollFast failure flips link to down and logs once; recovery logs restore", async () => {
+ const routes = baseRoutes();
+ const api = fakeApi(routes);
+ const store = new CockpitStore(api);
+ await store.init();
+ await store.pollFast();
+ const good = routes["/connector/1/evse"];
+ delete routes["/connector/1/evse"];
+ await store.pollFast();
+ assert.equal(store.state.link, "down");
+ const downLogs = store.state.log.filter((l) => l.line.includes("POLL FAILED"));
+ assert.equal(downLogs.length, 1);
+ routes["/connector/1/evse"] = good;
+ await store.pollFast();
+ assert.equal(store.state.link, "ok");
+ assert.ok(store.state.log.some((l) => l.line.includes("LINK RESTORED")));
+});
+
+test("plugIn posts partial evse body and merges the response", async () => {
+ const api = fakeApi(baseRoutes());
+ const store = new CockpitStore(api);
+ await store.init();
+ await store.plugIn("1");
+ const post = api.calls.find(([m, p]) => m === "POST" && p === "/connector/1/evse");
+ assert.deepEqual(post[2], { evPlugged: true, evReady: true });
+ assert.equal(store.state.connectors[0].evPlugged, true);
+});
+
+test("unplug posts all-off body", async () => {
+ const api = fakeApi(baseRoutes());
+ const store = new CockpitStore(api);
+ await store.init();
+ await store.unplug("1");
+ const post = api.calls.find(([m, p]) => m === "POST" && p === "/connector/1/evse");
+ assert.deepEqual(post[2], { evPlugged: false, evReady: false, evseReady: false });
+});
+
+test("tapCard posts idTag; endSession re-presents the current session tag", async () => {
+ const routes = baseRoutes();
+ const api = fakeApi(routes);
+ const store = new CockpitStore(api);
+ await store.init();
+ await store.tapCard("1", "D7A4-0001");
+ let post = api.calls.filter(([m, p]) => m === "POST" && p === "/connector/1/transaction");
+ assert.deepEqual(post[0][2], { idTag: "D7A4-0001" });
+ store.state.connectors[0].idTag = "D7A4-0001";
+ store.state.connectors[0].transactionId = 1181;
+ await store.endSession("1");
+ post = api.calls.filter(([m, p]) => m === "POST" && p === "/connector/1/transaction");
+ assert.deepEqual(post[1][2], { idTag: "D7A4-0001" });
+});
+
+test("toggle flips a single evse boolean via partial POST", async () => {
+ const api = fakeApi(baseRoutes());
+ const store = new CockpitStore(api);
+ await store.init();
+ await store.toggle("1", "evseReady");
+ const post = api.calls.find(([m, p]) => m === "POST" && p === "/connector/1/evse");
+ assert.deepEqual(post[2], { evseReady: true });
+});
+
+test("saveBackend coerces intervals to ints and posts /websocket", async () => {
+ const api = fakeApi(baseRoutes());
+ const store = new CockpitStore(api);
+ await store.init();
+ await store.saveBackend({ backendUrl: "ws://y", chargeBoxId: "CB02", authorizationKey: "k", pingInterval: "7", reconnectInterval: "20" });
+ const post = api.calls.find(([m, p]) => m === "POST" && p === "/websocket");
+ assert.equal(post[2].pingInterval, 7);
+ assert.equal(post[2].reconnectInterval, 20);
+ assert.equal(store.state.backend.chargeBoxId, "CB02");
+});
+
+test("actions append wire-trace entries, capped at 60", async () => {
+ const api = fakeApi(baseRoutes());
+ const store = new CockpitStore(api);
+ await store.init();
+ for (let i = 0; i < 70; i++) store.trace("1", "line " + i, "text");
+ assert.equal(store.state.log.length, 60);
+ await store.plugIn("1");
+ assert.ok(store.state.log[0].line.includes("POST /connector/1/evse"));
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd webapp-src && npm test`
+Expected: FAIL — cannot find `../src/store.mjs`.
+
+- [ ] **Step 3: Implement store.mjs**
+
+`webapp-src/src/store.mjs`:
+```js
+import { stamp } from "./lib/format.mjs";
+
+const HISTORY_LEN = 60;
+const LOG_CAP = 60;
+
+export function makeConnector(id) {
+ return {
+ id: String(id),
+ evPlugged: false, evsePlugged: true, evReady: false, evseReady: false,
+ chargePointStatus: "",
+ transactionId: -1, idTag: "",
+ energy: 0, power: 0, current: 0, voltage: 0,
+ maxPower: 11000, maxCurrent: 16,
+ history: new Array(HISTORY_LEN).fill(0),
+ };
+}
+
+export class CockpitStore {
+ constructor(api) {
+ this.api = api;
+ this.state = {
+ connectors: [],
+ backend: { backendUrl: "", chargeBoxId: "", authorizationKey: "", pingInterval: 0, reconnectInterval: 30 },
+ link: "init",
+ log: [],
+ };
+ this.listeners = new Set();
+ this.polling = false;
+ }
+
+ subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
+ emit() { for (const fn of this.listeners) fn(); }
+
+ trace(cid, line, tone = "text") {
+ this.state.log.unshift({ t: stamp(), cid, line, tone });
+ if (this.state.log.length > LOG_CAP) this.state.log.length = LOG_CAP;
+ this.emit();
+ }
+
+ connector(id) { return this.state.connectors.find((c) => c.id === String(id)); }
+
+ markLink(ok) {
+ const prev = this.state.link;
+ const next = ok ? "ok" : "down";
+ if (prev === next) return;
+ this.state.link = next;
+ if (next === "down") this.trace(0, "POLL FAILED · SIMULATOR UNREACHABLE", "red");
+ else if (prev === "down") this.trace(0, "LINK RESTORED · POLLING RESUMED", "green");
+ this.emit();
+ }
+
+ async init() {
+ const ids = await this.api.get("/connectors");
+ this.state.connectors = ids.map(makeConnector);
+ try { await this.loadBackend(); } catch (e) { /* backend config panel shows blanks */ }
+ this.emit();
+ }
+
+ async loadBackend() {
+ const cfg = await this.api.get("/websocket");
+ this.state.backend = { ...this.state.backend, ...cfg };
+ this.emit();
+ }
+
+ async pollFast() {
+ if (this.polling || this.state.connectors.length === 0) return;
+ this.polling = true;
+ try {
+ await Promise.all(this.state.connectors.map(async (c) => {
+ const [evse, meter, txn] = await Promise.all([
+ this.api.get(`/connector/${c.id}/evse`),
+ this.api.get(`/connector/${c.id}/meter`),
+ this.api.get(`/connector/${c.id}/transaction`),
+ ]);
+ Object.assign(c, evse, meter, {
+ idTag: txn.idTag || "",
+ transactionId: typeof txn.transactionId === "number" ? txn.transactionId : -1,
+ });
+ c.history.push(c.power || 0);
+ if (c.history.length > HISTORY_LEN) c.history.shift();
+ }));
+ this.markLink(true);
+ } catch (e) {
+ this.markLink(false);
+ } finally {
+ this.polling = false;
+ this.emit();
+ }
+ }
+
+ async pollSlow() {
+ try {
+ await Promise.all(this.state.connectors.map(async (c) => {
+ const sc = await this.api.get(`/connector/${c.id}/smartcharging`);
+ c.maxPower = sc.maxPower ?? c.maxPower;
+ c.maxCurrent = sc.maxCurrent ?? c.maxCurrent;
+ }));
+ this.emit();
+ } catch (e) { /* smartcharging is cosmetic; fast poll owns link state */ }
+ }
+
+ async postEvse(id, body) {
+ const c = this.connector(id);
+ try {
+ const resp = await this.api.post(`/connector/${id}/evse`, body);
+ Object.assign(c, resp);
+ this.trace(id, `POST /connector/${id}/evse ${JSON.stringify(body)} → 200`, "ice");
+ } catch (e) {
+ this.trace(id, `POST /connector/${id}/evse FAILED`, "red");
+ }
+ this.emit();
+ }
+
+ plugIn(id) { return this.postEvse(id, { evPlugged: true, evReady: true }); }
+ unplug(id) { return this.postEvse(id, { evPlugged: false, evReady: false, evseReady: false }); }
+ toggle(id, key) {
+ const c = this.connector(id);
+ return this.postEvse(id, { [key]: !c[key] });
+ }
+
+ async tapCard(id, idTag) {
+ const c = this.connector(id);
+ try {
+ const resp = await this.api.post(`/connector/${id}/transaction`, { idTag });
+ c.idTag = resp.idTag ?? c.idTag;
+ if (typeof resp.transactionId === "number") c.transactionId = resp.transactionId;
+ this.trace(id, `POST /connector/${id}/transaction {idTag:"${idTag}"} → 200`, "green");
+ } catch (e) {
+ this.trace(id, `POST /connector/${id}/transaction FAILED`, "red");
+ }
+ this.emit();
+ }
+
+ endSession(id) {
+ const c = this.connector(id);
+ return this.tapCard(id, c.idTag);
+ }
+
+ async saveBackend(form) {
+ const body = {
+ backendUrl: form.backendUrl,
+ chargeBoxId: form.chargeBoxId,
+ authorizationKey: form.authorizationKey,
+ pingInterval: parseInt(form.pingInterval, 10) || this.state.backend.pingInterval,
+ reconnectInterval: parseInt(form.reconnectInterval, 10) || this.state.backend.reconnectInterval,
+ };
+ const resp = await this.api.post("/websocket", body);
+ this.state.backend = { ...this.state.backend, ...resp };
+ this.trace(0, `POST /api/websocket {chargeBoxId:"${body.chargeBoxId}"} → 200 · RECONNECTING`, "gold");
+ this.emit();
+ }
+
+ async loadCert() {
+ const resp = await this.api.get("/ca_cert");
+ return resp.caCert || "";
+ }
+
+ async saveCert(pem) {
+ await this.api.post("/ca_cert", { caCert: pem });
+ this.trace(0, "POST /api/ca_cert → 200 · CERTIFICATE UPDATED", "gold");
+ this.emit();
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd webapp-src && npm test`
+Expected: all tests PASS (status, format, store).
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd webapp-src && git add src/store.mjs tests/store.test.mjs && git commit -m "feat: CockpitStore polling engine and actions with DI tests"
+```
+
+---
+
+### Task 4: Design-system primitives (`ds.js`)
+
+**Files:**
+- Create: `webapp-src/src/components/ds.js`
+
+**Interfaces:**
+- Consumes: CSS variables/classes from `tokens.css`.
+- Produces (named exports, Preact): `Button({variant, size, clip, disabled, onClick, style, children})`, `CornerBracketCard({selected, tone, clip, padding, style, children})`, `SectionLabel({label, action, color, style})`, `TwoPartPrompt({label, action, tone, filled, onClick, disabled, style})`, `Chip({label, tone, style})`, `ConnectionDot({label, status, style})`, `MonoStat({value, label, unit, tone, size, align, style})`, `StatusPill({status, tone, dot, style})`, `Spark({data, max})`, plus shared style objects `LABEL` and `MONO`.
+
+- [ ] **Step 1: Port the primitives**
+
+Port each component in `webapp-src/design-reference/ds-components.js` from `React.createElement` calls to Preact JSX (`import { h, Fragment } from "preact"; import { useState } from "preact/hooks";`). Rules for the port:
+- Keep every style object, tone table, and prop default **byte-identical** to the reference — this is a faithful port, not a redesign.
+- `className` → keep as `className` (Preact accepts it).
+- Drop the `__ds_scope` / try-catch wrappers; use `export function`.
+- Add `Spark` and the shared label styles (used by Bay/Trace/TopStrip/SettingsDrawer), source `design-reference/bay.jsx` lines 5-19:
+
+```js
+export const LABEL = { fontFamily: "var(--font-mono)", fontSize: "var(--fs-micro)", textTransform: "uppercase", letterSpacing: "var(--ls-label)", color: "var(--text-2)" };
+export const MONO = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
+
+export function Spark({ data, max }) {
+ const w = 100, h2 = 30;
+ const peak = Math.max(max * 0.25, ...data) || 1;
+ const pts = data.map((v, i) => `${(i / (data.length - 1)) * w},${h2 - (v / peak) * h2}`).join(" ");
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 2: Smoke-render in the entry stub**
+
+Temporarily extend `src/index.js` to render one of each primitive (a `CornerBracketCard` containing `StatusPill status="CHARGING" tone="ONLINE"`, a `TwoPartPrompt label="BAY 1" action="PLUG IN CAR"`, a `MonoStat value="7.40" unit="KW" label="POWER"`, a `Spark data={[0,2,5,3,8]} max={10}`). Run `npm run dev` (dev server opens on a local port) and visually confirm: dark page, gold clipped buttons, bracket corners on `selected`, mono uppercase labels, Michroma renders on `.hud-display`.
+
+- [ ] **Step 3: Verify production build still compiles**
+
+Run: `cd webapp-src && npm run build`
+Expected: exit 0.
+
+- [ ] **Step 4: Commit** (leave the smoke-render in place; Task 7 replaces `index.js` anyway)
+
+```bash
+cd webapp-src && git add src/components/ds.js src/index.js && git commit -m "feat: port HUD design-system primitives to Preact"
+```
+
+---
+
+### Task 5: TopStrip, Trace, Bay
+
+**Files:**
+- Create: `webapp-src/src/components/TopStrip.js`, `webapp-src/src/components/Trace.js`, `webapp-src/src/components/Bay.js`
+
+**Interfaces:**
+- Consumes: `ds.js` exports; `statusLabel`, `PILL_TONE`, `isActiveStatus` from `../lib/status.mjs`; `kw`, `kwh`, `amps`, `volts`, `pad2`, `stamp` from `../lib/format.mjs`; store instance via props.
+- Produces: `TopStrip({ store, onSettings })`, `Trace({ log })`, `Bay({ store, c })` — where `c` is a connector object from `store.state.connectors`.
+
+- [ ] **Step 1: Port TopStrip and Trace**
+
+Port from `design-reference/app-shell.jsx` (functions `TopStrip` and `Trace`) with these changes:
+- `S.state.backend` → `store.state.backend`; `down` → `store.state.link === "down"`; `S.stamp()` → `stamp()`.
+- The `ri-settings-3-line` icon → import the existing SVGR icon: `import IControls from "./icons/IControls.svg";` rendered at 13px.
+- The METER ConnectionDot status: `store.state.link === "ok" ? "ok" : "error"`.
+- Brand text: `MICROOCPP SIM` + gold dot; subtitle `OCPP 1.6J CHARGE POINT`.
+- Everything else (layout, styles, LIVE · 1S pulse dot, strip-min responsive class) byte-identical.
+
+- [ ] **Step 2: Port Bay**
+
+Port from `design-reference/bay.jsx` with these changes:
+1. Props are `{ store, c }`; every `act(() => S.xxx(c))` becomes a direct `store.xxx(c.id)` call (the store emits — no `bump` prop).
+2. `const status = c.chargePointStatus;` (server truth, not client derivation) and `const [text, tone] = statusLabel(status); const charging = isActiveStatus(status);`
+3. `FlowScene` icons: `import IEvseIcon from "./icons/IEvseIcon.svg";` for the charger, `import IEv from "./icons/IEv.svg";` for the car (render ~34px via width/height props or wrapping span with `display:flex`). Keep the 16 flow bars and wire-color logic identical.
+4. Stats row: ``, `value={kwh(c.energy)} unit="KWH" label="ENERGY"`, `value={amps(c.current)} unit="A" label="CURRENT"`, `value={volts(c.voltage)} unit="V" label="VOLTAGE"`.
+5. Verb logic identical: `!c.evPlugged` → `TwoPartPrompt label={"BAY " + c.id} action="PLUG IN CAR"` calling `store.plugIn(c.id)`; else if `c.transactionId < 0` → `action="TAP RFID CARD"` calling `store.tapCard(c.id, tag)`; else → `label={"TXN " + c.transactionId} action="STOP CHARGING" tone="amber"` calling `store.endSession(c.id)`. `UNPLUG` outline button when plugged.
+6. **Remove** the `INJECT FAULT` button (no endpoint). Keep `ADVANCED` toggle.
+7. Advanced panel: four `Toggle`s — `EVPLUGGED`, `EVSEPLUGGED`, `EVREADY`, `EVSEREADY` — each calling `store.toggle(c.id, key)`; the idTag input (local `useState`, default `"D7A4-1180"`) + `AUTHORISE` button calling `store.tapCard(c.id, tag)`; readout line shows `CHARGEPOINTSTATUS {status || "—"}`, `TRANSACTIONID {c.transactionId}`, `MAXCURRENT {c.maxCurrent} A`.
+8. Header right side: `TYPE 2 · {kw(c.maxPower)} KW LIMIT` (was `.toFixed(1)` of kW — use `(c.maxPower/1000).toFixed(1)` to stay identical to the prototype).
+9. `PEAK {kw(Math.max(...c.history))} KW` above ``.
+
+- [ ] **Step 3: Compile check**
+
+Run: `cd webapp-src && npm run build`
+Expected: exit 0. (Visual verification happens in Task 7 with the full app.)
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd webapp-src && git add src/components/TopStrip.js src/components/Trace.js src/components/Bay.js && git commit -m "feat: port TopStrip, Trace, Bay to Preact wired to CockpitStore"
+```
+
+---
+
+### Task 6: SettingsDrawer
+
+**Files:**
+- Create: `webapp-src/src/components/SettingsDrawer.js`
+
+**Interfaces:**
+- Consumes: `SectionLabel`, `Button`, `Chip`, `ConnectionDot`, `LABEL`, `MONO` from `ds.js`; `stamp` from `../lib/format.mjs`; store via props.
+- Produces: `SettingsDrawer({ store, open, onClose })`.
+
+- [ ] **Step 1: Port from `design-reference/settings-drawer.jsx`**
+
+Changes from the reference:
+1. On `open` becoming true: `setForm({ ...store.state.backend })`, `setSaved(false)`, and `store.loadCert().then(setCert).catch(() => {})` (`useEffect` on `[open]`).
+2. `save()` becomes async: `await store.saveBackend(form)`; then `if (certDirty) await store.saveCert(cert);` then `setSaved(true)`. Track `certDirty` with a boolean set in the cert field's onChange. Wrap in try/catch — on failure render `SAVE FAILED` in `var(--red)` instead of the `WRITTEN · {stamp()}` confirmation.
+3. **Remove** the DIAGNOSTICS section entirely (`SIMULATE LINK LOSS` was mock-only; link state now comes from real poll failures).
+4. `ConnectionDot` in the BACKEND LINK header: `status={store.state.link === "ok" ? "ok" : "error"}`.
+5. Field labels/hints identical: `BACKEND SERVER`, `STATION ID`, `AUTH KEY`, `PING (S)`, `RECONNECT (S)`, `CA CERTIFICATE` with hint `PEM, POSTED TO THE SIMULATOR ON SAVE`, chip `REAL` → change chip to `LIVE` tone green.
+6. Footer button: `SAVE & RECONNECT` (`Button clip`).
+
+- [ ] **Step 2: Compile check**
+
+Run: `cd webapp-src && npm run build`
+Expected: exit 0.
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd webapp-src && git add src/components/SettingsDrawer.js && git commit -m "feat: port settings drawer with live websocket + ca_cert round-trip"
+```
+
+---
+
+### Task 7: App assembly + end-to-end browser verification
+
+**Files:**
+- Create: `webapp-src/src/components/App.js`
+- Modify: `webapp-src/src/index.js`
+
+**Interfaces:**
+- Consumes: everything above; `DataService` from `../DataService`.
+- Produces: `App({ store })` — the complete cockpit.
+
+- [ ] **Step 1: Write App.js**
+
+Port the `Cockpit` function from `design-reference/app-shell.jsx`:
+
+```js
+import { h } from "preact";
+import { useEffect, useReducer, useState } from "preact/hooks";
+import TopStrip from "./TopStrip";
+import Trace from "./Trace";
+import Bay from "./Bay";
+import SettingsDrawer from "./SettingsDrawer";
+import { MonoStat, Chip, LABEL, MONO } from "./ds";
+import { isActiveStatus } from "../lib/status.mjs";
+import { kw, pad2 } from "../lib/format.mjs";
+import IForbidden from "./icons/IForbidden.svg";
+
+export default function App({ store }) {
+ const [, bump] = useReducer((n) => n + 1, 0);
+ const [open, setOpen] = useState(false);
+
+ useEffect(() => {
+ const unsub = store.subscribe(bump);
+ store.init().then(() => store.pollFast()).catch(() => store.markLink(false));
+ const fast = setInterval(() => store.pollFast(), 1000);
+ const slow = setInterval(() => store.pollSlow(), 5000);
+ return () => { unsub(); clearInterval(fast); clearInterval(slow); };
+ }, []);
+
+ const cs = store.state.connectors;
+ const down = store.state.link === "down";
+ const active = cs.filter((c) => isActiveStatus(c.chargePointStatus)).length;
+ const total = cs.reduce((a, c) => a + (c.power || 0), 0);
+ const energy = cs.reduce((a, c) => a + (c.energy || 0), 0);
+
+ return (
+
+ setOpen(true)} />
+ {down && (
+
+
+
+ SIMULATOR UNREACHABLE · SHOWING LAST KNOWN STATE · RETRYING EVERY 1S
+
+