# Stick 'Em Workshop — Build Your Own Robot App

Welcome! In the next two hours you are going to dream up and build a **web app that controls a Stick 'Em build over Bluetooth**, straight from your laptop. No servers, no installs, no frameworks. Just a single HTML file you can vibe-code with your AI assistant.

The output you build does **not** have to be a humanoid robot. It can be a snake, a flower, a giant eyeball, a cardboard cat — anything you can make move with up to 8 small motors.

This document is your **starting kit**. Drop it into Claude Code (or Cursor, Windsurf, Copilot — whatever you use). Your AI assistant will read it, ask you a few questions about what you want to build, and then scaffold the project with you.

**What is in this starter kit (two files):**

1. **This guide** (`Stick-Em-Workshop-Instructions.md`) tells your AI helper everything it needs. Give it this file first.
2. **`Humanoid-Robot-Example.html`** is a complete, working robot app in ONE file. Open it to see how a finished Stick 'Em app is built, then make your own.

Works with any AI helper: Claude, Codex, DeepSeek, Copilot, Cursor, Gemini. Everything you build is a single `.html` file, so there is nothing to unzip and no folders to wire together. If your AI helper cannot open files (for example DeepSeek in the browser), just paste the text of this guide straight into the chat.

---

## ⚠️ Before You Start — Check Your Device

**This is the most important thing on this page. Read it first.**

Web Bluetooth, the technology your app will use to talk to the Stick 'Em board, **does not work on iPhones or iPads in Safari**. Apple has not added it. If you build your project planning to test on an iPhone at the end, it will not work and you will be sad.

| Device | Will it work? |
|---|---|
| **Windows / Mac / Linux laptop in Chrome or Edge** | ✅ Yes, this is the easy path. Build here. |
| **Android phone in Chrome** | ✅ Yes, works natively. |
| **iPhone or iPad in Safari / Chrome / Firefox** | ❌ No. Web Bluetooth is blocked. |
| **iPhone or iPad with the *Bluefy* app from the App Store** | ✅ Yes — *Bluefy* is a third-party browser that supports Web Bluetooth. Open your web page inside Bluefy. |

**👉 Plan on developing and demoing on a laptop in Chrome.** If your project specifically wants the phone to be the controller, make sure your phone is Android — or be ready to install *Bluefy* on iPhone.

When you start chatting with your AI assistant, **tell it which device you plan to use** before it writes any code.

---

## 🛠 How the Stick 'Em Motors Actually Work (read this — it surprises everyone)

The motors in your Stick 'Em kit are **MG90S continuous rotation servos**. There are two things you must understand before you build:

**1. They don't know what an angle is.**
These motors have **no idea what "90 degrees" means.** Forget anything you have heard about servos having "positions." They cannot move to a specific angle, and there is no way to ask one where it is currently pointing. The only thing you can tell them is:

> *"Spin this way at this speed, for this many milliseconds, then stop."*

That's it. So if you want the motor to "turn a quarter turn," you instead say *"spin forward for about 400 milliseconds at medium speed,"* and you fiddle with the number until the physical movement looks about right.

**2. No two motors behave identically.**
Even between the *forward* and *backward* directions of the same motor, the speed is not the same. One direction will travel further in the same number of milliseconds than the other. Two different motors will be different again. This is normal for cheap motors.

That is exactly why every project you build in this workshop comes with a **Servo Calibration Tool** baked into the bottom of the page — so you can nudge each motor until forward and backward look balanced. More on that further down.

The two numbers you have control over for any motion are therefore:

- **Speed**: how far the PWM value is from `1500`. `1500` means *stop*. `1700` is a moderate forward spin. `2000` is full speed forward. `1300` and `1000` are the same in the other direction.
- **Duration**: how many milliseconds you keep that speed before stopping.

To make a movement bigger, spin **longer** or **faster**. To get it more accurate, **tune the duration in small steps** until it matches what you want.

> ⚠️ If you find yourself asking your AI assistant *"can you make it rotate 45 degrees?"* — stop. The motor cannot do that. Ask instead: *"can you spin it forward for about 300 ms at medium speed and let me tune it from there?"*

---

## What Do You Want to Build?

This is the fun part. **You decide.** The Stick 'Em board exposes up to 8 motors over Bluetooth — that is your output. Anything in the browser can be your input: microphone, camera, mouse, keyboard, phone tilt, AI models, audio files, the time of day, the weather, whatever you can imagine.

Take a few minutes to think before you start typing. A good project is **specific, simple, and weird**. "A snake that sways when I play music" is a better starting point than "an AI assistant."

If you are stuck for inspiration, here are some directions other people have explored — but please **invent your own** instead of just picking one of these.

- A **snake charmer** — play music or whistle and a servo-jointed snake sways with the rhythm.
- A **sound-reactive flower** — each petal is a servo; music opens it, silence closes it.
- A **giant watching eyeball** that pans and tilts to follow your face around the room.
- A **sunflower tracker** — a cardboard sunflower that turns toward the brightest spot on the webcam.
- An **eight-tentacle octopus** where each tentacle reacts to a different input (mic, mouse, hand, weather…).
- A **lip-sync puppet** — speak into the mic and the puppet's mouth flaps in time with your voice.
- A **paper face** that smiles, frowns, or raises an eyebrow when you do.
- A **cardboard hand** that mirrors your real fingers via the webcam.
- A **drumming pair of sticks** that beats in time with music in the room.
- A **live audio equalizer** — eight motors as eight EQ bars, dancing to whatever's playing.
- A **cardboard cat** whose paw swats at your mouse cursor on screen.
- An **object follower** — a head or eye that tracks a coloured ball, fruit, or pet across the room.
- A **phone-tilt steering rig** — tilt an Android phone, the build leans the same way.
- A **morse-code tapper** — type a message, the motor taps it out on a desk.
- A **magic 8-ball** — ask any question; an LLM picks the answer and a needle points at "yes / no / maybe".
- A **hat-tipping gentleman** — a cardboard figure that tips its top hat whenever you say "thank you".
- A **typing speedometer** — a needle that physically moves with how fast you type.
- An **excitable pet** that dances when you type or play music, and droops in silence.
- A **lonely plant** that slowly droops over time and perks back up when you talk to it.
- A **cuckoo inbox** where a figure pops out every time a "notification" arrives.

The point is: pick something you can describe in one sentence, and which you can build a first version of in **under an hour**. You can always make it weirder afterwards.

---

## How to Use This File

1. Open this file with your AI helper (Claude, Codex, DeepSeek, Copilot, Cursor...).
2. Ask it: *"Read this whole file, then help me build my own Stick 'Em app."*
3. The AI will **interview you**: what device, what idea, what build, how many motors, what skill level. **Answer honestly.** Saying "I don't know yet" is fine; the AI will help you narrow down.
4. Once your idea is clear, the AI builds you a single `.html` file. That one file IS your whole app. There are no other files to manage.
5. To test it, ask your AI helper: *"Please run this on a local web server and open it in Chrome."* It will do it for you. (Why a server? See Quick Setup below.)
6. Test on a real Stick 'Em board as early as possible. Real motors behave differently from your imagination. That is what the calibration tool at the bottom of your page is for.
7. Stuck for how it should look or move? Open `Humanoid-Robot-Example.html` and show it to your AI helper as a reference.

---

## Quick Setup

**The easy way: ask your AI helper to run it for you.** Say *"run this on a local web server and open it in Chrome"* and it will handle everything below. Only read on if you want to do it by hand.

Your app talks to the robot over Bluetooth. Browsers only allow that on a real website (`https`) or on your own computer's `localhost`. Opening the file by double-clicking it (a `file://` address) is blocked for safety. So you run a tiny local web server. Open a terminal in the folder with your `.html` file:

```bash
# Mac / Linux
python3 -m http.server 8000

# Windows (PowerShell or Command Prompt)
python -m http.server 8000

# Or, with Node installed:
npx serve .
```

Then visit **http://localhost:8000** in Chrome. **Not** `127.0.0.1`, **not** `file://`, **not** Safari.

---

## When Things Break

- **"My Connect Board button does nothing."** → Make sure you are in Chrome / Edge, on `http://localhost`, not `file://`. iOS Safari will not work.
- **"I clicked a button or triggered a movement, but the robot didn't move and nothing on the page looked broken."** → This was the #1 mystery in earlier workshops. The fix: look at the **Debug Log** panel at the bottom of your page (the scaffold mounts one automatically). Every command actually transmitted over Bluetooth shows up there with a timestamp.
  - **If you see the command listed in green** → the robot received it. The bug is physical: check wiring, check that the calibration speed isn't set so low the motor can't overcome friction, check the duration isn't a few milliseconds.
  - **If nothing shows up** → your event handler never called `sendServos` or `pulseServo`. Add a `console.log` to the start of your handler and check it actually fires.
  - **If you see a red `error:` line** → the BLE write failed, usually because the board just disconnected. Reconnect.
- **"It connects but the motor does not move."** → Open the Chrome DevTools console (F12) and look for BLE write errors. Check your command string has a trailing comma. Check your PWM values are between 1000 and 2000.
- **"The motor jitters or whines."** → You are sending values too fast or with too big a jump. Add smoothing. The starter scaffold caps sends at ~40Hz already.
- **"It moves further forward than backward."** → That's the asymmetry. Open the Servo Calibration Tool at the bottom of your page and adjust the trim sliders until forward and backward match.
- **"I asked it to move 90 degrees and nothing sensible happens."** → These motors don't know what degrees are. Tell your AI to spin the motor for a short burst of milliseconds and let you tune the duration by feel.
- **"The motor suddenly stops responding."** → BLE disconnected. Reconnect. Cheap modules drop sometimes. The scaffold automatically re-sends the last command every 200 ms while connected, so brief drops normally self-heal once you reconnect.
- **"I clicked Stop and the robot kept moving. I reloaded the page and it STILL kept moving. Only the physical reset button on the robot stopped it."** → This is the dangerous one. Two things have to be true for Stop to actually stop:
  1. Your Stop button must send the literal string `stopAll,` — *not* `mv:1500:1500:...`. The latter says "drive all motors at PWM 1500" which depends on each motor's mechanical neutral; the former literally halts the PWM signal.
  2. The firmware has no connection-loss watchdog — whatever command it last heard keeps running. So your page must fire one more `stopAll,` on `beforeunload` and `pagehide` (page reload, tab close), AND your Stop button should spam `stopAll,` 5× directly to the BLE characteristic (bypassing dedup) to guarantee at least one lands before the user closes the tab. The scaffold's `window.stickemPanicStop()` does this for you — call it instead of `stopAll()` from your Stop button.
- **"The robot moves but reacts slowly / does the wrong thing for the current line/object position."** → You are flooding BLE with too-frequent writes and old commands are queued ahead of new ones. Apply all three: rate-limit BLE writes to **20 Hz max** (50 ms minimum interval), **EMA-smooth** your CV signal (`smoothed = smoothed * 0.6 + raw * 0.4`) before computing PWM, and **quantize PWM to multiples of 5** so sub-detectable adjustments don't generate new command strings. See pitfall §C in the AI section below for the technical why.
- **`stopAll,`** is your friend. Wire a big red Stop button into your UI early.

---

Have fun. The build is forgiving. Your code does not need to be elegant — it just needs to produce numbers between 1000 and 2000, hold them for a measured number of milliseconds, then stop.

---
---

# 🤖 AI ASSISTANT — READ THIS WHOLE SECTION BEFORE WRITING ANY CODE

The rest of this document is the technical spec for the AI coding assistant helping the workshop participant. Everything above this line was for the human; everything below is for you, the assistant.

## STEP 1 — Interview the User FIRST. Do Not Skip This.

**Before you write a single line of code, ask the user the following questions, one at a time, in plain conversation.** Do not present them as a numbered list. Wait for an answer before moving to the next.

1. **What device are you developing on?** (Windows laptop / Mac / Linux / Android phone / iPad / iPhone.) → If iPhone or iPad: warn them that Safari does not support Web Bluetooth, point them at the *Bluefy* app on the App Store, and confirm they understand before continuing. If Windows / Mac / Linux: confirm they will use Chrome or Edge.

2. **In one sentence, what do you want to build?** → If they say "I don't know" or hand you a vague idea, **help them brainstorm**. Offer 2–3 specific, weird, achievable suggestions tied to whatever they seem interested in — but encourage them to land on something *they* invented. The output does **not** have to be a humanoid robot; remind them they can build a snake, a flower, a giant eyeball, a cardboard cat, an octopus, anything. **Once the idea is locked, immediately commit to a strong visual theme for the UI based on that idea — see Step 5. Do not default to a generic-looking page.**

3. **What is the input?** (Mic? Camera? Mouse / keyboard? Phone tilt? Typed text? An audio file? An AI model?) → If the answer involves a phone, double-check the phone is Android.

4. **How many motors are you using, and what does each one do on the physical build?** → This determines which servo slots (1–8) you send commands to. Write this down — the calibration tool you generate must include one card per motor they list here.

5. **Does the project need precise positioning** (e.g., "point at this exact spot," "rotate by a specific angle")? → If yes, **warn them**: these are **continuous rotation servos**. They do not know angles or absolute positions. The only thing you can control is *direction*, *speed* (PWM offset from 1500), and *duration* (in milliseconds). To get an approximate "position," they will pulse the motor for a calibrated duration and tune by trial and error. Ask if they are OK with that constraint; if not, help them reshape the project into something motion-based rather than position-based.

6. **What is their experience level?** (Never coded / some HTML / experienced dev / AI engineer.) → Adjust how much you explain. For beginners, keep the code in one file and over-comment. For experienced devs, skip the hand-holding.

7. **What does "done" look like for the next 90 minutes?** → Get them to commit to a v1 they can demo. They can always extend it after.

Once you have answers, **summarise the project back to the user in 3–4 sentences and ask them to confirm** before you write code. If they want to change anything, iterate. Only start coding when they say "go".

---

## STEP 2 — Hardware Facts

- The **Stick 'Em servo controller board** has **8 servo ports** (servo 1 through servo 8).
- The board uses an HM-10 style **UART-over-BLE module**.
- The motors are **MG90S continuous rotation servos**. **Critical:**
  - They are **NOT positional**. They cannot move to "an angle."
  - PWM = `1500` means **STOP**, not "centred position."
  - PWM > `1500` means **spin in one direction at a speed proportional to the offset from 1500**. So `1700` is a moderate forward spin; `2000` is full forward.
  - PWM < `1500` means **spin in the other direction**. `1300` moderate, `1000` full reverse.
  - The "amount" the motor turns is determined by **how long you hold the spin** in milliseconds.
- Motors have **asymmetric behaviour**: forward speed and backward speed at the same PWM offset are usually different, and two motors on the same board will also differ. This is why every project must include the Servo Calibration Tool (Step 7).
- Useful PWM offset range is usually `1300–1700` (gentler control); `1000` and `2000` are full-speed and can be too fast for delicate builds. Start gentle.
- Servos are wired to whatever the participant built. **Ask** — do not assume servo 1 is "the head."

**Do not use the word "degrees" or "angle" or "position" when describing servo movement in chat with the user or in code comments.** Talk about *direction*, *speed*, and *duration* in milliseconds.

## STEP 3 — BLE Protocol (exact spec — follow precisely)

| Item | Value |
|---|---|
| BLE Service UUID | `0xFFE0` |
| TX Characteristic UUID | `0xFFE1` (write only, no notifications) |
| Device picker filter | `namePrefix: '⠀'` (Braille blank — only Stick 'Em boards include this) |
| Fallback picker | `acceptAllDevices: true, optionalServices: [0xFFE0]` if the namePrefix filter fails on the user's OS Bluetooth stack |
| Command encoding | UTF-8 text via `new TextEncoder().encode(cmd)` |
| Send cadence | **~20 Hz max (50 ms minimum interval), with deduplication.** See pitfall §C below — higher cadences saturate the OS BLE stack and the robot processes stale commands instead of the latest one. |
| Write concurrency | **Strictly one GATT write at a time.** Use a `writing` boolean guard. Overlapping writes will throw. |
| Halt command | `stopAll,` — **the explicit firmware halt.** *Not* equivalent to `mv:1500:1500:...` See pitfall §A below. |
| Disconnect behaviour | The firmware has **no connection-loss watchdog.** Whatever command was last received keeps running forever. See pitfall §B below. |

### Command Format

Move command (mandatory trailing comma):

```
mv:<m1>:<m2>:<m3>:<m4>:<m5>:<m6>:<m7>:<m8>,
```

- Each `mN` is either an integer PWM value `1000–2000`, OR `-` to skip that slot (leaves the motor doing whatever it was doing).
- `1500` = **stop spinning**. Other values = spin at a speed proportional to offset.
- Trailing comma is a **firmware delimiter** and must always be present.

Stop command:

```
stopAll,
```

### Examples

```
mv:1500:1500:1500:1500:1500:1500:1500:1500,     // all 8 motors stopped
mv:1700:-:-:-:-:-:-:-,                          // motor 1 spins moderate forward; others untouched
mv:1300:-:-:-:-:-:-:-,                          // motor 1 spins moderate reverse
mv:1500:-:-:-:-:-:-:-,                          // motor 1 stop
stopAll,                                        // emergency stop / all idle
```

### Buzzer Commands (piezo on the Stick 'Em board)

Every Stick 'Em PCB has a piezo buzzer wired into the firmware. There are four buzzer commands, and **every numeric argument MUST be wrapped in the Scratch-block text form `{(num:{N})}`**. Bare integers (e.g. `playToneDuration:262:120:0`) fail the firmware's parser silently — the buzzer simply doesn't update and the previous tone stays stuck. The `bg` flag is a literal `{0}` or `{1}` (boolean-like, not number-typed).

```
playToneDuration:{(num:{<Hz>})}:{(num:{<ms>})}:{<bg>},      // play a tone for ms milliseconds
playTone:{(num:{<Hz>})}:{(num:{<beats>})}:{<bg>},           // play a tone for beats beats at the current BPM
setBPM:{(num:{<bpm>})},                                      // set tempo used by playTone
stopAllSounds,                                               // silence the buzzer immediately
```

- `Hz` is the tone frequency. Audible piezo range is roughly 100–5000 Hz; below 80 Hz or above 8 kHz the buzzer is inaudible or unpleasant.
- `bg` is `0` for **blocking** (firmware will not accept new commands until the tone finishes) or `1` for **background** (fire-and-forget; the firmware keeps accepting commands). For continuous-update apps like a theremin, use `bg=1` with overlapping durations so each new write overrides the previous tone smoothly.
- `playTone` uses musical beats, scaled by whatever you last set via `setBPM:`. Default firmware BPM is 120.
- The buzzer is a single-voice piezo — there is **no polyphony and no real volume control.** In speaker-mode (Web Audio) apps you get true amplitude control; on the piezo the right hand can only operate as an on/off gate.
- **Multiple commands per write.** The firmware splits one BLE write at commas (at brace level 0 — braces nest!) and runs each piece as a sequential program on one parser task. Pack a stop+play pair as `stopAllSounds,playToneDuration:{(num:{440})}:{(num:{120})}:{1},` in ONE write so they execute as a unit; two separate writes spawn two parser tasks and can race.

#### Examples

```
playToneDuration:{(num:{262})}:{(num:{1000})}:{0},          // C4 (262 Hz) for one second, blocking
playToneDuration:{(num:{440})}:{(num:{120})}:{1},           // A4 background tone for 120 ms (good for theremin refresh)
setBPM:{(num:{90})},                                          // slower tempo
playTone:{(num:{523})}:{(num:{2})}:{1},                       // C5 for two beats at 90 BPM, background
stopAllSounds,                                                // silence
stopAllSounds,playToneDuration:{(num:{330})}:{(num:{150})}:{1},  // atomic pitch change in ONE write
```

#### Buzzer Recipes (real code you can lift)

Pull these directly into your project. All four are wired up in the scaffold via `window.sendBuzzerTone`, `window.sendBuzzerStop`, etc. — never assemble the raw `playToneDuration:...` strings yourself.

**Recipe 1 — One-shot "tick" beep (countdown timers, metronome, heartbeat).** Pair it with a `pulseServo` call for a synchronised tick. Use `background = true` so the firmware accepts the next tick without waiting for the previous tone to finish.

```js
function tick() {
  if (!window.stickemIsConnected()) return;
  window.pulseServo(1, +1, 100);                 // 100 ms servo flick
  window.sendBuzzerTone(1000, 30, true);          // 1 kHz beep for 30 ms, background
}
setInterval(tick, 1000);
```

**Recipe 2 — Continuous siren / alarm (timer expired, defuse-me, robot startled).** Re-fire the tone every ~480 ms so it overlaps with the previous 400 ms tone and the buzzer sounds continuous. Pair it with a continuous servo spin (`sendServos(2000, ...)` or `1000` for the other direction) to escalate the panic.

```js
let alarmInterval = null;
function startAlarm() {
  if (window.stickemIsConnected()) {
    window.sendServos(2000, null, null, null, null, null, null, null); // continuous spin
  }
  alarmInterval = setInterval(() => {
    if (window.stickemIsConnected()) window.sendBuzzerTone(1000, 400, true);
  }, 480); // 400 ms tone, fire every 480 ms — slight overlap = continuous siren
}
function stopAlarm() {
  if (alarmInterval) { clearInterval(alarmInterval); alarmInterval = null; }
  window.stopAll();
  window.sendBuzzerStop();
}
```

**Recipe 3 — Pitched theremin / continuous-pitch instrument.** Use `sendBuzzerChangeTone(hz, ms, true)` — it's an atomic stop+play in ONE BLE write so the firmware doesn't spawn two parser tasks. Pick a `ms` slightly longer than your refresh rate so each new pitch overrides cleanly.

```js
function setPitch(hz) {
  if (!window.stickemIsConnected()) return;
  window.sendBuzzerChangeTone(hz, 150, true);    // 150 ms refresh, atomic
}
setInterval(() => setPitch(440 + Math.sin(performance.now()/200) * 200), 80);
```

**Recipe 4 — Musical beats (note runs, melodies, success/fail jingles).** Set a BPM, then send notes by beat count. Use blocking `bg=false` if you want a melody to play through cleanly without being interrupted.

```js
function playSuccessJingle() {
  if (!window.stickemIsConnected()) return;
  window.sendBuzzerBPM(120);
  window.sendBuzzerNote(523, 0.5, false);  // C5 for half a beat (250 ms at 120 BPM)
  window.sendBuzzerNote(659, 0.5, false);  // E5
  window.sendBuzzerNote(784, 1.0, false);  // G5 for a full beat
}
```

**Buzzer-only apps.** If your project doesn't drive motors (sound art, metronome, etc.), call this right after `stickemConnect()`:

```js
window.stickemSetMotorKeepalive(false);   // stops the 200 ms `mv:` keepalive so the buzzer gets the BLE channel
```

### Disconnect Handling

Attach a `gattserverdisconnected` listener on the BLE device object. On disconnect: set internal state to disconnected, change the page background to red, and show a Reconnect button.

### ⚠️ Pitfalls Discovered the Hard Way (bake all of these into the scaffold)

These three patterns were learned by burning hours debugging real workshops. Every scaffold you generate must include all of them — they are not optional.

**§A. Stop must send the literal string `stopAll,` — NEVER `mv:1500:...`**

The firmware treats them differently. `stopAll,` halts the PWM signal entirely. `mv:1500:1500:...` says "drive every motor at PWM 1500" — which on continuous-rotation servos depends on each motor's mechanical neutral, and drifty motors will *creep slowly forever* at PWM 1500. So:

```js
window.stopAll = function () {
  desired.fill(1500);   // keep local state coherent
  pending = 'stopAll,'; // but actually halt the firmware
};
```

NOT this (it looks right and is subtly broken):
```js
// WRONG — produces "mv:1500:1500:..." which is NOT a halt command:
window.stopAll = function () { desired.fill(1500); rebuildPending(); };
```

**§B. The firmware has no connection-loss watchdog — guarantee the LAST command is a stop**

When the page reloads or the BLE channel drops, the firmware keeps executing whatever command it last heard. Forever. The only way to halt from JavaScript is to make sure the very last command on the wire before disconnect is `stopAll,`. The scaffold's normal send loop (25 ms dedup) is too lazy for this — by the time the next tick fires, the page is already closed. So:

1. Expose a `window.stickemPanicStop()` that writes `stopAll,` **directly** to `bleChar` 5× with 70 ms gaps, bypassing the dedup loop. This guarantees at least one lands even if a write is in flight or the page is unloading.
2. Wire BOTH `beforeunload` and `pagehide` to call `stickemPanicStop()`. Mobile Safari fires `pagehide` reliably; desktop Chrome fires `beforeunload`. Cover both.
3. Make the Stop button call `stickemPanicStop()`, not `stopAll()`.

```js
async function panicStop() {
  desired.fill(1500);
  pending = 'stopAll,';
  if (!bleChar || !connected) return;
  for (let i = 0; i < 5; i++) {
    // wait for any in-flight write to drain
    let waited = 0;
    while (writing && waited < 120) { await new Promise(r => setTimeout(r, 20)); waited += 20; }
    writing = true;
    try { await bleChar.writeValue(new TextEncoder().encode('stopAll,')); lastSent = 'stopAll,'; }
    catch (e) { /* log */ }
    finally { writing = false; }
    await new Promise(r => setTimeout(r, 70));
  }
}
window.stickemPanicStop = panicStop;
window.addEventListener('beforeunload', () => { try { panicStop(); } catch {} });
window.addEventListener('pagehide',     () => { try { panicStop(); } catch {} });
```

**§C. Rate-limit BLE writes to 20 Hz max, EMA-smooth CV signals, quantize PWM**

The original scaffold cadence (`~40 Hz max`) was too aggressive. BLE acknowledged-write round-trips take 25-50 ms; hammering `writeValue()` faster than the peripheral can drain causes the OS BLE stack to queue commands. Symptoms: the robot appears to lag, or processes stale commands several hundred ms after the user moved a hand / line / face. The triple fix is non-negotiable for any project that derives motor commands from a continuous input (camera, mic, motion sensor):

1. **Rate limit in the scaffold**: 50 ms minimum interval between BLE writes (20 Hz cap). Tracked via `lastSendAt`:
   ```js
   const MIN_SEND_INTERVAL_MS = 50;
   setInterval(() => {
     if (!connected || writing || !pending) return;
     const now = Date.now();
     if (now - lastSendAt < MIN_SEND_INTERVAL_MS) return;
     const due = now - lastForceMs >= 200;
     if (pending !== lastSent || due) { sendRaw(pending); lastSendAt = now; if (due) lastForceMs = now; }
   }, 25);
   ```
2. **EMA-smooth the input signal** (in the project, not the scaffold) before deriving PWM. α between 0.3-0.5 is the sweet spot — kills frame-to-frame jitter with ~40-60 ms of effective lag, which servos can't even respond to faster than:
   ```js
   smoothedErr = smoothedErr * (1 - 0.4) + rawErr * 0.4;
   ```
3. **Quantize PWM to multiples of 5** before calling `sendServos(...)` so identical-after-rounding commands dedup naturally:
   ```js
   const Q = 5;
   const leftPwm  = Math.round((1500 + leftOffset)  / Q) * Q;
   const rightPwm = Math.round((1500 + rightOffset) / Q) * Q;
   ```

Without all three, the BLE channel saturates with sub-detectable adjustments and the robot lags the input by hundreds of milliseconds.

---

## STEP 4 — Starter Scaffold (use this verbatim as your base)

Build the project as a **single self-contained `index.html`** file. Start from this scaffold and extend it. The scaffold handles:

- BLE connection + write guard + 40 Hz send loop
- **Per-motor desired-state tracking** so rapid-fire commands never overwrite each other (this is the fix for the #1 workshop bug where "I clicked but the robot didn't move")
- **200 ms keepalive** that re-sends the current motor state even when nothing changes, so a single dropped BLE packet does not freeze the build
- A `window.sendServos(...)` function for continuous speed control
- A `window.pulseServo(...)` helper for "spin for N ms then stop" actions, with per-direction calibration trim
- `window.sendBuzzerTone(hz, ms, background)`, `sendBuzzerNote(hz, beats, background)`, `sendBuzzerBPM(bpm)`, `sendBuzzerStop()`, `sendBuzzerChangeTone(hz, ms, background)` (atomic stop+play in one BLE write), and the escape-hatch `sendBuzzerCommand(raw)` for driving the on-board piezo. All numeric args are auto-wrapped in the firmware's Scratch-block `{(num:{N})}` format. One-shots queue separately from motor commands so they cannot starve each other.
- `window.stickemSetMotorKeepalive(false)` for buzzer-only apps that never call `sendServos` — silences the 200 ms `mv:` keepalive so buzzer commands have the BLE channel to themselves.
- A `window.mountCalibrationTool(servoCount, container)` function that auto-generates the Servo Calibration Tool UI for however many motors the project uses
- A **per-motor pulse lock** so the Calibration Tool's Test buttons fire even while the participant's project code is continuously calling `sendServos` every frame. When the project is "active," continuous control on a motor is paused for the duration of a calibration pulse, then resumes automatically.
- A **visible Debug Log panel** at the bottom of the page that shows every command actually transmitted (or errored), with timestamps — so participants can immediately tell whether a missing movement was a BLE problem, an event-handler problem, or a wiring problem
- `localStorage` persistence of calibration trims

**You must keep all the scaffold below intact, and you must call `window.mountCalibrationTool(N, container)` at the end of your project code with `N` = the number of motors in the build.**

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>My Stick 'Em App</title>
  <style>
    body { font-family: system-ui, sans-serif; padding: 24px; background: #f3f4f6; color: #111; transition: background 0.3s; }
    button { font-size: 16px; padding: 10px 16px; border-radius: 10px; border: 0; cursor: pointer; margin: 0 6px 6px 0; }
    .primary { background: #20a7d9; color: white; }
    .danger  { background: #ef4444; color: white; }
    .ghost   { background: #e5e7eb; color: #111; }
    #status { margin: 16px 0; font-size: 14px; color: #6b7280; }

    /* Calibration tool styling */
    #calibration { margin-top: 48px; padding-top: 24px; border-top: 2px dashed #d1d5db; }
    #calibration h2 { margin: 0 0 8px; }
    #calibration .why { background: #fef3c7; padding: 12px 16px; border-radius: 10px; font-size: 14px; color: #78350f; margin-bottom: 20px; }
    .cal-card { background: white; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; margin-bottom: 14px; }
    .cal-card h3 { margin: 0 0 8px; font-size: 16px; }
    .cal-row { display: flex; align-items: center; gap: 10px; margin: 6px 0; font-size: 14px; }
    .cal-row label { min-width: 140px; }
    .cal-row input[type=range] { flex: 1; }
    .cal-row .val { min-width: 50px; text-align: right; font-variant-numeric: tabular-nums; color: #6b7280; }

    /* Stick 'Em brand mark (top-right corner, on every project) */
    #stickem-logo {
      position: fixed; top: 14px; right: 16px; z-index: 9999;
      display: inline-flex; align-items: center; gap: 6px;
      padding: 6px 12px; border-radius: 999px;
      background: rgba(255, 255, 255, 0.92);
      color: #111; text-decoration: none;
      font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
      font-weight: 700; font-size: 13px; letter-spacing: 0.5px;
      box-shadow: 0 2px 6px rgba(0,0,0,0.18);
      transition: transform 0.15s ease, box-shadow 0.15s ease;
    }
    #stickem-logo:hover { transform: translateY(-1px); box-shadow: 0 4px 10px rgba(0,0,0,0.22); }
    #stickem-logo .dot { width: 8px; height: 8px; border-radius: 50%; background: #20a7d9; box-shadow: 0 0 0 2px #fff inset; }

    /* Progressive disclosure: dim everything until BLE is connected */
    body.not-connected .dim-until-connected { opacity: 0.25; pointer-events: none; filter: grayscale(0.7); transition: opacity 0.3s ease, filter 0.3s ease; }
    body:not(.not-connected) .dim-until-connected { opacity: 1; pointer-events: auto; filter: none; transition: opacity 0.4s ease, filter 0.4s ease; }

    /* Jiggle / shake — used as a "do this next" breadcrumb on the active button */
    @keyframes jiggle {
      0%   { transform: rotate(0) translateY(0); }
      15%  { transform: rotate(-1.8deg) translateY(-1px); }
      30%  { transform: rotate(1.8deg)  translateY(0); }
      45%  { transform: rotate(-1.2deg) translateY(-1px); }
      60%  { transform: rotate(1.2deg)  translateY(0); }
      75%  { transform: rotate(-0.6deg) translateY(0); }
      100% { transform: rotate(0)       translateY(0); }
    }
    .jiggle { animation: jiggle 1.4s ease-in-out infinite; transform-origin: center; }

    /* Debug log styling */
    #debug-panel { margin-top: 32px; background: #0f172a; color: #e2e8f0; border-radius: 10px; padding: 12px 16px; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; }
    #debug-panel summary { cursor: pointer; color: #94a3b8; user-select: none; }
    #debug-log { margin-top: 8px; max-height: 200px; overflow-y: auto; }
    .log-line { padding: 2px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .log-sent  { color: #6ee7b7; }
    .log-error { color: #fca5a5; }
    .log-info  { color: #93c5fd; }
  </style>
</head>
<body class="not-connected">
  <!-- Always-on Stick 'Em brand mark (top-right, links to stickem.sg) -->
  <a id="stickem-logo" href="https://stickem.sg" target="_blank" rel="noopener noreferrer" aria-label="Stick 'Em — open stickem.sg">
    <span class="dot"></span>Stick&nbsp;'Em
  </a>

  <h1>My Stick 'Em App</h1>

  <button id="connect" class="primary jiggle">Connect Board</button>
  <button id="stop" class="danger dim-until-connected">Stop</button>
  <div id="status">Not connected</div>

  <!-- =================================================== -->
  <!-- PROJECT-SPECIFIC UI GOES HERE                       -->
  <!-- (e.g. webcam preview, mic meter, text inputs)       -->
  <!-- =================================================== -->

  <!-- Calibration tool gets mounted here at the bottom    -->
  <div id="calibration"></div>

  <!-- Debug log: shows every command actually sent over BLE -->
  <details id="debug-panel" open>
    <summary>🐞 Debug Log — live view of every command sent over Bluetooth</summary>
    <div id="debug-log"></div>
  </details>

  <script type="module">
    const SERVICE = 0xFFE0;
    const TX_CHAR = 0xFFE1;

    // ---------- Source of truth: each motor's desired speed ----------
    // 1500 = stop. Anything else = spin at that PWM.
    // EVERY command sent to the firmware is a full snapshot of this array,
    // so rapid-fire updates can NEVER overwrite each other in a 25 ms tick.
    const desired = [1500, 1500, 1500, 1500, 1500, 1500, 1500, 1500];

    // Per-motor calibration / pulse lock: a future timestamp (ms epoch)
    // up to which `sendServos` will IGNORE writes to that motor. This is
    // what makes the Servo Calibration Tool's Test buttons actually fire
    // even when the participant's project code is hammering sendServos()
    // every animation frame. While a pulse is in flight on motor N, the
    // project's continuous control cannot overwrite it.
    const calibLockUntil = [0, 0, 0, 0, 0, 0, 0, 0];

    let bleChar     = null;
    let connected   = false;
    let pending     = null;   // current full-state mv command (string)
    let lastSent    = null;
    let writing     = false;
    let lastForceMs = 0;       // last time we resent for keepalive
    let lastSendAt  = 0;       // last successful write timestamp — used by the 20 Hz rate limit (§C)
    let oneShotPending = null; // one-shot non-motor command (buzzer); latest-wins, drained once and cleared

    const statusEl = document.getElementById('status');
    const logEl    = document.getElementById('debug-log');

    function logLine(text, kind = 'info') {
      if (!logEl) return;
      const t = new Date().toLocaleTimeString([], { hour12: false });
      const div = document.createElement('div');
      div.className = 'log-line log-' + kind;
      div.textContent = `[${t}] ${text}`;
      logEl.prepend(div);
      while (logEl.childElementCount > 30) logEl.removeChild(logEl.lastChild);
    }

    function rebuildPending() {
      pending = 'mv:' + desired.join(':') + ',';
    }

    document.getElementById('connect').onclick = connect;
    document.getElementById('stop').onclick    = () => window.stopAll();

    async function connect() {
      try {
        const device = await navigator.bluetooth.requestDevice({
          filters: [{ namePrefix: '⠀' }],
          optionalServices: [SERVICE],
        });
        device.addEventListener('gattserverdisconnected', () => {
          connected = false;
          statusEl.textContent = '⚠️ Disconnected — click Connect Board again';
          document.body.style.background = '#fecaca';
          document.body.classList.add('not-connected');
          document.getElementById('connect').classList.add('jiggle');
          logLine('BLE disconnected', 'error');
        });
        const server  = await device.gatt.connect();
        const service = await server.getPrimaryService(SERVICE);
        bleChar = await service.getCharacteristic(TX_CHAR);
        connected = true;
        statusEl.textContent = '✅ Connected: ' + device.name;
        document.body.style.background = '#bae6fd';
        // Progressive disclosure: lift the grey dim now that we're connected,
        // and stop the Connect button jiggling. If your project has a NEXT
        // step (e.g. an "Activate" button), add the .jiggle class to it here
        // so the participant's eye knows where to look next.
        document.body.classList.remove('not-connected');
        document.getElementById('connect').classList.remove('jiggle');
        logLine('Connected: ' + device.name, 'info');
        // Send a known-safe state immediately so the board starts stopped.
        desired.fill(1500);
        rebuildPending();
        startSendLoop();
      } catch (e) {
        statusEl.textContent = '❌ Connect failed: ' + e.message;
        logLine('Connect failed: ' + e.message, 'error');
      }
    }

    /**
     * Set the SPEED of one or more motors (continuous spin).
     * Pass values 1000-2000. 1500 = STOP. Further from 1500 = faster.
     * Pass null/undefined to LEAVE a motor unchanged
     * (it keeps its current desired speed; rapid concurrent calls
     * for different motors are safe — they merge, they don't fight).
     *
     *   sendServos(1700);                      // motor 1 forward, others untouched
     *   sendServos(1500, 1500, 1500);          // motors 1-3 stop
     *   sendServos(null, 1700);                // motor 2 forward, motor 1 untouched
     */
    window.sendServos = function (m1, m2, m3, m4, m5, m6, m7, m8) {
      const now = Date.now();
      [m1, m2, m3, m4, m5, m6, m7, m8].forEach((v, i) => {
        if (v === null || v === undefined) return;
        // If this motor is currently locked by a pulse (e.g. the
        // Calibration Tool's Test buttons), let the pulse finish.
        // The project's continuous control resumes the instant the
        // lock expires.
        if (calibLockUntil[i] > now) return;
        desired[i] = Math.max(1000, Math.min(2000, Math.round(v)));
      });
      rebuildPending();
    };

    /**
     * Stop every motor immediately.
     *
     * IMPORTANT (pitfall §A): we set `pending = 'stopAll,'` rather than rebuilding
     * an mv:1500:... command, because the firmware's "stopAll," is the explicit
     * halt — mv:1500 just says "drive all motors at PWM 1500" which on drifty
     * continuous-rotation servos can mean "creep slowly forever."
     */
    window.stopAll = function () {
      desired.fill(1500);
      pending = 'stopAll,';
    };

    /**
     * Buzzer helpers — one-shot commands for the on-board piezo. They go
     * through a small FIFO queue separate from the motor `pending`, so a
     * stop+play pair is preserved (older queued tones coalesce; stops
     * cannot be coalesced away). Two important format rules:
     *
     *   1. Numeric args MUST be wrapped as `{(num:{N})}` (Scratch-block
     *      text). Bare integers silently fail the firmware's parser.
     *   2. The bg flag is `{0}` or `{1}` (boolean-style), NOT wrapped in num.
     *
     * Public API:
     *
     *   window.sendBuzzerTone(hz, ms, background);
     *     // playToneDuration:{(num:{H})}:{(num:{M})}:{bg},
     *
     *   window.sendBuzzerNote(hz, beats, background);
     *     // playTone:{(num:{H})}:{(num:{B})}:{bg},
     *
     *   window.sendBuzzerBPM(bpm);
     *     // setBPM:{(num:{N})},
     *
     *   window.sendBuzzerStop();
     *     // stopAllSounds,
     *
     *   window.sendBuzzerChangeTone(hz, ms, background);
     *     // ATOMIC stop+play in one BLE write — the firmware parses both
     *     // commands on a single parser task, avoiding the task-spawn race
     *     // you get from sending stop and play as two separate writes.
     *
     *   window.sendBuzzerCommand(rawCmd);
     *     // Escape hatch: queue any string the firmware understands.
     *
     *   window.stickemSetMotorKeepalive(false);
     *     // Buzzer-only apps: disable the 200 ms mv: keepalive that would
     *     // otherwise burn BLE bandwidth competing with the buzzer.
     */
    const num = (n) => '{(num:{' + n + '})}';
    function pushOneShot(cmd, kind) {
      const full = cmd.endsWith(',') ? cmd : cmd + ',';
      if (kind === 'tone') {
        // Coalesce only adjacent tones; preserve any stop/cmd between them.
        const last = oneShotQueue[oneShotQueue.length - 1];
        if (last && last.kind === 'tone') { last.cmd = full; return; }
      }
      oneShotQueue.push({ cmd: full, kind });
      while (oneShotQueue.length > 4) oneShotQueue.shift();
    }
    window.sendBuzzerCommand = (cmd) => pushOneShot(cmd, 'cmd');
    window.sendBuzzerTone = function (hz, durationMs, background) {
      const h = Math.max(31, Math.min(20000, Math.round(hz)));
      const d = Math.max(1, Math.round(durationMs));
      pushOneShot('playToneDuration:' + num(h) + ':' + num(d) + ':{' + (background ? 1 : 0) + '}', 'tone');
    };
    window.sendBuzzerNote = function (hz, beats, background) {
      const h = Math.max(31, Math.min(20000, Math.round(hz)));
      pushOneShot('playTone:' + num(h) + ':' + num(beats) + ':{' + (background ? 1 : 0) + '}', 'tone');
    };
    window.sendBuzzerBPM = function (bpm) {
      const b = Math.max(20, Math.min(400, Math.round(bpm)));
      pushOneShot('setBPM:' + num(b), 'cmd');
    };
    window.sendBuzzerStop = function () { pushOneShot('stopAllSounds', 'stop'); };
    window.sendBuzzerChangeTone = function (hz, durationMs, background) {
      const h = Math.max(31, Math.min(20000, Math.round(hz)));
      const d = Math.max(1, Math.round(durationMs));
      pushOneShot('stopAllSounds,playToneDuration:' + num(h) + ':' + num(d) + ':{' + (background ? 1 : 0) + '}', 'tone');
    };
    window.stickemSetMotorKeepalive = (enabled) => { motorKeepalive = !!enabled; };

    /**
     * Panic stop (pitfall §B): guarantees the LAST command the firmware
     * received is "stopAll,". Use this from your Stop button AND from
     * beforeunload/pagehide handlers so the robot halts even when the page
     * reloads. The firmware has no connection-loss watchdog — whatever
     * command it last heard keeps running forever.
     */
    window.stickemPanicStop = async function () {
      desired.fill(1500);
      pending = 'stopAll,';
      if (!bleChar || !connected) return;
      for (let i = 0; i < 5; i++) {
        let waited = 0;
        while (writing && waited < 120) { await new Promise(r => setTimeout(r, 20)); waited += 20; }
        writing = true;
        try {
          await bleChar.writeValue(new TextEncoder().encode('stopAll,'));
          lastSent = 'stopAll,';
          logLine('panic stop #' + (i + 1), 'sent');
        } catch (e) {
          logLine('panic stop failed: ' + e.message, 'error');
        } finally { writing = false; }
        await new Promise(r => setTimeout(r, 70));
      }
    };
    window.addEventListener('beforeunload', () => { try { window.stickemPanicStop(); } catch {} });
    window.addEventListener('pagehide',     () => { try { window.stickemPanicStop(); } catch {} });

    /**
     * Pulse a single motor: spin in a direction for a duration, then stop.
     * Honours per-motor, per-direction calibration trim from localStorage.
     * Safe to call back-to-back or in parallel for different motors —
     * each motor has its own desired-state slot so concurrent pulses
     * cannot cancel each other's stops.
     *
     *   pulseServo(1, +1, 500);   // motor 1, forward, 500 ms
     *   pulseServo(2, -1, 300);   // motor 2, reverse, 300 ms
     *
     * Returns a Promise that resolves after the pulse completes.
     */
    window.pulseServo = function (servoIndex, direction, durationMs = 500) {
      const cal      = loadCal();
      const sIdx     = servoIndex - 1;
      const trimKey  = direction > 0 ? 'fwdTrim'  : 'backTrim';
      const speedKey = direction > 0 ? 'fwdSpeed' : 'backSpeed';
      const trim     = (cal[sIdx] && cal[sIdx][trimKey])  || 0;
      const pwmOff   = (cal[sIdx] && cal[sIdx][speedKey]) || 200;
      const actualMs = Math.max(20, Math.round(durationMs * (1 + trim / 100)));
      const pwm      = direction > 0 ? 1500 + pwmOff : 1500 - pwmOff;

      // Lock this motor against the project's sendServos overwrites
      // for the entire pulse + 80 ms tail so the calibration test
      // actually reaches the motor even while the project's main
      // control loop is running.
      calibLockUntil[sIdx] = Date.now() + actualMs + 80;

      desired[sIdx] = pwm;
      rebuildPending();

      return new Promise(resolve => {
        setTimeout(() => {
          desired[sIdx] = 1500;
          rebuildPending();
          // Release the lock 30 ms after the stop frame transmits.
          setTimeout(() => {
            calibLockUntil[sIdx] = 0;
            resolve();
          }, 30);
        }, actualMs);
      });
    };

    async function sendRaw(cmd) {
      if (!connected || !bleChar || writing) return false;
      writing = true;
      try {
        await bleChar.writeValue(new TextEncoder().encode(cmd));
        lastSent = cmd;
        logLine('sent: ' + cmd, 'sent');
        return true;
      } catch (e) {
        logLine('write error: ' + e.message, 'error');
        return false;
      } finally {
        writing = false;
      }
    }

    function startSendLoop() {
      // 20 Hz rate cap (pitfall §C). BLE acknowledged-write round-trips take
      // 25-50 ms. Hammering writeValue() faster causes the OS BLE stack to
      // queue commands, which shows up as "robot reacts to old inputs."
      // The setInterval still wakes at 25 ms so we react fast to changes,
      // but a successful write blocks the next one for at least 50 ms.
      const MIN_SEND_INTERVAL_MS = 50;
      setInterval(() => {
        if (!connected || writing) return;
        const now = Date.now();
        if (now - lastSendAt < MIN_SEND_INTERVAL_MS) return;
        // Drain a one-shot (buzzer) command first if one is queued. One-shots
        // are fired exactly once and cleared; the motor pending continues to
        // self-refresh every 200 ms via lastForceMs, so cutting in here cannot
        // make the motor stale for long.
        if (oneShotPending) {
          const cmd = oneShotPending;
          oneShotPending = null;
          sendRaw(cmd);
          lastSendAt = now;
          return;
        }
        if (!pending) return;
        const due = now - lastForceMs >= 200;  // keepalive every 200 ms
        if (pending !== lastSent || due) {
          sendRaw(pending);
          lastSendAt = now;
          if (due) lastForceMs = now;
        }
      }, 25);
    }

    // -------------------- CALIBRATION TOOL --------------------
    const CAL_KEY = 'stickemCalibration';
    function loadCal() {
      try {
        const raw = JSON.parse(localStorage.getItem(CAL_KEY) || '[]');
        const fresh = Array.from({ length: 8 }, (_, i) => ({
          fwdTrim: 0, backTrim: 0, fwdSpeed: 200, backSpeed: 200,
        }));
        for (let i = 0; i < Math.min(raw.length, 8); i++) {
          fresh[i] = Object.assign(fresh[i], raw[i] || {});
        }
        return fresh;
      } catch { return Array.from({ length: 8 }, () => ({ fwdTrim: 0, backTrim: 0, fwdSpeed: 200, backSpeed: 200 })); }
    }
    function saveCal(cal) { localStorage.setItem(CAL_KEY, JSON.stringify(cal)); }

    /**
     * Build the Servo Calibration Tool UI for `servoCount` motors.
     * Call this once at the end of your project setup:
     *   window.mountCalibrationTool(3, document.getElementById('calibration'));
     */
    window.mountCalibrationTool = function (servoCount, container) {
      const cal = loadCal();
      container.innerHTML = `
        <h2>Servo Calibration Tool</h2>
        <div class="why">
          <strong>Why this exists.</strong> The MG90S motors in your kit are continuous rotation —
          they do not know what angles are. They only know <em>direction</em>, <em>speed</em> and <em>duration</em>.
          And no two motors spin exactly the same amount in the same number of milliseconds —
          forward and backward on a single motor usually don't match either.
          Use the controls below to tune each motor until a forward pulse and a backward pulse
          travel the same distance. Settings persist in your browser.
        </div>
      `;
      for (let i = 1; i <= servoCount; i++) {
        const card = document.createElement('div');
        card.className = 'cal-card';
        card.dataset.servo = i;
        const c = cal[i - 1];
        card.innerHTML = `
          <h3>Motor ${i}</h3>
          <div class="cal-row">
            <label>Pulse length (ms)</label>
            <input type="range" min="100" max="2000" step="50" value="500" class="dur">
            <span class="val">500 ms</span>
          </div>
          <div class="cal-row">
            <label>Forward speed (PWM offset)</label>
            <input type="range" min="50" max="500" step="10" value="${c.fwdSpeed}" class="fwdSpeed">
            <span class="val">${c.fwdSpeed}</span>
          </div>
          <div class="cal-row">
            <label>Backward speed (PWM offset)</label>
            <input type="range" min="50" max="500" step="10" value="${c.backSpeed}" class="backSpeed">
            <span class="val">${c.backSpeed}</span>
          </div>
          <div class="cal-row">
            <label>Forward duration trim</label>
            <input type="range" min="-50" max="50" step="1" value="${c.fwdTrim}" class="fwdTrim">
            <span class="val">${c.fwdTrim}%</span>
          </div>
          <div class="cal-row">
            <label>Backward duration trim</label>
            <input type="range" min="-50" max="50" step="1" value="${c.backTrim}" class="backTrim">
            <span class="val">${c.backTrim}%</span>
          </div>
          <div class="cal-row">
            <button class="primary test-fwd">Test forward</button>
            <button class="primary test-back">Test backward</button>
            <button class="ghost test-cycle">Test forward → backward</button>
          </div>
        `;
        container.appendChild(card);

        const dur = card.querySelector('.dur');
        const valSpans = card.querySelectorAll('.val');
        const sliders = {
          fwdSpeed:  card.querySelector('.fwdSpeed'),
          backSpeed: card.querySelector('.backSpeed'),
          fwdTrim:   card.querySelector('.fwdTrim'),
          backTrim:  card.querySelector('.backTrim'),
        };
        function syncLabels() {
          valSpans[0].textContent = dur.value + ' ms';
          valSpans[1].textContent = sliders.fwdSpeed.value;
          valSpans[2].textContent = sliders.backSpeed.value;
          valSpans[3].textContent = sliders.fwdTrim.value + '%';
          valSpans[4].textContent = sliders.backTrim.value + '%';
        }
        function persist() {
          const idx = i - 1;
          cal[idx].fwdSpeed  = +sliders.fwdSpeed.value;
          cal[idx].backSpeed = +sliders.backSpeed.value;
          cal[idx].fwdTrim   = +sliders.fwdTrim.value;
          cal[idx].backTrim  = +sliders.backTrim.value;
          saveCal(cal);
        }
        [dur, sliders.fwdSpeed, sliders.backSpeed, sliders.fwdTrim, sliders.backTrim]
          .forEach(el => el.addEventListener('input', () => { syncLabels(); persist(); }));

        card.querySelector('.test-fwd').onclick   = () => window.pulseServo(i, +1, +dur.value);
        card.querySelector('.test-back').onclick  = () => window.pulseServo(i, -1, +dur.value);
        card.querySelector('.test-cycle').onclick = async () => {
          await window.pulseServo(i, +1, +dur.value);
          await new Promise(r => setTimeout(r, 200));
          await window.pulseServo(i, -1, +dur.value);
        };
      }
    };

    // =====================================================
    // PROJECT-SPECIFIC LOGIC GOES BELOW.
    //
    // When you produce a new SPEED for a motor, call:
    //   window.sendServos(m1, m2, ...);   // 1000-2000, 1500=stop
    //
    // When you want a one-shot "spin then stop" action, call:
    //   await window.pulseServo(servoIndex, +1 or -1, durationMs);
    //
    // AT THE VERY END of your setup, mount the calibration tool:
    //   window.mountCalibrationTool(N, document.getElementById('calibration'));
    // where N = the number of motors your project uses.
    // =====================================================

  </script>
</body>
</html>
```

---

## STEP 5 — UI/UX: Thematic, but Clear and Readable First

**Two non-negotiables for the first draft:**

1. **It must look thematic.** Colours, central visual, background, and accent fonts all serve the project's world. Plain grey + `system-ui` is a fail.
2. **It must be immediately understandable.** Readable body text, obvious "what do I press next," and clear functional labels. Tacky decorative fonts on body text, illegible cursive scripts, and copy that hides what the button actually does are all fails.

**The F1 console example is the bar.** Strong theme (carbon black, red, industrial dashboard), huge readable text, an obvious sequence of actions. Aim for that level of clarity in every project. The earlier Ouija and flower mock-ups were too tacky and the fonts were unreadable — that is what we are *not* doing.

### Typography Rules (read these carefully)

- **Body text = always a clean, readable sans-serif.** Use `Inter`, `Manrope`, `DM Sans`, `Work Sans`, or `Nunito Sans` for the page body. Never use a cursive, dripping, gothic, or pixel-grunge font for body copy, button labels, status messages, or the calibration tool. Bad fonts for body text include `Creepster`, `Pirata One`, `Nosifer`, `Pacifico`, `Caveat`, `Press Start 2P`, `Bungee` — they're decorative only.
- **Display / accent fonts are for the headline and the headline only.** Reserve thematic display fonts for the page title (`<h1>`), the giant central visual's caption, and *maybe* the main call-to-action button if the font is still legible at button size. Test by reading the headline at arm's length — if you'd squint, switch to a sturdier display font (e.g., `Orbitron`, `Bebas Neue`, `Cinzel`, `Bowlby One`).
- **Always set high contrast on text.** WCAG AA at minimum — light text on a dark themed background or vice versa. Use the browser's contrast eyedropper if in doubt.

### Recommended Theme Palettes & Fonts (these all keep readability)

| Theme | Display font (headlines only) | Body font | Palette |
|---|---|---|---|
| F1 console / sci-fi cockpit | `Orbitron`, `Rajdhani` | `Inter`, `Manrope` | Black / carbon, neon red `#ef4444`, lime telemetry `#22c55e` |
| Ouija board / séance | `Cinzel`, `IM Fell English SC` | `Inter` | Deep brown `#1a0f08`, parchment `#e8d9b8`, candle orange `#c2410c` |
| Cute flower / pastel | `Fraunces`, `DM Serif Display` | `Nunito Sans`, `Manrope` | Sky blue `#bae6fd`, blush `#fda4af`, mint `#bbf7d0`, lavender `#ddd6fe` |
| Robot dog | `Fredoka` (also OK for body if regular weight) | `Nunito Sans` | Tan `#d6b48a`, chocolate `#5b3a1d`, cream `#fff8ec` |
| Snake charmer | `Cinzel`, `Yatra One` | `Inter` | Sand `#e7c89c`, terracotta `#c2410c`, deep teal `#134e4a` |
| Eyeball / horror | `Cinzel` (in red), or `Bebas Neue` | `Inter` | Near-black, sclera off-white, vein-red `#dc2626` |
| Rock drumming | `Bebas Neue`, `Bowlby One` | `Work Sans` | Spotlight yellow `#facc15` on black |

### Button Label Rule — Theme Word + Bracketed Action

Every button label must communicate both the *flavour* and the *functional action*. Theme-only labels like "Summon the Spirits" are ambiguous — the user doesn't know if that connects, starts listening, or summons something else. Solve it by always appending the literal action in brackets:

- **"Summon the Spirits (Connect)"** — Ouija connect button
- **"Banish (Disconnect)"** — Ouija disconnect button
- **"Bloom (Connect)"** — flower connect
- **"Wilt (Stop)"** — flower stop
- **"Engage Telemetry (Connect)"** — F1
- **"Pit In (Stop)"** — F1 emergency stop
- **"Wake the Pup (Connect)"** — robot dog
- **"Activate Servos (Start)"** — generic next-step after connect
- **"Start Listening (Microphone)"** — voice projects

Style the bracket in a smaller weight or lighter colour if you want, but it must be present and readable in the first draft. The same rule applies to switches and links.

### Progressive Disclosure & The Jiggle Breadcrumb (from humanoid.stickem.sg)

The scaffold already includes everything you need to use the exact UX pattern from `humanoid.stickem.sg`. **Use it.** It is what makes the workshop's projects feel polished.

How it works:

1. **The Connect button starts with `class="jiggle"`** — a gentle rotating shake animation. The participant's eye immediately knows where to start.
2. **When the participant clicks Connect and BLE pairs**, the scaffold fires its connect event. Your project listens with `window.stickemOnConnect(...)` and moves the jiggle to whatever the *next-step* button is (e.g., "Start Listening", "Start Camera", "Activate Servos").
3. **When that next button is clicked**, you remove the jiggle by calling `window.setJiggleTarget(null)` — or pass another selector if there are *more* steps after it (e.g., RPS goes Connect → Start Camera → Throw).
4. **There must only ever be one button jiggling at a time.** `setJiggleTarget` strips `.jiggle` from any previously-animating button before adding it to the new one.

So the participant's experience is: page loads with one shaking button → click → next shaking button → click → things start happening. Always *one* obvious thing to do next.

What you write in your project code:

```html
<!-- The initial Connect button starts jiggling immediately -->
<button id="connect" class="primary jiggle">Connect Board</button>
<button id="activate" class="primary">Activate Servos (Start)</button>
```

```js
// After BLE pairs, the scaffold fires the connect event — move the jiggle onward.
window.stickemOnConnect(() => window.setJiggleTarget('#activate'));

// When the participant clicks the next button, stop the jiggle (or chain it to a third button).
document.getElementById('activate').addEventListener('click', () => {
  window.setJiggleTarget(null);
  // …start the project loop…
});
```

For a multi-step flow (e.g. Connect → Start Camera → Throw), keep chaining:

```js
window.stickemOnConnect(() => window.setJiggleTarget('#cam'));
document.getElementById('cam').addEventListener('click',  () => window.setJiggleTarget('#play'));
document.getElementById('play').addEventListener('click', () => window.setJiggleTarget(null));
```

The scaffold's CSS already declares the `@keyframes stickem-jiggle` and the `.jiggle` class. Do not redeclare them. Just use the API above.

### Background Colour Should Reflect State

Mirror the humanoid.stickem.sg approach — the background colour itself communicates state at a glance:

- **Not connected:** desaturated thematic background (e.g., a muted grey-brown for Ouija, dim charcoal for F1). The theme is *visible* but feels asleep.
- **Connected:** full thematic colour. The page "lights up."
- **Project actively running:** subtle accent (e.g., a glowing edge, a tinted vignette, a "REC" indicator).
- **Unexpectedly disconnected:** background goes light red and the Reconnect button jiggles.

### Layout & Visual Hierarchy

- **Make the central thematic visual huge** — an enormous emoji, inline SVG, or `<img>` front and centre on first load. The user should see it the moment the page loads.
- **Background must reinforce the theme** — a CSS gradient, an SVG pattern (e.g., `https://www.transparenttextures.com/`), a free Unsplash image (`https://images.unsplash.com/...`), or repeating thematic icons. **But do not let the background hurt the body text contrast** — a busy texture often needs a semi-transparent dark or light panel underneath the text.
- **Group controls by purpose.** Connection controls top. Project controls middle. Calibration tool at the bottom (the scaffold already does this — leave it).
- **Theme the Debug Log header copy** if you like (`Crystal Ball`, `Pit Crew Radio`, `Doggy Diary`) but **leave its monospaced dark styling alone**. It's a debug surface; legibility wins over theme.

### Concrete Implementation Pattern (drop into `<head>`)

```html
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&family=Inter:wght@400;600&display=swap" rel="stylesheet">
<style>
  :root {
    --bg-base:     #0a0a0a;
    --bg-accent:   #1a0a0a;
    --fg:          #f4f4f5;
    --fg-muted:    #a1a1aa;
    --accent:      #ef4444;          /* signal red */
    --accent-2:    #22c55e;          /* telemetry green */
    --font-display:'Orbitron', sans-serif;
    --font-body:   'Inter', system-ui, sans-serif;
  }
  body {
    background: radial-gradient(circle at top, var(--bg-accent), var(--bg-base));
    color: var(--fg);
    font-family: var(--font-body);    /* readable body */
    font-size: 16px; line-height: 1.5;
  }
  h1 { font-family: var(--font-display); font-size: 2.4rem; letter-spacing: 3px; color: var(--accent); text-transform: uppercase; }
  /* Theme the scaffold's buttons */
  button.primary { background: var(--accent); color: #fff; font-family: var(--font-body); font-weight: 600; letter-spacing: 0.5px; }
  button.danger  { background: #f59e0b; color: #1a0a0a; font-weight: 700; }
  /* Theme #status, headings, etc — but keep body text in --font-body */
</style>
```

### Keyboard Shortcuts: Space, Escape, and Other Single-Key Triggers

Every project that has a primary start/pause action **must** also accept the spacebar as that trigger, and every project that uses a fullscreen mode **must** accept `Escape` to exit. Single-key bindings are how presenters drive a Stick 'Em demo — they cannot click while holding the robot.

**Mandatory bindings:**

- `Space` → play / pause / stop the active alarm (one key, context-sensitive based on app state)
- `Escape` → exit fullscreen if currently fullscreen

**Optional but encouraged** for projects with a primary toggle (start / stop / record / fire): keep the spacebar as the shortcut even if you also add a button.

**Drop this into every project's `<script type="module">` block.** It's about 20 lines and covers every workshop project that has a "play / pause / stop alarm" flow:

```js
document.addEventListener('keydown', (e) => {
  // Ignore the key when the user is typing into a number/text input.
  if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;

  if (e.code === 'Space' || e.key === ' ') {
    e.preventDefault();                       // stop the page from scrolling
    if (alarming)      stopAlarm(false);
    else if (running)  pauseCountdown();
    else               startCountdown();
    return;
  }
  if (e.key === 'Escape' && document.fullscreenElement) {
    (document.exitFullscreen || document.webkitExitFullscreen).call(document);
  }
});
```

**Gotcha — focused buttons re-fire on space.** After clicking ▶ Start, the button stays focused and pressing Space would *also* click it (because Space activates focused buttons by default). To prevent double-firing, blur the action buttons in their click handlers:

```js
startBtn.onclick = () => { startCountdown(); startBtn.blur(); };
pauseBtn.onclick = () => { pauseCountdown(); pauseBtn.blur(); };
stopAlarmBtn.onclick = () => { stopAlarm(false); stopAlarmBtn.blur(); };
```

**Tell the user about the shortcuts in the UI.** Add a small `<kbd>`-styled hint strip near the controls — the keyboard shortcut isn't discoverable otherwise:

```html
<div class="kbd-hint">
  <kbd>Space</kbd> play / pause / stop alarm &nbsp;·&nbsp; <kbd>Esc</kbd> exit fullscreen
</div>
```

### Quick Sanity Checks Before You Hand Over

1. **At arm's length, can you still read every button label and the status text?** If no, switch the body font.
2. **Does every button label include a bracketed action verb** (e.g., `(Connect)`, `(Stop)`, `(Start)`, `(Disconnect)`)?
3. **Before connect, is exactly ONE thing on the page interactive and visibly shaking** (the Connect button with `.jiggle`)? Is everything else dimmed via `.dim-until-connected`?
4. **After connect, has the dim lifted, has the Connect button stopped jiggling, and is the next-step button now jiggling**?
5. **Would a stranger glancing at the page guess the project's theme within two seconds?** If no, push the theme further (bigger central visual, more saturated palette, thematic display font on the headline). If yes — and the text is still readable — you're done.

---

## STEP 6 — Implementation Guidelines

When you write the project-specific code, follow these rules:

- **Single file.** Everything in `index.html`. No build step, no `npm`, no bundler.
- **CDN only.** Load libraries (MediaPipe, TensorFlow.js, etc.) via `<script>` or ES module `import` from `cdn.jsdelivr.net` or `unpkg.com`.
- **Use the right helper for the right job:**
  - **Continuous, reactive motion** (e.g., snake swaying to music, eyeball tracking face) → use `sendServos(...)` with PWM speeds (1500 = stop).
  - **One-shot actions** (e.g., morse tap, hat tip, drumstick beat, flower petal opening) → use `pulseServo(servoIndex, direction, ms)`. It applies calibration trim automatically.
  - **Buzzer / sound output** (theremins, alarms, melodies, beat-driven projects) → use `sendBuzzerTone(hz, ms, background)` for raw tones, `sendBuzzerNote(hz, beats, background)` + `sendBuzzerBPM(bpm)` for musical timing, and `sendBuzzerStop()` to silence. Always pass `background=true` for continuous-update apps so the next tone can override; reserve `background=false` for rare cases where you want the firmware to block until the note ends. The piezo has no volume control — implement "volume" as an on/off gate, not amplitude.
- **Never construct `mv:...,` strings yourself elsewhere in the code.** Always go through `sendServos` or `pulseServo`. Same rule for buzzer commands — never build `playToneDuration:...,` strings by hand, go through `sendBuzzerTone` / `sendBuzzerNote` / `sendBuzzerStop`.
- **Do not try to manage command flow yourself.** The scaffold already handles per-motor desired state, deduplication, write-guarding, the 25 ms transmit loop, and a 200 ms keepalive resend so dropped BLE packets self-heal. Just call `sendServos` / `pulseServo` whenever your logic dictates — even ten times in the same millisecond — and trust the scaffold to merge them correctly. Do **not** add your own debouncing, throttling, "is this different from last time" checks, or `setInterval` re-senders around these calls. Doing so re-introduces the very bug the scaffold was designed to prevent (commands silently lost between ticks).
- **Trust the Debug Log to tell you if it sent.** If a participant reports "I clicked but nothing happened," the first instruction is always: *"Look at the Debug Log panel at the bottom — does your command appear there?"* If yes, the issue is hardware. If no, the issue is in their event handler not actually invoking `sendServos` / `pulseServo`.
- **Talk in time, never in degrees.** When explaining motion to the user, say *"spin forward for about 400 ms"* — never *"rotate 45 degrees."*
- **Smooth jittery inputs** with exponential moving average before sending to `sendServos`:
  ```js
  current = current * 0.8 + target * 0.2;
  ```
- **Start gentle.** First demo should keep PWM offsets within `±200` of 1500 (so `1300–1700`) until the participant has tuned their build.
- **Add a visible STOP button** wired to `window.stopAll()`. Cheap motors at full speed can hurt fingers if they swing into them.
- **Show what's happening on screen.** Mini meters or text readouts for each motor's current speed help the participant debug.
- **No browser storage APIs** unless the user asks — `localStorage` is fine if they want settings persistence (the calibration tool already uses it).

---

## STEP 7 — Servo Calibration Tool (MANDATORY in every project)

**Every project you build must include the Servo Calibration Tool at the bottom of the page.** It is already in the scaffold. You just need to call `window.mountCalibrationTool(N, container, { labels })` at the end of your project code, where `N` is the number of motors the participant told you they have wired up.

```html
<div id="calibration"></div>
```

```js
// At the end of your project setup:
window.mountCalibrationTool(4, document.getElementById('calibration'), {
  labels: ['left front paw', 'right front paw', 'left back paw', 'right back paw'],
});
```

**Always pass `labels`** — a short noun phrase per motor describing what it controls on the physical build (e.g. "left tread", "front-left petal", "rock arm"). The tool renders them as faded suffixes next to "Motor 1", "Motor 2", etc. so the participant always knows which physical part each slider corresponds to. Bare "Motor 1 / Motor 2 / Motor 3" with no labels is a fail — by the time the participant is debugging an asymmetric motor at 11 pm, they will not remember which servo was which.

### Why It Exists (explain this to the user in chat — do not skip)

When you summarise the project for the user before coding, **and again after the code is written**, explicitly tell them in plain words:

> "The motors in the Stick 'Em kit are continuous rotation servos. They don't know what an angle is — they only know how fast to spin and for how long. Even more annoying: no two motors spin the same amount at the same PWM, and **clockwise usually doesn't match anticlockwise** on a single motor.
>
> That's why your page has a Servo Calibration Tool at the bottom. There is one card per motor with **one trim slider** (range -80 to +80). The slider biases the asymmetry:
>
> - **Push it RIGHT (positive trim)** to slow the clockwise direction.
> - **Push it LEFT (negative trim)** to slow the anticlockwise direction.
> - **Leave it at 0** if the motor is already balanced.
>
> To calibrate: click **Test forward → backward**. If the motor returns to where it started, you're calibrated. If the clockwise direction over-travels, push the slider RIGHT a few clicks and try again. If the anticlockwise direction over-travels, push it LEFT. Your settings save automatically in your browser."

This single-slider model matches the `humanoid.stickem.sg` calibration tool — same `applyTrim()` math, same UX. Do not bring back per-direction trim/speed sliders; that overwhelms the participant.

### How the Calibration Tool Works Internally

- Reads / writes `localStorage['stickemCalibration']` as a JSON array of 8 objects: `{ trim: <-80..+80> }`. Default trim is 0.
- The scaffold applies trim **once, on the way out** — every `sendServos` / `pulseServo` call has trim re-applied in `rebuildPending()`. You never call `applyTrim` yourself.
- `applyTrim(pwm, trim)`: if `(pwm - 1500)` and `trim` share a sign, the offset is scaled by `(1 - |trim|/100)`; otherwise the motor is left untouched. So a trim of +50 cuts clockwise speed in half; -30 cuts anticlockwise speed by 30%.
- The "Test forward / Test backward / Test cycle" buttons inside the tool call `pulseServo` directly so the user can see the trim take effect live.

### What You Must Tell the User in Chat

When you finish writing the code, your final message to the user should include:

1. A reminder that the motors are continuous-rotation and the duration in milliseconds is the knob, not "degrees."
2. A short paragraph about the calibration tool: one slider per motor, push right to slow clockwise, push left to slow anticlockwise, click Test forward → backward to check.
3. A specific suggestion of which value to tweak if the first test of the project looks off (e.g., "if the snake sways further left than right, push motor 1's trim slider to about -20 and retest").

---

## STEP 7.5 — App-Specific Calibration Settings (MANDATORY in every project)

**Beyond the Servo Calibration Tool, every project must include an "App Settings" panel that exposes the most important *project-specific* tunables as live sliders or pickers, persisted to `localStorage`.**

The Servo Calibration Tool only tunes the *motors*. What makes the project actually feel right is its **input/output mapping** — and that mapping is different for every project. The participant needs to be able to tweak those without editing code.

### How to Pick the Settings

When you finish interviewing the participant (Step 1), look at the *input* and *output* they described and ask yourself: *"What knob, if turned, would make a stranger using this project for the first time go from 'this barely reacts' to 'this is too sensitive'?"* Those are the settings. Aim for **3–5 sliders or pickers** — fewer than 3 is too rigid; more than 5 is overwhelming.

Common categories, with concrete examples from the showcase projects:

| Project type | Typical app-specific settings |
|---|---|
| **Mic / music reactive** (sound flower, EQ, lip-sync puppet) | Mic sensitivity (multiplier on RMS), max motor speed (PWM offset cap), wobble/animation frequency |
| **Body pose / hand tracking** (F1 hand wheel, paper hand mirror, phone tilt steering) | Sensitivity multiplier on the input axis, deadzone (degrees / pixels), throttle / max output power, smoothing factor |
| **Voice command** (robot pet, hat-tipping gentleman) | Drive speed (PWM offset), action-duration multiplier, voice-matching strictness (strict vs fuzzy), confirmation phrase |
| **Game with countdown** (rock-paper-scissors, drumming sticks) | Countdown length (3 / 5 / 10), tick speed, difficulty (% the robot "cheats"), pulse duration of the reaction servo |
| **LLM / oracle / magic 8-ball** | Response timeout, randomness / temperature analogue, needle pulse length, retry count |
| **Object / face / colour tracking** | Detection confidence threshold, deadzone around centre, tracking-loss timeout, max output speed |
| **Phone tilt / shake** | Tilt scale, deadzone in degrees, shake threshold, output speed cap |

If a participant says "make it less twitchy" or "it's too slow" or "it triggers on every little noise" during the demo, the fix should be in **this panel**, not in the source code.

### How to Mount the App Settings Panel

The scaffold ships with two helpers — use them, do not roll your own:

```html
<div id="app-settings"></div>
```

```js
// 1. Declare the settings + defaults. Persists to localStorage automatically.
const settings = window.useAppSettings('your-project-key', {
  micSensitivity: 4,
  maxSpeed: 200,
  countdownLength: 3,
});

// 2. Build the UI from a small schema. Sliders ('range') and pickers ('pills') are supported.
window.mountAppSettings(
  document.getElementById('app-settings'),
  settings,
  [
    { key: 'micSensitivity', label: '🎤 Mic sensitivity', type: 'range',
      min: 1, max: 10, step: 0.5, format: v => v.toFixed(1) + '×' },
    { key: 'maxSpeed', label: '🌸 Max motor speed (PWM offset)', type: 'range',
      min: 50, max: 400, step: 10, format: v => v + ' PWM' },
    { key: 'countdownLength', label: '⏱ Countdown length', type: 'pills', options: [
      { label: '3 · 2 · 1', value: 3 },
      { label: '5 · 4 · …', value: 5 },
      { label: '10 · 9 · …', value: 10 },
    ]},
  ],
  { title: '⚙️ App Settings', hint: 'Tune how the project responds to you. Saves to your browser.' }
);

// 3. READ the live values straight from `settings` inside your loop.
//    Do NOT cache them at startup — the participant changes them mid-run.
function tick() {
  const amp = rawAmp * settings.micSensitivity;       // live read
  const pwm = 1500 + Math.round(amp * settings.maxSpeed);
  window.sendServos(pwm, pwm, pwm, pwm);
}
```

### Rules

1. **Always read settings live from the proxy.** The user is going to slide while the project is running — that is the whole point. Caching the value into a local at `init()` time defeats the panel.
2. **Sensible defaults that feel good out of the box.** A stranger should be able to ignore the panel entirely on the first run and still see the project work. **For audio/sensor-reactive projects, default sensitivity to or near the slider's maximum.** Mic-driven projects in particular hear far less amplitude than the laptop speaker volume suggests — start them aggressive.
3. **Label with what the user feels, not what the variable is called.** "🎤 Mic sensitivity" beats `micGain`. "⏱ Countdown length" beats `cdN`. Add a unit suffix in `format` (`'×'`, `'°'`, `'%'`, `' ms'`, `' PWM'`) so the number on screen is self-explanatory.
4. **Persist per project, not globally.** Use a unique `projectKey` for each project (e.g., `'reactive-flower'`, `'f1-hand-wheel'`). The helper namespaces `localStorage` automatically — do not share keys across projects.
5. **Do not duplicate the servo calibration tool.** Forward/backward PWM offsets and duration trims belong in the Servo Calibration Tool (Step 7), not here. App Settings are about the *input-to-output mapping*, not about motor asymmetry.
6. **Pick 3–5 settings, not 15.** If you find yourself adding "is the moon full?" toggles, you have lost the plot. Cut to the actual knobs that change how the project *feels*.

### Reset to Defaults (auto-added by the scaffold — do not roll your own)

`window.mountAppSettings(...)` automatically appends a **Reset to defaults** button at the bottom of the settings panel. Clicking it confirms with the user, clears the project's localStorage entry, and reloads the page — so every slider, pill, checkbox, and theme returns to the values declared in the `useAppSettings(projectKey, defaults)` defaults object.

You do **not** need to add a reset button yourself. Just make sure:

- Your `useAppSettings` defaults object lists every key the project relies on, with sensible "fresh install" values. The reset returns to *those exact* values.
- Any custom UI you build outside `mountAppSettings` (theme pickers, mode toggles) reads its initial state from `settings.xxx` so the post-reload re-initialisation picks it up automatically.
- If your project is the rare standalone one that does NOT use `mountAppSettings` (because it has its own settings UI), add an equivalent reset button manually — same confirm dialog, same clear-localStorage-and-reload pattern:

  ```js
  resetBtn.addEventListener('click', () => {
    if (!confirm('Reset all settings on this page back to their defaults?\n\nThe page will reload.')) return;
    localStorage.removeItem('your-project-localstorage-key');
    location.reload();
  });
  ```

Why a reset button is mandatory: workshop participants slide knobs at random and then can't get the project back to a working state. Without a one-click reset, they reload the tab and lose their changes anyway — but slower and with more frustration.

### Per-Motor Reverse Checkboxes (when the project has multiple motors)

If the project drives 3 or more motors that the participant could plausibly wire backwards (paws, treads, axes), include a checkbox group in the App Settings so they can flip any motor's direction without rewiring or rewriting code. The scaffold supports this via the `type: 'checkboxes'` schema entry:

```js
const settings = window.useAppSettings('robot-pet', {
  reverseM1: false, reverseM2: false, reverseM3: false, reverseM4: false,
});

window.mountAppSettings(container, settings, [
  // …other settings…
  { type: 'checkboxes', label: '🔁 Reverse motor direction (tick if wired backwards)', options: [
    { key: 'reverseM1', label: 'Motor 1 (left front paw)'  },
    { key: 'reverseM2', label: 'Motor 2 (right front paw)' },
    { key: 'reverseM3', label: 'Motor 3 (left back paw)'   },
    { key: 'reverseM4', label: 'Motor 4 (right back paw)'  },
  ]},
]);

// Use a thin wrapper around pulseServo / sendServos that flips direction per the checkbox.
function pulse(motor, dir, ms) {
  const flip = settings['reverseM' + motor] ? -1 : 1;
  return window.pulseServo(motor, dir * flip, ms);
}
function driveAll(m1, m2, m3, m4) {
  const vals = [m1, m2, m3, m4].map((v, i) =>
    v == null ? null : (settings['reverseM' + (i+1)] ? 3000 - v : v));
  window.sendServos(...vals);
}
```

**Why this matters:** participants wire motors by feel, not by reading a schematic. About 1 in 3 motors ends up wired backwards on the first try. Without these checkboxes, "sit" lifts the robot's rump and "stand" plants it — and the participant gives up because they don't know whether the bug is in the code or the wiring.

**Do not apply the reverse checkbox to the calibration trim** — calibration trim addresses *asymmetric* speed (CW vs CCW on the same motor), reverse checkboxes address *swapped* polarity (the participant connected red and black backwards). They are independent.

### Sustained Reactivity: Slow Continuous Pulse, Not Twitchy Jitter

For any project that listens to a continuous input (mic level, hand tilt, brightness, etc.) and drives motors in proportion, **do not pipe the raw input straight to the servos.** That produces a twitchy build that lurches at every spike of noise and looks broken even when the code is correct.

Instead, layer three things:

1. **Heavy low-pass smoothing on the raw input.** `smoothed = smoothed * 0.92 + raw * 0.08` is a good starting point (~0.5 s time constant at 60 fps). Slower than `0.7 * old + 0.3 * new` — you want the build to *glide*, not chase every transient.
2. **A slow continuous oscillator that modulates the smoothed amplitude.** `breathe = 0.5 + 0.5 * Math.sin(t * 2π * freq)` at 0.3–1 Hz is a slow, perceptible "breath". Multiply this against the smoothed input so the build pulses *gently* even when the input is steady, and pulses *bigger* when the input is loud.
3. **A CSS transition on the visual element**, so the on-screen graphic glides between states even if your JS update rate is irregular: `.petal { transition: transform 0.45s cubic-bezier(.4,.0,.3,1); }`.

The output should feel like the build is *breathing* with the music, not seizing.

### One-Shot Actions Must Return to Rest

If a motor pulses to a position to "show" something — e.g. rock-paper-scissors throws an arm out, a hat-tipping gentleman lifts his hat, a magic 8-ball needle swings to YES — **the same motor must return to its rest position** after the action, otherwise the next round starts with the arm/hat/needle still extended and the gesture is invisible.

Two safe patterns:

```js
let lastFiredServo = null;
let resetTimer = null;

async function fireAction(servoIndex, ms) {
  // 1. If a previous arm is still extended, snap it home first.
  if (resetTimer) { clearTimeout(resetTimer); resetTimer = null; }
  if (lastFiredServo != null) {
    const s = lastFiredServo; lastFiredServo = null;
    await window.pulseServo(s, -1, ms);
  }
  // 2. Fire the new action.
  await window.pulseServo(servoIndex, +1, ms);
  lastFiredServo = servoIndex;
  // 3. Schedule an auto-return ~3 s later so the build resets on its own
  //    if the participant doesn't immediately start another round.
  resetTimer = setTimeout(() => {
    if (lastFiredServo != null) {
      window.pulseServo(lastFiredServo, -1, ms);
      lastFiredServo = null;
    }
  }, 3000);
}
```

The reverse pulse can use the same duration as the forward pulse, because the Servo Calibration Tool already balances CW vs CCW asymmetry.

### Real-World Reference for Movement Design

When the participant says "I want a robot dog that sits / stands / shakes / walks", do not invent motion primitives from scratch. Look up how a real quadruped robot (Boston Dynamics Spot, Unitree Go) performs those exact commands and translate them into pulses:

- **Sit** → back legs reverse to fold; front legs hold. Rump goes down.
- **Stand** → all four legs extend together. Rump comes up.
- **Shake** → one front paw lifts and waves while the other three hold still.
- **Forward / Back** → diagonal-pair trot (front-left + back-right, then front-right + back-left). *Not* all four legs in lockstep — that's a hop, not a walk.
- **Spin in place** → left side legs forward, right side legs reverse (or vice versa).
- **Dance** → a wave / ripple around the four paws, not a chaotic flailing.

Similar rule for any biomimicry build: a snake charmer should sway the way a real snake does (head and tail moving out of phase), a flower should bloom from a calyx outward (not all petals pivoting from a single point), a face should raise eyebrows symmetrically. Five minutes of YouTube reference saves an hour of "why does this look weird?"

### Auto-Start Sensors; Keep the Manual Button as a Backup

If the project needs the webcam or microphone, **start the sensor on `window.load` automatically.** Do not make the participant click "Start Camera" before they can do anything. Leave the Start button in the UI as a backup in case the auto-start failed (permission was denied, model fetch timed out, the browser blocked autoplay, etc.).

```js
document.getElementById('cam').onclick = () => camStream ? stopCam() : startCam();

// Auto-start on load; the button stays as a fallback.
window.addEventListener('load', () => { startCam().catch(() => {}); });
```

For mic-driven projects, the same pattern applies, but be aware that some browsers require a user gesture before granting audio permission. If the auto-start throws, leave the button jiggling.

### Layout: Big Visuals Go Left, Controls Stay Visible

If your project has a large central visual (a camera feed, a 2D board, a giant flower), do **not** stack it at the top with controls below — the controls will be pushed off the fold and the participant will think the project is broken. Use a two-column grid: visual on the **left**, controls (Connect, Start, Stop, app-specific buttons) stacked vertically on the **right**. Collapse to a single column at `max-width: 880px` for tablets and below.

```css
.layout {
  display: grid;
  grid-template-columns: minmax(280px, 480px) minmax(280px, 1fr);
  gap: 24px;
  align-items: start;
}
@media (max-width: 880px) {
  .layout { grid-template-columns: 1fr; gap: 16px; }
}
```

If the visual must be centred and tall (rare), at minimum ensure the primary action button is **above** the visual or in a corner overlay, not buried under a 640 px square that pushes everything off the bottom of the screen.

### Mobile Friendliness Is Not Optional

Every project must include `<meta name="viewport" content="width=device-width, initial-scale=1" />` in `<head>` and a `@media (max-width: 600px)` block that:

- Shrinks the page title (e.g. `h1 { font-size: 2rem !important; }`)
- Caps oversized visuals using `clamp()` or `min()` (`font-size: clamp(6rem, 22vw, 11rem)` for emoji centrepieces; `width: min(360px, 88vw)` for SVGs)
- Tightens button padding/font so the controls fit on one row on a 360 px screen
- Reduces body padding to ~`8px 16px 16px` so content reaches closer to the screen edges

The scaffold's shared chrome (logo bar, calibration tool, debug panel) already has its own mobile media query. You only need to add one for the *project-specific* UI.

### What You Must Tell the User in Chat

When summarising the finished project, include:

- A one-line list of the App Settings you added and what each one does.
- A specific recommended starting point if the project feels wrong on first try — for example: *"if the flower barely moves, bump 'Mic sensitivity' up to 6×; if the F1 car oversteers, drop 'Steering sensitivity' to 0.7× and add 3° of deadzone."*

---

## STEP 8 — Helpful Browser APIs by Input Type

| Input | Browser API | Notes |
|---|---|---|
| Microphone volume / lip-sync | `getUserMedia({ audio: true })` + Web Audio `AnalyserNode` → `getByteTimeDomainData()` for RMS, or `getByteFrequencyData()` for FFT | Free, built-in, no library. |
| Speech recognition (words) | `webkitSpeechRecognition` | Chrome only. |
| Text to speech (robot talks) | `speechSynthesis.speak(new SpeechSynthesisUtterance(text))` | Built in. To lip-sync the robot's own voice, pipe `speechSynthesis` through an `AudioContext` via a `MediaStreamAudioDestinationNode`. |
| Webcam | `getUserMedia({ video: true })` into a `<video>` element | Required for any vision project. |
| Body pose | `@mediapipe/tasks-vision` → `PoseLandmarker` | Loaded from `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.21`. |
| Hand tracking | `@mediapipe/tasks-vision` → `HandLandmarker` | Up to 2 hands, 21 landmarks each. Finger bend = compute the bend amount between MCP→PIP and PIP→TIP vectors, then map to motor speed/duration. |
| Face expressions | `@mediapipe/tasks-vision` → `FaceLandmarker` with `outputFaceBlendshapes: true` | Gives 0–1 scores for `jawOpen`, `mouthSmileLeft`, `browInnerUp`, `eyeBlinkLeft`, etc. Easier than landmark math. |
| Generic object detection | TensorFlow.js + `@tensorflow-models/coco-ssd` | Detects 80 common objects. |
| Colour tracking | Manual canvas pixel loop with RGB→HSV thresholding | Lightweight, no model needed. |
| Phone tilt | `DeviceOrientationEvent` (`beta` front/back, `gamma` left/right) | **iOS Safari does not support Web Bluetooth at all** — useful only with Android Chrome or *Bluefy* on iOS. Sensor permission must be requested inside a user click on iOS. |
| Phone shake | `DeviceMotionEvent` — magnitude of `acceleration` vector | Same iOS caveats as above. |
| Music FFT | Web Audio `createMediaElementSource(<audio>)` + `AnalyserNode` | For beat detection, track rolling average energy and threshold on ~1.4× average. |
| Gamepad | `navigator.getGamepads()` | Workshop participants are **unlikely to have controllers** — confirm before you build around this. |
| LLM-generated motion | `fetch` to user's chosen API (OpenAI, Claude, etc.) with structured JSON output | Have the LLM emit a sequence of `{ servo, direction, ms }` pulses — never angles. |

---

## STEP 8.5 — Reference Library: Patterns & Projects in the Showcase

Before you write a new project from scratch, look up which of the **live showcase projects** at https://vibecode.stickem.sg already solved a similar input→output problem. Most participant ideas are a remix of a pattern that's already in the gallery. The patterns below are the cross-cutting engineering recipes; underneath them is an index of each live project mapped to the patterns it uses.

**Rule of thumb:** if a participant describes a goal that lines up with one of these patterns, lift the pattern's code into the new project verbatim and re-skin it. Don't re-invent.

### Engineering Patterns (cross-cutting recipes)

#### Pattern A — Velocity-Based Control (the "humanoid" pattern)

**Why it exists.** Continuous-rotation servos have **no concept of position**. Sending PWM 1700 means "spin clockwise at this speed", not "go to angle 70". So when a participant wants a mechanism that moves to wherever the user is pointing/looking/opening/whatever, the only way to fake position is to drive the servo's *velocity* proportional to how fast the input is *changing*. The kid moves their hand → the servo spins → the mechanism settles wherever they drove it to. Hold still → velocity is zero → servo stops → mechanism holds position.

**When to use.** Any input that conceptually maps to a continuous "position" of a physical thing — mouth open angle, head tilt, arm raise, jaw open, eye direction. Used by humanoid.stickem.sg (head + shoulders) and Puppet Controller (jaw).

**Canonical implementation:**

```js
let smoothInput = 0;
let lastT = performance.now();

function loop() {
  const now = performance.now();
  const dt = Math.max(0.001, (now - lastT) / 1000);
  lastT = now;

  const rawInput = /* sensor reading normalised to 0..1 — hand openness, head pitch ratio, etc. */;

  // 1. Heavy smoothing on the raw signal (≥ 0.80 carry-over keeps velocity stable).
  const prev = smoothInput;
  smoothInput = smoothInput * settings.smoothing + rawInput * (1 - settings.smoothing);

  // 2. Velocity = derivative of smoothed signal, in units per second.
  let velocity = (smoothInput - prev) / dt;

  // 3. Soft deadzone — small jitter shouldn't push the motor.
  if (Math.abs(velocity) < settings.deadzone) velocity = 0;
  else velocity = velocity - Math.sign(velocity) * settings.deadzone;

  // 4. Velocity → PWM offset, clamped, with optional reverse.
  const reverse = settings.reverseMotor ? -1 : +1;
  const offset = Math.max(-settings.maxOffset,
                 Math.min( settings.maxOffset, velocity * settings.sensitivity * reverse));

  if (window.stickemIsConnected()) {
    window.sendServos(1500 + Math.round(offset), null, null, null, null, null, null, null);
  }
}
```

**Important edge cases:**
- When the input vanishes (no hand detected, no face detected), **do not decay `smoothInput`** — the next reappearance would spike a huge "closing" velocity. Instead, default the PWM to 1500 for that channel and let the smoothed value freeze.
- Don't reset `lastT` to the current time on a mode/detector swap (or do, but also reset `smoothInput` to 0) — otherwise the first frame after the swap will have a massive `dt` and explode the velocity.

#### Pattern B — Pulse and Auto-Return (one-shot motions)

**Why it exists.** If a motor pulses "out" to show something (an RPS arm flicks out, a planchette slides to YES, a magic-8-ball needle points), it has to come back to rest so the next round starts fresh. Otherwise the second pulse stacks on top of the first and the mechanism walks off-axis.

**When to use.** Any project with discrete, repeatable "fire one of N actions" behaviour — RPS arms, Spirit Board verdicts, robot dog tricks, slot-machine reels.

**Canonical implementation:**

```js
let lastFiredServo = null;
let resetTimer = null;

async function resetLast() {
  if (resetTimer) { clearTimeout(resetTimer); resetTimer = null; }
  if (lastFiredServo != null && window.stickemIsConnected()) {
    const s = lastFiredServo;
    lastFiredServo = null;
    await window.pulseServo(s, -1, settings.pulseDuration);
  }
}

async function fire(servoIdx) {
  // Return any previously-fired servo to rest before the new throw.
  await resetLast();
  window.pulseServo(servoIdx, +1, settings.pulseDuration);
  lastFiredServo = servoIdx;
  // Auto-return after 3 s in case the next round never comes.
  resetTimer = setTimeout(resetLast, 3000);
}
```

#### Pattern C — Continuous Reactive Pulse (ambient breathing)

**Why it exists.** Raw mic / sensor input is twitchy — feeding it directly into PWM produces seizure-like jitter. Heavy smoothing + a slow oscillator modulation makes the build look like it's *breathing* with the input. This is the difference between "audio-reactive art" and "broken servo".

**When to use.** Any project where the input is a sustained continuous signal (mic loudness, ambient brightness, phone tilt) and the output is a continuous-feeling motion (petals opening, lights pulsing, an antenna swaying).

**Canonical implementation:**

```js
let smooth = 0;
const SENS_MAX = 40;  // mic projects: default sensitivity to OR NEAR slider max.
function loop() {
  const rms = readMicRms();
  smooth = smooth * 0.92 + rms * 0.08;                       // ≥0.85 carry-over
  const t = performance.now() / 1000;
  const breathe = 0.5 + 0.5 * Math.sin(t * 2 * Math.PI * settings.pulseSpeed);  // ~0.3–1 Hz
  const amp = Math.min(1, smooth * settings.micSensitivity);
  const target = amp * (0.55 + 0.45 * breathe);              // modulation
  // Drive the visual / servo toward `target` with a CSS transition or smoothing.
}
```

#### Pattern D — Differential Drive (cars and tank-style robots)

**Why it exists.** Two motors (or two pairs of motors) on opposite sides of a chassis, steered by giving different PWM offsets to each side. Standard tracked-vehicle / tank steering. Throttle is the *common* PWM offset, steering is the *differential*.

**When to use.** Any 2- or 4-wheeled vehicle that turns by spinning one side faster than the other. Used by F1 Hand Wheel (4 motors: 1+3 left tread, 2+4 right tread) and the upcoming Line Tracing Robot.

**Canonical implementation:**

```js
// steerNorm in -1..+1 (positive = turn right). throttleNorm in 0..1.
function drive(steerNorm, throttleNorm) {
  if (!throttleNorm) {
    window.sendServos(1500, 1500, 1500, 1500);
    return;
  }
  const base = settings.throttlePower * throttleNorm;
  const leftScale  = 1 - Math.max(0, -steerNorm);  // slow the left when turning left
  const rightScale = 1 - Math.max(0,  steerNorm);  // slow the right when turning right
  const leftPwm  = 1500 + Math.round(base * leftScale);
  const rightPwm = 1500 + Math.round(base * rightScale);
  window.sendServos(leftPwm, rightPwm, leftPwm, rightPwm); // motors 1+3 left tread, 2+4 right tread
}
```

#### Pattern E — Countdown + Escalating Alarm

**Why it exists.** A timer ticks down second-by-second (each tick = a small servo pulse + a short buzzer beep), then on zero an alarm fires (continuous servo spin + repeating buzzer tones) until the user explicitly stops it. The "repeating tone every 480 ms over 400 ms duration" gives the illusion of a continuous siren without flooding the BLE channel.

**When to use.** Any timer-fires-something project: Ticking Clock (board-as-clock), Human Controlled Alarm (defuse-me), workshop kitchen timer, "time until you get up off the couch" reminder.

**Canonical implementation:**

```js
let countdownInterval = null, alarmBeepInterval = null;
function doTick() {                                    // 1 Hz tick
  if (!window.stickemIsConnected()) return;
  window.pulseServo(1, +1, settings.tickLength);       // short servo flick
  window.sendBuzzerTone(settings.buzzerPitch, 30, true); // soft beep
}
function startAlarm() {
  if (window.stickemIsConnected()) {
    window.sendServos(2000, null, null, null, null, null, null, null);  // continuous spin
  }
  alarmBeepInterval = setInterval(() => {
    if (window.stickemIsConnected()) window.sendBuzzerTone(settings.buzzerPitch, 400, true);
  }, 480);  // 400 ms tone every 480 ms = effectively continuous siren
}
function stopAlarm() {
  if (alarmBeepInterval) { clearInterval(alarmBeepInterval); alarmBeepInterval = null; }
  window.stopAll();
  window.sendBuzzerStop();
}
```

#### Pattern F — Mode Toggle (one project, multiple input modalities)

**Why it exists.** Some projects make sense in multiple input flavours — Puppet Controller works with *either* hand tracking (sock puppet) *or* face tracking (robot head). Letting the user pick is friendlier than hard-coding one.

**When to use.** When the same physical mechanism can be driven from two different sensors. The pattern: lazy-load whichever detector the user picked, dispose the old one on switch, reset velocity state to avoid spikes.

**Canonical implementation:**

```js
let detector = null, detectorMode = null;
async function loadDetector(mode) {
  if (detectorMode === mode && detector) return detector;
  if (detector && detector.close) try { detector.close(); } catch {}
  detector = null; detectorMode = null;
  const vision = await import('https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.21/vision_bundle.mjs');
  const fr = await vision.FilesetResolver.forVisionTasks('https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.21/wasm');
  if (mode === 'hand') {
    detector = await vision.HandLandmarker.createFromOptions(fr, { baseOptions: { /* ... */ }, runningMode: 'VIDEO', numHands: 2 });
  } else {
    detector = await vision.FaceLandmarker.createFromOptions(fr, { baseOptions: { /* ... */ }, runningMode: 'VIDEO', numFaces: 1 });
  }
  detectorMode = mode;
}
async function switchMode(mode) {
  settings.mode = mode;              // persists via useAppSettings
  applyModeUI(mode);                 // swap labels, accent colors, hint text
  smoothInput1 = 0; smoothInput2 = 0; // reset velocity state across the swap
  lastT = performance.now();
  await loadDetector(mode);
}
```

#### Pattern G — Voice → Multi-Servo Choreography (movement primitives)

**Why it exists.** When a project listens for words/phrases and the response is a *sequence* of motor moves, those sequences must match the **real-world reference** for whatever the build is. A 4-legged robot dog said "sit" should fold its back legs (not flap them). A humanoid said "wave" should raise the right shoulder, not both.

**When to use.** Voice-driven characters: Robot Pet, animatronic mascot, robot mime. Look up the real animal/robot first (Boston Dynamics Spot for quadrupeds, NAO for humanoids) and copy its primitives.

**Canonical implementation skeleton:**

```js
const MOVES = {
  sit:     () => { pulse(3, -1, 250); pulse(4, -1, 250); },                       // back paws fold
  stand:   () => { pulse(3, +1, 250); pulse(4, +1, 250); pulse(1, +1, 80); pulse(2, +1, 80); },
  shake:   () => { pulse(2, +1, 200); setTimeout(() => pulse(2, -1, 200), 250); }, // right front paw waves
  forward: () => { pulse(1, +1, 300); pulse(4, +1, 300); pulse(2, +1, 300); pulse(3, +1, 300); }, // diagonal trot
  spin:    () => { pulse(1, +1, 400); pulse(3, +1, 400); pulse(2, -1, 400); pulse(4, -1, 400); },
};
recognition.onresult = (e) => {
  const phrase = e.results[e.results.length - 1][0].transcript.trim().toLowerCase();
  for (const [k, fn] of Object.entries(MOVES)) if (phrase.includes(k)) { fn(); break; }
};
```

#### Pattern H — Theming via CSS Variables

**Why it exists.** Workshop participants love picking a vibe. Building 6 visual themes as CSS variable swaps (`[data-theme="x"] { --display-color: ... }`) costs ~150 lines of CSS and gives them a one-click personality switcher. Cheap, fun, and a great pattern for any project where the visual feel is half the appeal.

**When to use.** Any project where the visual look is meaningful — clocks, art pieces, dashboards. Define ~20 CSS variables in `:root`, override them in `[data-theme="..."]` blocks, persist `settings.theme`, swap via `document.documentElement.dataset.theme = themeName`. Used by Ticking Clock (6 themes: Neon, Clean, Pink, Flames, Grandfather, Ocean).

#### Pattern I — Buzzer-Only Apps

**Why it exists.** If the project never drives motors, the 200 ms `mv:` keepalive frame burns BLE bandwidth and competes with buzzer commands. Disable it.

**When to use.** Theremin Conductor, metronomes, sound art, alarm-only projects.

```js
window.stickemOnConnect(() => window.stickemSetMotorKeepalive(false));
```

### Reference Project Index

Use this when a participant's idea looks similar to something already shipped.

| Project | Input | Output | Patterns used | Key file |
|---|---|---|---|---|
| **Humanoid Robot Control** (humanoid.stickem.sg) | MediaPipe Pose + Face landmarkers | 3 servos: neck tilt, neck turn, both shoulders | A (velocity), G (movement primitives) | external site |
| **Ticking Clock** | `setInterval` 1 Hz | 1 servo + piezo buzzer | E (countdown+alarm), H (CSS-var theming) | `projects/ticking-clock/index.html` |
| **F1 Hand Wheel** | MediaPipe HandLandmarker (2 hands) | 4 servos as differential-drive treads (1+3 left, 2+4 right) | D (differential drive) | `projects/f1-hand-wheel/index.html` |
| **Robot Pet** | `webkitSpeechRecognition` | 4 servos as legs | G (voice→choreography), B (one-shot return) | `projects/robot-pet/index.html` |
| **Theremin Conductor** | MediaPipe HandLandmarker | piezo buzzer (no motors) | I (buzzer-only), continuous pitch via `sendBuzzerChangeTone` | `projects/theremin-conductor/index.html` |
| **Puppet Controller** | HandLandmarker *or* FaceLandmarker | 1–2 servos as jaw | A (velocity), F (mode toggle) | `projects/puppet-controller/index.html` |
| **Rock Paper Scissors** | MediaPipe HandLandmarker (1 hand) | 3 servos as fingers | B (pulse and return) | `projects/rock-paper-scissors/index.html` |
| **Reactive Flower** | Web Audio AnalyserNode (mic RMS) | 4 servos as petals | C (continuous reactive pulse) | `projects/reactive-flower/index.html` |
| **Spirit Board** | Voice + text | 1 servo as planchette | B (pulse and return), deliberation jitter | `projects/ouija-board/index.html` |
| **Human Controlled Alarm** | `setInterval` + Pose landmarker | 1 servo + buzzer | E (countdown+alarm), pose-match defuse loop | `projects/human-controlled-alarm/index.html` (standalone, not on the scaffold) |
| **Line Tracing Robot** *(coming soon)* | Webcam + OpenCV-style line detection | 4 servos differential | D (differential drive) | `projects/line-tracing-robot/` |

### How to Pick a Pattern (decision tree)

Use this when interviewing the participant in Step 1:

1. **Are they mapping a continuous input to a continuous-looking output?** (raised arm → raised arm, mouth open → mouth open, hand tilt → head tilt) → **Pattern A (velocity-based)**. Look at humanoid + Puppet Controller.
2. **Is the input a sustained signal (mic, ambient) and the output an ambient mood?** → **Pattern C (continuous reactive pulse)**. Look at Reactive Flower.
3. **Does the user perform a discrete action that triggers a discrete response?** (say "sit", throw scissors, ask a question) → **Pattern B (pulse + return)** plus **G (choreography)** if voice-driven. Look at RPS, Robot Pet, Spirit Board.
4. **Is it a vehicle that steers?** → **Pattern D (differential drive)**. Look at F1.
5. **Does it count down to an alarm?** → **Pattern E (countdown + alarm)**. Look at Ticking Clock, Human Alarm.
6. **Does the project have multiple input modes** (hand vs face, voice vs typed)? → **Pattern F (mode toggle)**. Look at Puppet Controller.
7. **Is the project mostly sound, not motors?** → **Pattern I (buzzer-only)**. Look at Theremin Conductor.
8. **Is the participant going to want to re-skin it?** → Always add **Pattern H (CSS-var theming)** if it costs you less than 30 minutes.

If a participant's idea touches *multiple* patterns (e.g. a "yoga timer that watches your pose and rings when you fall out of the pose" = E + A), lift both patterns and compose them. That's how Puppet Controller (A + F) and Human Controlled Alarm (E + camera defuse loop) got built.

---

## STEP 9 — Final Checklist Before Handing Code to the User

Before you tell the participant "try it now", make sure:

- [ ] You interviewed them and confirmed their project (Step 1).
- [ ] **Before writing a single line, you skimmed Step 8.5 and picked which existing showcase pattern(s) you're going to lift.** If the participant's idea is "puppet that opens its mouth when I open mine" you grab Pattern A (velocity) from Puppet Controller; if it's "robot that ticks every second and explodes at zero" you grab Pattern E from Ticking Clock; etc. Composing 2–3 known patterns is how every project in the gallery was built.
- [ ] You confirmed they are on Chrome / Edge on a desktop OS, or on Android Chrome.
- [ ] If they are on iOS, you warned them that they need *Bluefy* from the App Store.
- [ ] **The UI is thematic AND readable (Step 5).** A thematic display font is on the headline only, the body uses a clean sans-serif (`Inter` / `Manrope` / `Nunito Sans` / etc.), text contrast is high, the palette and central visual match the project's world, and a stranger glancing at the page would identify the theme within two seconds. Cursive / dripping / pixel-grunge fonts on body text or button labels = fail. Plain white/grey + `system-ui` = fail.
- [ ] **Every button label includes a bracketed action verb** — e.g. `Summon the Spirits (Connect)`, `Banish (Disconnect)`, `Bloom (Connect)`, `Engage Telemetry (Connect)`, `Pit In (Stop)`, `Activate Servos (Start)`, `Start Listening (Microphone)`. Pure theme-only labels like "Summon the Spirits" are forbidden.
- [ ] **Progressive disclosure jiggle is wired up.** The Connect button has `class="jiggle"` on first paint. After BLE pairs you call `window.stickemOnConnect(() => window.setJiggleTarget('#next-step-button'))` to move the animation onward. The next-step button's click handler calls `window.setJiggleTarget(null)` (or chains it to a third button for multi-step flows). At all times **exactly one button is jiggling** until the user reaches the main interaction. The participant always sees one obvious thing to do next.
- [ ] **The Stick 'Em logo bar is mounted** via `window.mountStickEmLogo({ logoPath, showcasePath, logoColor })`. Back-to-Showcase sits at the top-left edge of the screen; the Stick 'Em logo sits at the top-right edge linking to https://stickem.sg. Do not add a wordmark, subtitle, or shadow under the logo image.
- [ ] **`logoColor` is exactly `'white'` (default) or `'blue'` — these are the only two options.** Pick the one that contrasts with the project's background:
  - **Dark backgrounds** (navy, black, deep purple, dark brown, charcoal, etc.) → `logoColor: 'white'` (or omit, it's the default).
  - **Light backgrounds** (pastels, white, cream, light yellow, etc.) → `logoColor: 'blue'`.

  The image stays the raw blue PNG; the scaffold applies `filter: brightness(0) invert(1)` for white. Do not introduce other colours, do not tint with `rgba`, do not add a glow. If your theme background is multi-tone, choose the colour that wins on the corner where the logo lives (top-right).
- [ ] **The page title sits close to the top of the screen.** The logo bar's bottom margin (set by the scaffold) plus the h1's top margin should total no more than ~12 px. On a project page, the title should be the second thing the eye lands on after the logo bar — not floating in the middle of the page with a yawning gap of dead space above it. If you find yourself adding `padding-top: 56px` or `margin-top: 24px` on the h1, you are wrong; remove it.
- [ ] The single `index.html` file uses the scaffold above, **untouched** in the BLE plumbing, the `applyTrim` calibration math, and the calibration tool functions (theme it cosmetically, but do not modify the BLE send loop, the desired-state array, the keepalive timer, `applyTrim`, or the calibration tool's behaviour).
- [ ] All motor commands go through `window.sendServos(...)` or `window.pulseServo(...)`. No raw `mv:...,` strings anywhere else. **Trim is applied automatically inside `rebuildPending()`** — never apply it yourself.
- [ ] All buzzer commands go through `window.sendBuzzerTone(...)`, `window.sendBuzzerNote(...)`, `window.sendBuzzerBPM(...)`, `window.sendBuzzerStop()`, or `window.sendBuzzerChangeTone(...)`. **No raw `playToneDuration:...,` strings anywhere else** — the firmware requires the Scratch-block `{(num:{N})}` wrapping around numeric args, which the helpers apply for you. Bare integers fail the parser silently. For continuous-pitch apps (theremins) prefer `sendBuzzerChangeTone(hz, ms, true)` so each pitch change is one atomic BLE write that the firmware runs on a single parser task.
- [ ] **If the project plays sound, you used the correct buzzer recipe.** One-shot tick? `sendBuzzerTone(hz, 30, true)`. Continuous siren? `setInterval` firing `sendBuzzerTone(hz, 400, true)` every 480 ms. Pitched theremin? `sendBuzzerChangeTone(hz, 150, true)`. Musical melody? `sendBuzzerBPM(120)` + `sendBuzzerNote(...)`. (See Step 3 "Buzzer Recipes".)
- [ ] **Keyboard shortcuts are wired up.** If the project has a primary start/pause/stop-alarm action, `Space` triggers it (with `e.preventDefault()` to stop page scroll, and `.blur()` on action buttons so they don't double-fire). If the project has a fullscreen mode, `Escape` exits it. A `<kbd>`-styled hint strip is visible near the controls so users discover the shortcuts. (See Step 5 "Keyboard Shortcuts".)
- [ ] **A "↻ Reset to defaults" button is present.** Projects using `mountAppSettings` get it automatically — verify it's actually appearing at the bottom of the settings panel. Standalone projects (not on the scaffold) implement it manually: confirm dialog → `localStorage.removeItem(key)` → `location.reload()`. (See Step 7.5 "Reset to Defaults".)
- [ ] If the project does not move motors at all (buzzer-only apps, audio-reactive lighting, etc), call `window.stickemSetMotorKeepalive(false)` right after connect so the 200 ms `mv:1500:...` keepalive does not compete with your other commands for the BLE channel.
- [ ] You did **not** add any debouncing, throttling, `setInterval` re-senders, or "is this different from last time" guards around the motor calls. The scaffold handles all of that.
- [ ] **The Servo Calibration Tool is mounted at the bottom of the page** with the correct motor count *and motor labels*: `window.mountCalibrationTool(N, null, { labels: ['left front paw', ...] })`. Each motor card has **one** trim slider (range -80..+80) — not separate forward/backward sliders. Push right = slow clockwise; push left = slow anticlockwise. **Labels are mandatory** — bare "Motor 1 / Motor 2" with no descriptive suffix is a fail.
- [ ] **An App Settings panel is mounted above the Servo Calibration Tool (Step 7.5)** with 3–5 project-specific sliders or pickers — the knobs that change how the project *feels* (sensitivity, deadzone, countdown length, difficulty, drive speed, etc.). Wired up via `window.useAppSettings(...)` + `window.mountAppSettings(...)`. The project loop **reads `settings.xxx` live every tick**, not at startup. The participant should be able to fix "too sensitive" / "too slow" / "too easy" without touching code.
- [ ] **If the project drives 3+ motors that could be wired backwards** (paws, treads, axes), include a `type: 'checkboxes'` row in App Settings — one tick-box per motor labelled with what it controls. Pulse / drive calls flip the sign when the box is ticked. (See Step 7.5 "Per-Motor Reverse Checkboxes".)
- [ ] **Reactive projects pulse slowly with a speed gradient — they do not twitch.** If the input is a continuous signal (mic, tilt, brightness), apply heavy smoothing (≥0.85 carry-over factor), modulate by a slow oscillator (0.3–1 Hz), and add a CSS transition on the visual element so it glides. The build should look like it is *breathing* with the input, not seizing.
- [ ] **Mic-driven and other sensor-reactive projects default their sensitivity to or near the slider maximum.** Out of the box, the build should react obviously to ordinary input.
- [ ] **One-shot servo actions return to rest.** If a motor pulses out to "show" something (RPS arm, hat tip, magic-8-ball needle), track `lastFiredServo` and either reverse-pulse it on the next round or auto-return after ~3 s. (See Step 7.5 "One-Shot Actions Must Return to Rest".)
- [ ] **Multi-motor character builds match their real-world reference.** If the participant said "robot dog", look up Boston Dynamics Spot or Unitree Go: sit = back legs fold, stand = legs extend, forward = diagonal-pair trot, spin = sides counter-rotate, dance = wave around the paws. Do not invent ad-hoc motion primitives that don't resemble the real animal/robot.
- [ ] **Webcam and microphone auto-start on `window.load`.** Do not make the participant click "Start Camera" before they can do anything. Keep the manual button visible as a backup in case auto-start failed.
- [ ] **Big visuals go on the left in a two-column grid; controls stay on the right.** A 480 px board / video feed centred above the controls pushes Connect/Start/Stop off the fold. Use `grid-template-columns: minmax(280px, 480px) minmax(280px, 1fr)` with a `max-width: 880px` single-column collapse.
- [ ] **Mobile friendliness is wired up.** `<meta name="viewport" content="width=device-width, initial-scale=1" />` in `<head>`, plus a `@media (max-width: 600px)` block that shrinks the title, caps oversized visuals via `clamp()` / `min()`, tightens button padding, and reduces body padding so the page fits a 360 px phone. The build must load and be usable on a phone — *humanoid.stickem.sg already is*.
- [ ] **The Debug Log panel from the scaffold is still in the page** (do not remove it). The participant needs it to diagnose missing-movement issues live.
- [ ] In the chat reply, explicitly tell the participant: *"If something you do in the app doesn't move the robot, open the Debug Log at the bottom and check whether your command appears there — that tells you instantly whether the bug is in your event handler, in BLE, or in the wiring."*
- [ ] There is a visible Stop button wired to `window.stopAll()`.
- [ ] There is on-screen feedback (mini meters, numeric readout, or text) so they can debug.
- [ ] PWM offsets start within `±200` of 1500 (range `1300–1700`) with a comment explaining how to widen them.
- [ ] You explained, in plain language in the chat, **(a)** that these are continuous rotation motors with no concept of angle, **(b)** how the calibration tool works and the workflow for using it, and **(c)** which specific calibration value to tweak if the first test of their project looks off.
- [ ] Nowhere in the code, comments, or chat did you mention degrees, angles, or absolute positions.
