Pockety

Planted May 26, 2026

![Pockety — final pocket device with e-ink display and encoder](Screenshot 2026-06-08 at 08.53.53.png)

Overview

In current times we need our technological devices for most tasks like looking at our to-do’s, calender or tasks such as timing stuff. Though these are crucial tasks since now we have devices a myriad of functions turning them on for these may lead to hours of distraction particularly caused by social media apps directing the user away from their task. Hence, comes in Pockety a device which gives the user a medium to easily acsess this functionlity and not get distracted as a pocket sized e-ink device made for this. Intially planned as a simple utility device containing apps such as: to-do’s, calender, notes, timer as need rises further functionality will be implemented. Additionally, the final goal for the device, if time permits, is becoming a writer deck: a distractionless writing device.

Motivation

Pocket e-ink for calendar, notes, timer, and to-dos - intentional use without phone distraction. Planning, inspiration, timeline, and pre-final prototypes: Project development →. Main browser data editor: Main interface →. KiCad boards and Fusion CAD: Design files →

Final device internals (May 2026)

Version 3, the one in Week 15 system integration as well. Thinner form factor, better placement for the screen.

Final pocket — white enclosure exterior with e-ink module and encoder

Inside

Final pocket — interior showing PCB, battery, and cable routing

Final product tests

White May 2026 integration build - functional tests on the shipped form factor. Yellow prototype videos: Project development →.

Full hardware lineup — white Pockety with reader menu, gyro-stylus, ChordBoard, and draw module

On-device demo

Encoder navigation and e-ink menu refresh:

Download demo clip (MP4)

BOM

Bill of materials aligned with the integrated hardware documented in Week 15: System Integration.

Compute and interfaces

ItemQtyNotes
Raspberry Pi Pico 2 W1Main MCU and Wi-Fi
Custom PCB (bare board)1Interconnect and breakout

Displays and input

ItemQtyNotes
Waveshare 2.13 inch e-ink module1Primary display
0.96 inch SSD1306 OLED1Secondary status / UI
ALPS EC11 magnetic encoder1With cable to PCB
IC184 red push button1Tactile input

Power

ItemQtyNotes
1S 3.7 V LiPo battery (350 mAh)1As used in integration
TP4056 LiPo charging module1Charging / protection (module as built)

PCB population (SMD / through-hole on custom board)

PartQty
LED 1206 (orange)2
C 1206 0.1 µF2
R 1206 100 Ω1
C 1206 10 µF1
Pin header 2.54 mm13
5-position vertical SMD socket8
4-position vertical SMD socket1
3-position vertical SMD socket1
2-position vertical SMD socket1

Mechanical and assembly

ItemQtyNotes
3D-printed parts (body, top, screen cap, encoder shaft, guides, etc.)1 setDesign-specific enclosure
3D printing filamentAs needede.g. PLA or PETG
Magnets (~0.5 inch diameter)SetMagnetic retention for e-ink cap / top
M2 screwsSetTop–body assembly
Brass threaded insertsSetInstalled in top for screw retention

PCB

Main board

CAD

Main PCB — KiCad layout with Pico socket and display headers

3D

Main PCB — 3D view of populated interconnect board

Enclosure

CAD

Fusion 360 assembly — wooden base, grey top plate (screen, encoder, and button cutouts), and loose inserts for fit check before print.

![Fusion 360 assembly — exploded top plate, wooden base, and press-fit inserts](Screenshot 2026-06-08 at 15.20.31.png)

Download .f3z / .f3d sources: Design files →

Submodules

External extension pack of pockety

Gyro-Stylus

Gyro-based stylus for elementary sketches in the draw app - PCB, bench layout, and draw pose.

KiCad layoutOn the benchDraw position
Gyro-Stylus PCB — four switches, LED, and XIAO footprintGyro-Stylus laid flatGyro-Stylus in draw orientation

5 Pad EngelBart ChordBoard

Five touch pads, dual switches, XIAO footprint - PLA top, 3 mm wood base, ecoflex silicone buttons.

PCB layoutAssembled moduleSilicone buttons
ChordBoard PCB layoutChordBoard with PLA top and wood baseChordBoard closed-up — ecoflex pads

Feyncorder

Magnetic encoder daughterboard - ALPS EC11 breakout wired to the main PCB.

ResourceLink
KiCad projectfeyncorder design files
Top plate moldingProject development →

Feyncorder — magnetic encoder daughterboard prototype on the bench

On-device encoder demo: Final product tests →.

Firmware & apps

MicroPython on the Pico 2 W drives the e-ink UI, OLED status, encoder, and on-device apps (calendar, reader, flashcards, weather). Custom EPD driver over SPI, framebuf drawing, partial/full refresh. Encoder demo: Final product tests →.

System diagram

Firmware system diagram — framebuf, EPD driver, and I/O buses

Full firmware write-ups and every MicroPython source file: Project development →. Week 14 canvas and ESP32 voice gadget: Additional interfaces →. KiCad and Fusion CAD: Design files →.

Main interface (pockety-interface)

The shipped browser data editor - a tabbed web UI for everything the on-device apps read from flash. Plain HTML/CSS/JS with no build step; when the Pico W serves it over Wi-Fi, edits go straight to the device via a small REST API.

Open on the device: http://<pico-ip>/. For local development with Live Server, set the PICO URL in the header (stored in localStorage) so fetch calls reach the board.

Download: pockety-interface/index.html

BooksFlashcards
![Pockety data editor — Books tab](Screenshot 2026-06-08 at 11.07.12.png)![Pockety data editor — Flashcards tab](Screenshot 2026-06-08 at 11.07.25.png)

Layout and tabs

TabOn-device dataWhat you do
Books*.txt in flashList, edit, create, delete reader library files
Flashcardsflashcards.jsonDeck picker, front/back cards, SRS fields (due, etc.)
Weatherweather.txtName,lat,lon rows for Open-Meteo
Voice*.raw clipsList 16-bit mono 16 kHz recordings; play in-browser or export WAV
All filesany filenameRaw read/write/delete for debugging
Build appgenerated *.pyDrag-and-drop menu/text screens → MicroPython app; optional main.py launcher entry
Freppylocal + optional PicoPCB design via frep-pcb MCP (validate, render, Claude design stream)

Header shows SSID, IP, and free/total flash from GET /api/info.

Pico API (api.js)

EndpointMethodRole
/api/infoGETSSID, IP, free/total bytes (JSON)
/api/filesGETFile list with sizes (JSON)
/api/read?name=GETFile body (text or binary)
/api/write?name=POSTWrite body to flash
/api/delete?name=POSTDelete file

picoBase() uses same-origin when served from the Pico; on localhost it reads the saved device URL. Tab choice persists in sessionStorage.

Full interface markup (pockety-interface/index.html)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pockety</title>
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<header>
  <div class="seal"></div>
  <h1>Pockety<small>data editor</small></h1>
  <div class="meta">
    <span>NET <b id="m-ssid">&mdash;</b></span>
    <span>IP <b id="m-ip">&mdash;</b></span>
    <span>FREE <b id="m-free">&mdash;</b></span>
    <span class="pico-url-wrap">PICO <input id="pico-url" class="pico-url" title="Pico device URL when using Live Server"></span>
  </div>
</header>

<div class="wrap">
  <nav>
    <button data-tab="books" class="active">Books</button>
    <button data-tab="cards">Flashcards</button>
    <button data-tab="weather">Weather</button>
    <button data-tab="audio">Voice</button>
    <button data-tab="files">All files</button>
    <button data-tab="build">Build app</button>
    <button data-tab="freppy">Freppy</button>
    <div class="spacer"></div>
    <div class="hint">Edits write straight to the device's flash. Pick the app's
      data and Save.</div>
  </nav>

  <main>
    <section class="tab show" id="tab-books">
      <h2>Reader · books (.txt)</h2>
      <div class="row">
        <div class="col" style="max-width:280px">
          <div class="list" id="book-list"></div>
          <div class="bar">
            <button class="btn" onclick="newBook()">+ New book</button>
            <button class="btn" onclick="loadBooks()">Refresh</button>
          </div>
        </div>
        <div class="col">
          <label>Filename</label>
          <input id="book-name" placeholder="mybook.txt">
          <label>Contents</label>
          <textarea id="book-text" placeholder="Book text…"></textarea>
          <div class="bar">
            <button class="btn primary" onclick="saveBook()">Save book</button>
            <button class="btn danger" onclick="delBook()">Delete</button>
          </div>
        </div>
      </div>
    </section>

    <section class="tab" id="tab-cards">
      <h2>Flashcards · flashcards.json</h2>
      <div class="bar">
        <label style="margin:0">Deck</label>
        <select id="deck-sel" style="max-width:220px" onchange="renderCards()"></select>
        <button class="btn" onclick="addDeck()">+ Deck</button>
        <button class="btn" onclick="addCard()">+ Card</button>
        <span class="pill" id="card-day"></span>
      </div>
      <div id="cards"></div>
      <div class="bar">
        <button class="btn primary" onclick="saveCards()">Save deck file</button>
        <button class="btn" onclick="loadCards()">Reload</button>
      </div>
    </section>

    <section class="tab" id="tab-weather">
      <h2>Weather · cities (weather.txt)</h2>
      <table>
        <thead><tr><th>City</th><th style="width:90px">Lat</th><th style="width:90px">Lon</th><th style="width:40px"></th></tr></thead>
        <tbody id="city-rows"></tbody>
      </table>
      <div class="bar">
        <button class="btn" onclick="addCity()">+ City</button>
        <button class="btn primary" onclick="saveCities()">Save cities</button>
        <button class="btn" onclick="loadCities()">Reload</button>
      </div>
    </section>

    <section class="tab" id="tab-audio">
      <h2>Voice recorder &middot; clips</h2>
      <div class="bar">
        <button class="btn" onclick="loadClips()">Refresh</button>
        <span class="pill" id="clip-fmt">16-bit &middot; 16 kHz &middot; mono</span>
      </div>
      <table>
        <thead><tr><th>Clip</th><th style="width:74px">Length</th><th style="width:70px">Size</th><th style="width:210px"></th></tr></thead>
        <tbody id="clip-rows"></tbody>
      </table>
    </section>

    <section class="tab" id="tab-files">
      <h2>All files</h2>
      <div class="row">
        <div class="col" style="max-width:280px">
          <div class="list" id="file-list"></div>
          <div class="bar">
            <button class="btn" onclick="newFile()">+ New file</button>
            <button class="btn" onclick="loadFiles()">Refresh</button>
          </div>
        </div>
        <div class="col">
          <label>Filename</label>
          <input id="file-name" placeholder="events.txt">
          <label>Contents</label>
          <textarea id="file-text"></textarea>
          <div class="bar">
            <button class="btn primary" onclick="saveFile()">Save file</button>
            <button class="btn danger" onclick="delFile()">Delete</button>
          </div>
        </div>
      </div>
    </section>

    <section class="tab" id="tab-freppy">
      <h2>Freppy · PCB design</h2>
      <div class="freppy-setup card">
        <div class="f">
          <span class="freppy-dot offline" id="freppy-status"></span>
          <span id="freppy-status-hint">checking Claude Code bridge…</span>
          <button class="btn" onclick="checkFreppyBridge()">Reconnect</button>
        </div>
        <p class="freppy-note">Describe a board below — Claude Code calls <code>frep-pcb</code> MCP tools (template, validate, render), then the preview updates here. No Pico required.</p>
        <label>What should Claude design?</label>
        <textarea id="freppy-prompt" class="freppy-prompt" rows="3" placeholder="e.g. ATtiny412 breakout with UPDI header and a red LED on pin 6"></textarea>
        <div class="bar">
          <button type="button" class="btn primary" id="freppy-design-btn" onclick="designFreppyWithClaude()">Design with Claude</button>
          <span class="pill" id="freppy-design-status"></span>
        </div>
        <label>Claude activity</label>
        <div class="freppy-activity-bar">
          <span class="freppy-activity-live" id="freppy-activity-live">idle</span>
          <select id="freppy-activity-log" class="freppy-activity-select" title="Activity log">
            <option value="">— function log —</option>
          </select>
        </div>
        <details class="freppy-activity-details">
          <summary>Full log</summary>
          <pre class="freppy-progress" id="freppy-progress"></pre>
        </details>
        <details class="freppy-details">
          <summary>Claude Code · submodule MCP setup</summary>
          <ol class="freppy-steps">
            <li>Add freppy as a submodule: <code>git submodule add &lt;repo&gt; freppy</code></li>
            <li>Run <code>scripts/setup-freppy.sh</code> to create the venv</li>
            <li>In Claude Code, run <code>/mcp</code> — <code>frep-pcb</code> connects via <code>.mcp.json</code></li>
            <li>For this web tab, start the local bridge: <code>scripts/start-freppy-bridge.sh</code></li>
          </ol>
          <label>Project root path (for MCP config)</label>
          <input id="freppy-project-root" placeholder="/Users/you/interface" value="">
          <label>MCP config (<code>.mcp.json</code>)</label>
          <pre class="freppy-mcp" id="freppy-mcp-json"></pre>
          <div class="bar">
            <button class="btn" onclick="copyFreppyMcp()">Copy MCP config</button>
          </div>
          <div class="meta">Freppy dir: <span id="freppy-dir">—</span></div>
        </details>
      </div>
      <div class="row freppy-workspace">
        <div class="col" style="max-width:240px">
          <label>Local designs</label>
          <div class="list" id="freppy-list"></div>
          <div class="bar">
            <button class="btn" onclick="newFreppyDesign()">+ New design</button>
            <button class="btn" onclick="clearFreppyCache()" title="Clear saved designs and session for a fresh demo">Reset</button>
          </div>
        </div>
        <div class="col freppy-editor">
          <label>Filename</label>
          <input id="freppy-name" placeholder="myboard.py">
          <div class="bar">
            <button class="btn" onclick="loadFreppyTemplate('template')">Template</button>
            <button class="btn" onclick="loadFreppyTemplate('example')">Example</button>
            <label style="margin:0">DPI</label>
            <input id="freppy-dpi" type="number" value="150" min="50" max="600" style="max-width:72px">
            <button class="btn" onclick="validateFreppy()">Validate</button>
            <button class="btn primary" onclick="renderFreppy()">Render</button>
          </div>
          <label>Design code</label>
          <textarea id="freppy-code" class="freppy-code" placeholder="Click Template to start, or paste Python design code (not markdown fences)"></textarea>
          <div class="bar">
            <button class="btn primary" onclick="saveFreppyLocal()">Save locally</button>
            <button class="btn" onclick="downloadFreppyDesign()">Download .py</button>
            <button class="btn danger" onclick="delFreppyLocal()">Delete</button>
          </div>
          <details class="freppy-details freppy-pico-opt">
            <summary>Optional · push to Pico</summary>
            <p class="freppy-note">Only needed if you want this design file on the device. Requires Pico to be online.</p>
            <div class="bar">
              <button class="btn" onclick="pushFreppyToPico()">Push to Pico</button>
            </div>
            <div class="meta" id="freppy-pico-hint">not pushed yet</div>
          </details>
        </div>
        <div class="col freppy-preview-col">
          <label>Preview</label>
          <div class="freppy-preview-wrap" id="freppy-preview-wrap">
            <img id="freppy-preview" alt="PCB preview">
            <div class="empty freppy-preview-empty" id="freppy-preview-empty">Render to preview</div>
          </div>
          <label>Result</label>
          <pre class="freppy-result" id="freppy-result"></pre>
        </div>
      </div>
    </section>

    <section class="tab" id="tab-build">
      <h2>App builder &middot; generate &amp; push</h2>
      <div class="row">
        <div class="col">
          <label>App name (launcher label)</label>
          <input id="b-name" value="My App">
          <label>Filename</label>
          <input id="b-file" value="myapp.py">
          <label>Drag screens onto the canvas</label>
          <div class="builder-palette">
            <div class="builder-chip" draggable="true" data-screen-type="menu" title="Drag onto canvas">⠿ Menu screen</div>
            <div class="builder-chip" draggable="true" data-screen-type="text" title="Drag onto canvas">⠿ Text screen</div>
          </div>
          <p class="builder-hint">Drag chips to add screens · drag ⠿ handles to reorder · drop menu items between rows</p>
          <div id="b-screens" class="builder-canvas"></div>
        </div>
        <div class="col">
          <label>Generated MicroPython</label>
          <textarea id="b-code" readonly placeholder="Press Generate to preview the code"></textarea>
          <div class="bar">
            <button class="btn" onclick="genCode()">Generate</button>
            <button class="btn primary" onclick="pushApp()">Push to Pico</button>
          </div>
          <label style="text-transform:none;letter-spacing:0;display:flex;gap:.5rem;align-items:center">
            <input type="checkbox" id="b-launch" checked style="width:auto"> register in launcher (main.py)
          </label>
        </div>
      </div>
    </section>
  </main>
</div>

<div id="toast"></div>

<script src="js/api.js"></script>
<script src="js/books.js"></script>
<script src="js/cards.js"></script>
<script src="js/weather.js"></script>
<script src="js/files.js"></script>
<script src="js/audio.js"></script>
<script src="js/builder.js"></script>
<script src="js/freppy.js"></script>
<script src="js/main.js"></script>
</body>
</html>
Stylesheet (pockety-interface/css/styles.css)
:root {
  --paper: #f2eee4;
  --panel: #ece6d9;
  --raise: #e6dfce;
  --ink: #1b1916;
  --muted: #8b8576;
  --faint: #aaa492;
  --line: #d6cfbe;
  --line2: #bdb4a1;
  --seal: #b1432b;
  --serif: "Hiragino Mincho ProN", "Yu Mincho", "Songti SC", Georgia, "Times New Roman", serif;
  --sans: "Hiragino Kaku Gothic ProN", "Yu Gothic UI", Helvetica, Arial, sans-serif;
  --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
}

* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  height: 100%;
}

body {
  background: var(--paper);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 14px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

header {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  flex-wrap: wrap;
  padding: 1.1rem 1.5rem;
  border-bottom: 1px solid var(--ink);
}

.seal {
  width: 26px;
  height: 26px;
  flex: none;
  background: var(--seal);
}

header h1 {
  font-family: var(--serif);
  font-weight: 500;
  font-size: 19px;
  margin: 0;
  letter-spacing: 0.06em;
}

header h1 small {
  font-family: var(--sans);
  color: var(--muted);
  font-size: 10px;
  letter-spacing: 0.32em;
  text-transform: uppercase;
  margin-left: 0.7rem;
}

header .meta {
  margin-left: auto;
  display: flex;
  gap: 1.6rem;
  color: var(--muted);
  font-family: var(--mono);
  font-size: 11px;
}

header .meta b {
  color: var(--ink);
  font-weight: 600;
}

.pico-url-wrap {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
}

.pico-url {
  width: 9.5rem;
  font-family: var(--mono);
  font-size: 10px;
  padding: 0.15rem 0.35rem;
  border: 1px solid var(--line2);
  background: var(--paper);
}

.wrap {
  display: flex;
  min-height: calc(100% - 65px);
}

nav {
  width: 178px;
  flex: none;
  border-right: 1px solid var(--line);
  padding: 1.5rem 0.8rem;
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
}

nav button {
  font-family: var(--sans);
  text-align: left;
  background: none;
  border: none;
  border-left: 2px solid transparent;
  color: var(--muted);
  padding: 0.5rem 0.75rem;
  cursor: pointer;
  font-size: 13px;
  letter-spacing: 0.06em;
}

nav button:hover {
  color: var(--ink);
}

nav button.active {
  color: var(--ink);
  border-left-color: var(--seal);
  font-weight: 600;
}

nav .spacer {
  flex: 1;
}

nav .hint {
  color: var(--faint);
  font-size: 11px;
  padding: 0.6rem 0.75rem;
  line-height: 1.6;
  font-family: var(--mono);
}

main {
  flex: 1;
  padding: 1.7rem 2rem;
  min-width: 0;
}

.tab {
  display: none;
}

.tab.show {
  display: block;
}

h2 {
  font-family: var(--serif);
  font-size: 16px;
  font-weight: 500;
  color: var(--ink);
  margin: 0 0 1.2rem;
  padding-bottom: 0.55rem;
  border-bottom: 1px solid var(--line);
  letter-spacing: 0.02em;
}

.row {
  display: flex;
  gap: 1.4rem;
  align-items: flex-start;
  flex-wrap: wrap;
}

.col {
  flex: 1;
  min-width: 240px;
}

.list {
  border: 1px solid var(--line);
  background: var(--panel);
}

.list .item {
  padding: 0.5rem 0.7rem;
  cursor: pointer;
  border-bottom: 1px solid var(--line);
  display: flex;
  justify-content: space-between;
  gap: 0.5rem;
  color: var(--ink);
  font-family: var(--mono);
  font-size: 12px;
}

.list .item:last-child {
  border-bottom: none;
}

.list .item:hover {
  background: var(--raise);
}

.list .item.sel {
  background: var(--ink);
  color: var(--paper);
}

.list .item small {
  color: var(--muted);
}

.list .item.sel small {
  color: #c7c0af;
}

textarea,
input,
select {
  font-family: var(--mono);
  font-size: 13px;
  color: var(--ink);
  background: var(--paper);
  border: 1px solid var(--line2);
  border-radius: 0;
  padding: 0.5rem 0.55rem;
  width: 100%;
}

textarea {
  min-height: 46vh;
  resize: vertical;
  white-space: pre;
  tab-size: 2;
  line-height: 1.7;
}

textarea:focus,
input:focus,
select:focus {
  outline: none;
  border-color: var(--ink);
}

label {
  display: block;
  color: var(--muted);
  font-size: 10px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  margin: 0.9rem 0 0.3rem;
  font-family: var(--sans);
}

.btn {
  font-family: var(--sans);
  font-size: 12px;
  cursor: pointer;
  border-radius: 0;
  padding: 0.45rem 0.95rem;
  border: 1px solid var(--ink);
  background: var(--paper);
  color: var(--ink);
  letter-spacing: 0.06em;
}

.btn:hover {
  background: var(--ink);
  color: var(--paper);
}

.btn.primary {
  background: var(--ink);
  color: var(--paper);
}

.btn.primary:hover {
  background: #000;
}

.btn.danger {
  border-color: var(--seal);
  color: var(--seal);
  background: var(--paper);
}

.btn.danger:hover {
  background: var(--seal);
  color: var(--paper);
}

.btn:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}

.bar {
  display: flex;
  gap: 0.6rem;
  margin: 1rem 0;
  flex-wrap: wrap;
  align-items: center;
}

table {
  width: 100%;
  border-collapse: collapse;
  border: 1px solid var(--line);
}

th,
td {
  text-align: left;
  padding: 0.45rem 0.55rem;
  border-bottom: 1px solid var(--line);
}

th {
  font-family: var(--sans);
  color: var(--muted);
  font-size: 10px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  font-weight: 600;
  border-bottom: 1px solid var(--line2);
}

td input {
  border: 1px solid transparent;
  background: transparent;
  padding: 0.3rem;
}

td input:hover {
  border-color: var(--line);
}

td input:focus {
  border-color: var(--ink);
}

.card {
  border: 1px solid var(--line);
  padding: 0.85rem;
  margin-bottom: 0.7rem;
  background: var(--panel);
}

.card .f {
  display: flex;
  gap: 0.7rem;
  align-items: center;
}

.card .f label {
  margin: 0;
  width: 44px;
}

.card .meta {
  color: var(--muted);
  font-size: 11px;
  margin-top: 0.5rem;
  font-family: var(--mono);
}

#toast {
  position: fixed;
  right: 1.5rem;
  bottom: 1.5rem;
  background: var(--paper);
  border: 1px solid var(--ink);
  padding: 0.55rem 0.95rem;
  border-radius: 0;
  font-family: var(--mono);
  font-size: 12px;
  opacity: 0;
  pointer-events: none;
}

#toast.show {
  opacity: 1;
}

#toast::before {
  content: "\2022\00a0\00a0";
  color: var(--ink);
}

#toast.ok::before {
  content: "\2713\00a0\00a0";
  color: var(--ink);
}

#toast.err::before {
  content: "\0021\00a0\00a0";
  color: var(--seal);
}

.empty {
  color: var(--muted);
  padding: 1rem;
  font-style: italic;
  font-family: var(--mono);
  font-size: 12px;
}

.pill {
  font-family: var(--mono);
  font-size: 11px;
  color: var(--muted);
  border: 1px solid var(--line2);
  padding: 0.1rem 0.5rem;
  border-radius: 0;
}

.link-danger {
  color: var(--seal);
}

/* Roland monoFab SRM-20 palette (Freppy tab) */
#tab-freppy {
  --srm-white: #f4f4f2;
  --srm-panel: #ededed;
  --srm-gray: #c8c8c8;
  --srm-gray-dark: #b5b5b5;
  --srm-ink: #2e2e2e;
  --srm-muted: #6b6b6b;
  --srm-faint: #9a9a9a;
  --srm-line: #d0d0d0;
  --srm-orange: #f07d00;
}

nav button.active[data-tab="freppy"] {
  border-left-color: var(--srm-orange);
}

#tab-freppy .card,
#tab-freppy .freppy-setup,
#tab-freppy .freppy-progress,
#tab-freppy .freppy-mcp,
#tab-freppy .freppy-result,
#tab-freppy .freppy-code,
#tab-freppy .freppy-prompt,
#tab-freppy input,
#tab-freppy textarea {
  background: var(--srm-white);
  border-color: var(--srm-line);
  color: var(--srm-ink);
}

#tab-freppy label,
#tab-freppy .meta,
#tab-freppy .freppy-note,
#tab-freppy .freppy-details,
#tab-freppy .freppy-steps {
  color: var(--srm-muted);
}

#tab-freppy .freppy-details summary {
  color: var(--srm-ink);
}

#tab-freppy .btn {
  background: var(--srm-white);
  border-color: var(--srm-ink);
  color: var(--srm-ink);
}

#tab-freppy .btn:hover {
  background: var(--srm-ink);
  color: var(--srm-white);
}

#tab-freppy .btn.primary {
  background: var(--srm-orange);
  border-color: var(--srm-orange);
  color: var(--srm-white);
}

#tab-freppy .btn.primary:hover {
  background: #d96f00;
  border-color: #d96f00;
}

#tab-freppy .btn.danger {
  border-color: var(--srm-orange);
  color: var(--srm-orange);
}

#tab-freppy .btn.danger:hover {
  background: var(--srm-orange);
  color: var(--srm-white);
}

#tab-freppy .freppy-dot.online {
  background: var(--srm-orange);
}

#tab-freppy .freppy-dot.offline {
  background: var(--srm-gray-dark);
}

#tab-freppy .freppy-progress.active {
  border-color: var(--srm-orange);
}

#tab-freppy .freppy-preview-wrap {
  background: var(--srm-gray);
  border-color: var(--srm-gray-dark);
  overflow: hidden;
}

#tab-freppy .freppy-preview-wrap.has-preview img {
  width: 100%;
  height: auto;
  object-fit: contain;
}

#tab-freppy .freppy-result.ok {
  border-color: var(--srm-orange);
}

#tab-freppy .freppy-result.err {
  border-color: var(--srm-orange);
  color: #b85c00;
}

#tab-freppy .list .item.sel {
  border-left-color: var(--srm-orange);
}

#tab-freppy h2 {
  color: var(--srm-ink);
}

.freppy-setup {
  margin-bottom: 1.2rem;
}

.freppy-prompt {
  min-height: 4.5rem;
  resize: vertical;
  margin-bottom: 0.2rem;
}

.freppy-activity-bar {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin-bottom: 0.45rem;
}

.freppy-activity-live {
  font-family: var(--mono);
  font-size: 11px;
  padding: 0.25rem 0.55rem;
  border: 1px solid var(--line);
  background: var(--paper);
  color: var(--muted);
  min-width: 9rem;
  white-space: nowrap;
}

.freppy-activity-live.active {
  border-color: var(--ink);
  color: var(--ink);
}

.freppy-activity-select {
  flex: 1;
  min-width: 0;
  font-family: var(--mono);
  font-size: 11px;
  padding: 0.3rem 0.45rem;
  border: 1px solid var(--line);
  background: var(--paper);
  color: var(--ink);
}

.freppy-activity-details {
  margin-bottom: 0.8rem;
  font-size: 12px;
}

.freppy-activity-details summary {
  cursor: pointer;
  color: var(--muted);
  font-family: var(--sans);
}

#tab-freppy .freppy-activity-live.active {
  border-color: var(--srm-orange);
}

#tab-freppy .freppy-activity-select {
  background: var(--srm-white);
  border-color: var(--srm-line);
}

.freppy-progress {
  margin: 0 0 0.8rem;
  min-height: 4.5rem;
  max-height: 14vh;
  overflow: auto;
  padding: 0.55rem 0.7rem;
  border: 1px solid var(--line);
  background: var(--paper);
  font-family: var(--mono);
  font-size: 11px;
  line-height: 1.5;
  white-space: pre-wrap;
}

.freppy-progress.active {
  border-color: var(--ink);
}

.freppy-pico-opt {
  margin-top: 0.8rem;
  padding-top: 0.4rem;
  border-top: 1px solid var(--line);
}

.freppy-note {
  margin: 0.6rem 0 0;
  color: var(--muted);
  font-size: 12px;
  line-height: 1.6;
}

.freppy-note code {
  font-family: var(--mono);
  font-size: 11px;
}

.freppy-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  flex: none;
  background: var(--faint);
}

.freppy-dot.online {
  background: #3d6b4f;
}

.freppy-dot.offline {
  background: var(--seal);
}

.freppy-details {
  margin-top: 0.8rem;
  font-size: 12px;
  color: var(--muted);
}

.freppy-details summary {
  cursor: pointer;
  color: var(--ink);
  font-family: var(--sans);
  letter-spacing: 0.04em;
}

.freppy-steps {
  margin: 0.6rem 0 0.8rem;
  padding-left: 1.2rem;
  line-height: 1.7;
}

.freppy-steps code {
  font-family: var(--mono);
  font-size: 11px;
}

.freppy-mcp {
  margin: 0;
  padding: 0.7rem;
  background: var(--paper);
  border: 1px solid var(--line);
  font-family: var(--mono);
  font-size: 11px;
  line-height: 1.5;
  overflow-x: auto;
  white-space: pre;
}

.freppy-workspace {
  align-items: stretch;
}

.freppy-code {
  min-height: 42vh;
}

.freppy-preview-col {
  min-width: 260px;
  max-width: 360px;
}

.freppy-preview-wrap {
  border: 1px solid var(--line);
  background: var(--panel);
  min-height: 220px;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
}

.freppy-preview-wrap img {
  max-width: 100%;
  width: 100%;
  height: auto;
  object-fit: contain;
  display: none;
}

.freppy-preview-wrap.has-preview img {
  display: block;
}

.freppy-preview-wrap.has-preview .freppy-preview-empty {
  display: none;
}

.freppy-result {
  margin: 0;
  min-height: 5rem;
  max-height: 22vh;
  overflow: auto;
  padding: 0.6rem 0.7rem;
  border: 1px solid var(--line);
  background: var(--paper);
  font-family: var(--mono);
  font-size: 11px;
  line-height: 1.5;
  white-space: pre-wrap;
}

.freppy-result.ok {
  border-color: #3d6b4f;
}

.freppy-result.err {
  border-color: var(--seal);
  color: var(--seal);
}

/* App builder — drag and drop */
.builder-palette {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin-bottom: 0.35rem;
}

.builder-chip {
  font-family: var(--mono);
  font-size: 11px;
  padding: 0.35rem 0.65rem;
  border: 1px dashed var(--line2);
  background: var(--panel);
  color: var(--ink);
  cursor: grab;
  user-select: none;
}

.builder-chip:hover {
  border-color: var(--ink);
  background: var(--raise);
}

.builder-chip.dragging {
  opacity: 0.55;
  cursor: grabbing;
}

.builder-hint {
  margin: 0 0 0.65rem;
  color: var(--muted);
  font-size: 11px;
  font-family: var(--mono);
}

.builder-canvas {
  min-height: 120px;
  padding: 0.25rem 0;
}

.builder-canvas.builder-drop-before {
  outline: 2px dashed var(--seal);
  outline-offset: 4px;
}

.builder-screen-card {
  margin-bottom: 0.65rem;
  cursor: grab;
}

.builder-screen-card.dragging {
  opacity: 0.45;
}

.builder-screen-card.builder-drop-before {
  box-shadow: inset 0 3px 0 var(--seal);
}

.builder-screen-card.builder-drop-after {
  box-shadow: inset 0 -3px 0 var(--seal);
}

.builder-handle {
  font-family: var(--mono);
  color: var(--muted);
  cursor: grab;
  padding: 0 0.15rem;
  user-select: none;
}

.builder-handle:hover {
  color: var(--ink);
}

.builder-item-row {
  margin-top: 0.35rem;
  padding: 0.15rem 0;
  border-radius: 0;
}

.builder-item-row.dragging {
  opacity: 0.45;
}

.builder-item-row.builder-drop-before {
  box-shadow: inset 0 2px 0 var(--seal);
}

.builder-item-row.builder-drop-after {
  box-shadow: inset 0 -2px 0 var(--seal);
}
API layer (js/api.js)
const POCKETY_PICO_KEY = 'pockety-pico-url';

function isLocalDev() {
  const h = location.hostname;
  return h === 'localhost' || h === '127.0.0.1';
}

function picoBase() {
  const saved = (localStorage.getItem(POCKETY_PICO_KEY) || '').trim().replace(/\/$/, '');
  if (saved) return saved;
  // When served from the Pico itself, API is same-origin.
  if (!isLocalDev()) return '';
  return '';
}

function picoReady() {
  return !!picoBase() || !isLocalDev();
}

function setPicoBase(url) {
  const v = (url || '').trim().replace(/\/$/, '');
  if (v) localStorage.setItem(POCKETY_PICO_KEY, v);
  else localStorage.removeItem(POCKETY_PICO_KEY);
}

function apiUrl(path) {
  const base = picoBase();
  return base ? base + path : path;
}

async function apiJson(path, opts) {
  const r = await fetch(apiUrl(path), opts);
  const text = await r.text();
  if (!text.trim()) {
    if (!r.ok) throw new Error('Pico API ' + r.status);
    return {};
  }
  try {
    return JSON.parse(text);
  } catch (e) {
    if (!r.ok) {
      throw new Error(
        isLocalDev() && !picoBase()
          ? 'Pico not configured — set device URL in the header'
          : 'Pico API ' + r.status + ' (not JSON)'
      );
    }
    throw new Error('invalid JSON from Pico API');
  }
}

const API = {
  async info() {
    return apiJson('/api/info');
  },
  async files() {
    return apiJson('/api/files');
  },
  async read(name) {
    const r = await fetch(apiUrl('/api/read?name=' + encodeURIComponent(name)));
    if (!r.ok) throw new Error('read ' + name + ' -> ' + r.status);
    return r.text();
  },
  async readMaybe(name) {
    const r = await fetch(apiUrl('/api/read?name=' + encodeURIComponent(name)));
    return r.ok ? r.text() : null;
  },
  async write(name, text) {
    const r = await fetch(apiUrl('/api/write?name=' + encodeURIComponent(name)), {
      method: 'POST',
      body: text
    });
    const j = await r.json().catch(() => ({ ok: r.ok }));
    if (!j.ok) throw new Error(j.err || 'write ' + r.status);
    return j;
  },
  async del(name) {
    const r = await fetch(apiUrl('/api/delete?name=' + encodeURIComponent(name)), { method: 'POST' });
    const j = await r.json().catch(() => ({ ok: r.ok }));
    if (!j.ok) throw new Error(j.err || 'delete ' + r.status);
    return j;
  }
};

let TO;

function toast(msg, kind) {
  const t = document.getElementById('toast');
  t.textContent = msg;
  t.className = 'show ' + (kind || '');
  clearTimeout(TO);
  TO = setTimeout(() => (t.className = ''), 2200);
}

function fmtBytes(n) {
  return n > 1024 ? (n / 1024).toFixed(1) + 'k' : n + 'b';
}

function esc(s) {
  return String(s)
    .replace(/&/g, '&amp;')
    .replace(/"/g, '&quot;')
    .replace(/</g, '&lt;');
}

function jsStr(s) {
  return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}

function initPicoUrl() {
  const el = document.getElementById('pico-url');
  if (!el) return;
  el.value = localStorage.getItem(POCKETY_PICO_KEY) || '';
  el.placeholder = isLocalDev() ? 'http://192.168.4.1' : 'same origin';
  el.addEventListener('change', () => {
    setPicoBase(el.value);
    refreshInfo();
    const tab = document.querySelector('nav button.active');
    if (tab) tab.click();
  });
}
Shell & tabs (js/main.js)
document.querySelectorAll('nav button[data-tab]').forEach(b => {
  b.onclick = () => {
    document.querySelectorAll('nav button').forEach(x => x.classList.remove('active'));
    b.classList.add('active');
    document.querySelectorAll('.tab').forEach(t => t.classList.remove('show'));
    document.getElementById('tab-' + b.dataset.tab).classList.add('show');
    sessionStorage.setItem('pockety-tab', b.dataset.tab);
    if (b.dataset.tab === 'books') loadBooks();
    if (b.dataset.tab === 'cards') loadCards();
    if (b.dataset.tab === 'weather') loadCities();
    if (b.dataset.tab === 'audio') loadClips();
    if (b.dataset.tab === 'files') loadFiles();
    if (b.dataset.tab === 'build') buildRender();
    if (b.dataset.tab === 'freppy') initFreppyTab();
  };
});

async function refreshInfo() {
  try {
    const i = await API.info();
    document.getElementById('m-ssid').textContent = i.ssid || '—';
    document.getElementById('m-ip').textContent = i.ip || '—';
    document.getElementById('m-free').textContent =
      i.free != null ? fmtBytes(i.free) + ' / ' + fmtBytes(i.total) : '—';
  } catch (e) {
    toast('device offline?', 'err');
  }
}

initPicoUrl();
if (picoReady()) {
  refreshInfo();
  loadBooks();
} else if (isLocalDev()) {
  document.getElementById('m-ssid').textContent = 'local';
  document.getElementById('m-ip').textContent = 'no pico';
  document.getElementById('m-free').textContent = '—';
}

const savedTab = sessionStorage.getItem('pockety-tab');
if (savedTab) {
  const tabBtn = document.querySelector('nav button[data-tab="' + savedTab + '"]');
  if (tabBtn) tabBtn.click();
}
Books (js/books.js)
let books = [];

async function loadBooks() {
  const all = await API.files();
  books = all.filter(f => f.name.toLowerCase().endsWith('.txt'));
  const el = document.getElementById('book-list');
  if (!books.length) {
    el.innerHTML = '<div class="empty">No .txt books yet</div>';
    return;
  }
  el.innerHTML = books
    .map(
      f =>
        `<div class="item" onclick="openBook('${jsStr(f.name)}',this)">
       <span>${esc(f.name)}</span><small>${fmtBytes(f.size)}</small></div>`
    )
    .join('');
}

async function openBook(name, node) {
  document.querySelectorAll('#book-list .item').forEach(i => i.classList.remove('sel'));
  if (node) node.classList.add('sel');
  document.getElementById('book-name').value = name;
  document.getElementById('book-text').value = await API.read(name);
}

function newBook() {
  document.querySelectorAll('#book-list .item').forEach(i => i.classList.remove('sel'));
  const nameEl = document.getElementById('book-name');
  const textEl = document.getElementById('book-text');
  nameEl.value = '';
  textEl.value = '';
  nameEl.focus();
}

async function saveBook() {
  const n = document.getElementById('book-name').value.trim();
  if (!n) {
    toast('need a filename', 'err');
    return;
  }
  if (!n.toLowerCase().endsWith('.txt')) {
    toast('books should end in .txt', 'err');
    return;
  }
  try {
    await API.write(n, document.getElementById('book-text').value);
    toast('saved ' + n, 'ok');
    loadBooks();
  } catch (e) {
    toast(e.message, 'err');
  }
}

async function delBook() {
  const n = document.getElementById('book-name').value.trim();
  if (!n) return;
  if (!confirm('Delete ' + n + '?')) return;
  try {
    await API.del(n);
    toast('deleted', 'ok');
    newBook();
    loadBooks();
  } catch (e) {
    toast(e.message, 'err');
  }
}
Flashcards (js/cards.js)
const CARDS_FILE = 'flashcards.json';
let cardsDB = null;

async function loadCards() {
  const txt = await API.readMaybe(CARDS_FILE);
  if (txt === null) {
    cardsDB = { baseline: 0, day: 0, decks: [] };
    toast('no flashcards.json yet — new one will be created', '');
  } else {
    try {
      cardsDB = JSON.parse(txt);
    } catch (e) {
      toast('flashcards.json not valid JSON', 'err');
      return;
    }
  }
  if (!Array.isArray(cardsDB.decks)) cardsDB.decks = [];
  const sel = document.getElementById('deck-sel');
  sel.innerHTML = cardsDB.decks
    .map((d, i) => `<option value="${i}">${esc(d.name || 'deck ' + i)}</option>`)
    .join('');
  document.getElementById('card-day').textContent = 'day ' + (cardsDB.day != null ? cardsDB.day : 0);
  renderCards();
}

function curDeck() {
  const i = +document.getElementById('deck-sel').value || 0;
  return cardsDB && cardsDB.decks[i];
}

function renderCards() {
  const wrap = document.getElementById('cards');
  const d = curDeck();
  if (!d) {
    wrap.innerHTML = '<div class="empty">No decks. Add one.</div>';
    return;
  }
  if (!Array.isArray(d.cards)) d.cards = [];
  if (!d.cards.length) {
    wrap.innerHTML = '<div class="empty">Empty deck. Add a card.</div>';
    return;
  }
  wrap.innerHTML = d.cards
    .map(
      (c, i) => `
    <div class="card">
      <div class="f"><label>Front</label>
        <input value="${esc(c.front || '')}" oninput="setCard(${i},'front',this.value)"></div>
      <div class="f" style="margin-top:.4rem"><label>Back</label>
        <input value="${esc(c.back || '')}" oninput="setCard(${i},'back',this.value)"></div>
      <div class="meta">due: ${c.due != null ? c.due : '—'} ·
        <a href="#" class="link-danger" onclick="delCard(${i});return false">delete card</a></div>
    </div>`
    )
    .join('');
}

function setCard(i, key, val) {
  curDeck().cards[i][key] = val;
}

function addCard() {
  const d = curDeck();
  if (!d) {
    toast('add a deck first', 'err');
    return;
  }
  if (!Array.isArray(d.cards)) d.cards = [];
  let nc;
  if (d.cards.length) {
    nc = Object.assign({}, d.cards[0]);
    for (const k in nc) {
      if (typeof nc[k] === 'number') nc[k] = 0;
    }
  } else {
    nc = { front: '', back: '', due: 0 };
  }
  nc.front = '';
  nc.back = '';
  if ('due' in nc) nc.due = cardsDB.day != null ? cardsDB.day : 0;
  d.cards.push(nc);
  renderCards();
}

function delCard(i) {
  curDeck().cards.splice(i, 1);
  renderCards();
}

function addDeck() {
  const name = prompt('New deck name?');
  if (!name) return;
  cardsDB.decks.push({ name: name, cards: [] });
  const sel = document.getElementById('deck-sel');
  sel.innerHTML = cardsDB.decks
    .map((d, i) => `<option value="${i}">${esc(d.name || 'deck ' + i)}</option>`)
    .join('');
  sel.value = cardsDB.decks.length - 1;
  renderCards();
}

async function saveCards() {
  try {
    await API.write(CARDS_FILE, JSON.stringify(cardsDB));
    toast('saved flashcards.json', 'ok');
  } catch (e) {
    toast(e.message, 'err');
  }
}
Weather cities (js/weather.js)
const CITIES_FILE = 'weather.txt';
let cities = [];

async function loadCities() {
  const txt = await API.readMaybe(CITIES_FILE);
  cities = [];
  if (txt) {
    for (const raw of txt.split('\n')) {
      const line = raw.trim();
      if (!line || line.startsWith('#')) continue;
      const parts = line.split(',').map(s => s.trim());
      if (parts.length < 3) continue;
      const lon = parts[parts.length - 1];
      const lat = parts[parts.length - 2];
      const name = parts.slice(0, parts.length - 2).join(', ');
      cities.push({ name, lat, lon });
    }
  }
  renderCities();
}

function renderCities() {
  const tb = document.getElementById('city-rows');
  if (!cities.length) {
    tb.innerHTML = '<tr><td colspan=4 class="empty">No cities. Add one.</td></tr>';
    return;
  }
  tb.innerHTML = cities
    .map(
      (c, i) => `
    <tr>
      <td><input value="${esc(c.name)}" oninput="cities[${i}].name=this.value"></td>
      <td><input value="${esc(c.lat)}" oninput="cities[${i}].lat=this.value"></td>
      <td><input value="${esc(c.lon)}" oninput="cities[${i}].lon=this.value"></td>
      <td><a href="#" class="link-danger" onclick="cities.splice(${i},1);renderCities();return false">✕</a></td>
    </tr>`
    )
    .join('');
}

function addCity() {
  cities.push({ name: 'New city', lat: '0', lon: '0' });
  renderCities();
}

async function saveCities() {
  let out = '# Cities for the weather app\n# Format: Name,latitude,longitude\n';
  for (const c of cities) {
    if (!c.name.trim()) continue;
    out += `${c.name},${c.lat},${c.lon}\n`;
  }
  try {
    await API.write(CITIES_FILE, out);
    toast('saved weather.txt', 'ok');
  } catch (e) {
    toast(e.message, 'err');
  }
}
All files (js/files.js)
async function loadFiles() {
  const all = await API.files();
  const el = document.getElementById('file-list');
  el.innerHTML =
    all
      .map(
        f =>
          `<div class="item" onclick="openFile('${jsStr(f.name)}',this)">
       <span>${esc(f.name)}</span><small>${fmtBytes(f.size)}</small></div>`
      )
      .join('') || '<div class="empty">No files</div>';
}

async function openFile(name, node) {
  document.querySelectorAll('#file-list .item').forEach(i => i.classList.remove('sel'));
  if (node) node.classList.add('sel');
  document.getElementById('file-name').value = name;
  document.getElementById('file-text').value = await API.read(name);
}

function newFile() {
  document.querySelectorAll('#file-list .item').forEach(i => i.classList.remove('sel'));
  const nameEl = document.getElementById('file-name');
  const textEl = document.getElementById('file-text');
  nameEl.value = '';
  textEl.value = '';
  nameEl.focus();
}

async function saveFile() {
  const n = document.getElementById('file-name').value.trim();
  if (!n) {
    toast('need a filename', 'err');
    return;
  }
  try {
    await API.write(n, document.getElementById('file-text').value);
    toast('saved ' + n, 'ok');
    loadFiles();
  } catch (e) {
    toast(e.message, 'err');
  }
}

async function delFile() {
  const n = document.getElementById('file-name').value.trim();
  if (!n) return;
  if (!confirm('Delete ' + n + '?')) return;
  try {
    await API.del(n);
    toast('deleted', 'ok');
    newFile();
    loadFiles();
  } catch (e) {
    toast(e.message, 'err');
  }
}
Voice clips (js/audio.js)
const AUDIO_RATE = 16000;
let audioCtx = null;

function ac() {
  if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  return audioCtx;
}

async function loadClips() {
  const all = await API.files();
  const clips = all.filter(f => /\.raw$/i.test(f.name));
  const tb = document.getElementById('clip-rows');
  if (!clips.length) {
    tb.innerHTML =
      '<tr><td colspan=4 class="empty">No clips yet. Triple-click on the recorder to send one.</td></tr>';
    return;
  }
  tb.innerHTML = clips
    .map(f => {
      const secs = (f.size / 2 / AUDIO_RATE).toFixed(1);
      return `<tr>
      <td>${esc(f.name)}</td>
      <td>${secs}s</td>
      <td>${fmtBytes(f.size)}</td>
      <td>
        <button class="btn" onclick="playClip('${jsStr(f.name)}')">Play</button>
        <button class="btn" onclick="dlClip('${jsStr(f.name)}')">WAV</button>
        <button class="btn danger" onclick="delClip('${jsStr(f.name)}')">Del</button>
      </td></tr>`;
    })
    .join('');
}

async function clipPCM(name) {
  const r = await fetch('/api/read?name=' + encodeURIComponent(name));
  if (!r.ok) throw new Error('read ' + name);
  return new Int16Array(await r.arrayBuffer());
}

async function playClip(name) {
  try {
    const pcm = await clipPCM(name);
    const ctx = ac();
    await ctx.resume();
    const ab = ctx.createBuffer(1, pcm.length, AUDIO_RATE);
    const ch = ab.getChannelData(0);
    for (let i = 0; i < pcm.length; i++) ch[i] = pcm[i] / 32768;
    const src = ctx.createBufferSource();
    src.buffer = ab;
    src.connect(ctx.destination);
    src.start();
    toast('playing ' + name, 'ok');
  } catch (e) {
    toast(e.message, 'err');
  }
}

function wavBlob(pcm) {
  const dataLen = pcm.length * 2;
  const buf = new ArrayBuffer(44 + dataLen);
  const dv = new DataView(buf);
  let p = 0;
  const ws = s => {
    for (let i = 0; i < s.length; i++) dv.setUint8(p++, s.charCodeAt(i));
  };
  const u32 = v => {
    dv.setUint32(p, v, true);
    p += 4;
  };
  const u16 = v => {
    dv.setUint16(p, v, true);
    p += 2;
  };
  ws('RIFF');
  u32(36 + dataLen);
  ws('WAVE');
  ws('fmt ');
  u32(16);
  u16(1);
  u16(1);
  u32(AUDIO_RATE);
  u32(AUDIO_RATE * 2);
  u16(2);
  u16(16);
  ws('data');
  u32(dataLen);
  new Int16Array(buf, 44).set(pcm);
  return new Blob([buf], { type: 'audio/wav' });
}

async function dlClip(name) {
  try {
    const pcm = await clipPCM(name);
    const url = URL.createObjectURL(wavBlob(pcm));
    const a = document.createElement('a');
    a.href = url;
    a.download = name.replace(/\.raw$/i, '') + '.wav';
    a.click();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  } catch (e) {
    toast(e.message, 'err');
  }
}

async function delClip(name) {
  if (!confirm('Delete ' + name + '?')) return;
  try {
    await API.del(name);
    toast('deleted', 'ok');
    loadClips();
  } catch (e) {
    toast(e.message, 'err');
  }
}
App builder (js/builder.js)
const RT = `"""
%%APPNAME%% -- generated by the Pockety app builder.

Encoder: rotate = move / page, click = open (back on a text screen),
double-click = back, long-press = exit to launcher.
"""
from machine import Pin, I2C
from epd2in13 import EPD
from pico_ui_input import InputManager

try:
    from ssd1306 import SSD1306_I2C
    _i2c = I2C(0, sda=Pin(16), scl=Pin(17), freq=400_000)
    oled = SSD1306_I2C(128, 64, _i2c)
    OLED_OK = True
except Exception as e:
    print('OLED unavailable:', e)
    oled = None
    OLED_OK = False

_CW, _LH, _EMG = 8, 10, 3
_PARTIALS_BEFORE_FULL = 15
_EW, _EH = 122, 250

SCREENS = %%SCREENS%%
START = %%START%%


class _Refresher:
    def __init__(self, epd):
        self.epd = epd
        self.partials = 0

    def push(self, force_full=False):
        if force_full or self.partials == 0 or self.partials >= _PARTIALS_BEFORE_FULL:
            self.epd.display_full()
            self.partials = 1
        else:
            self.epd.display_partial()
            self.partials += 1


def _wrap(s, width):
    out = []
    for para in s.split('\\n'):
        if not para:
            out.append('')
            continue
        line = ''
        for word in para.split(' '):
            if line and len(line) + 1 + len(word) > width:
                out.append(line)
                line = word
            else:
                line = word if not line else line + ' ' + word
        out.append(line)
    return out


class App:
    def __init__(self):
        global _EW, _EH
        self.epd = EPD()
        _EW, _EH = self.epd.WIDTH, self.epd.HEIGHT
        self.r = _Refresher(self.epd)
        self.ui = InputManager()
        self.ui.add_encoder('dial', clk=2, dt=3, sw=4,
                            long_press=True, double_click=True)
        self.stack = []
        self.cur = START
        self.sel = 0
        self.page = 0
        self.lines = []
        self.cols = (_EW - 2 * _EMG) // _CW
        self.rows_menu = max(1, (_EH - 2 * (_EMG + _LH) - 6) // (_LH + 4))
        self.rows_text = max(1, (_EH - 2 * (_EMG + _LH) - 6) // _LH)

    def _enter(self):
        self.sel = 0
        self.page = 0
        sc = SCREENS[self.cur]
        if sc.get('type') == 'text':
            self.lines = _wrap(sc.get('body', ''), self.cols)
        else:
            self.lines = []

    def _pages(self):
        n = len(self.lines)
        return max(1, (n + self.rows_text - 1) // self.rows_text)

    def _oled(self):
        if not OLED_OK:
            return
        oled.fill(0)
        oled.fill_rect(0, 0, 128, 11, 1)
        oled.text('%%APPNAME%%'[:15], 2, 2, 0)
        t = SCREENS[self.cur].get('title', '')
        oled.text(t[:16], 0, 24, 1)
        oled.text('hold = exit', 0, 54, 1)
        oled.show()

    def draw(self, partial=False):
        fb = self.epd.fb
        fb.fill(1)
        sc = SCREENS[self.cur]
        title = sc.get('title', '')
        fb.text(title[:self.cols], _EMG, _EMG, 0)
        fb.hline(0, _EMG + _LH, _EW, 0)
        top = _EMG + _LH + 4
        foot = _EH - _EMG - 8
        fb.hline(0, foot - 2, _EW, 0)

        if sc.get('type') == 'menu':
            items = sc.get('items', [])
            n = len(items)
            rows = self.rows_menu
            first = 0
            if self.sel >= rows:
                first = self.sel - rows + 1
            for i in range(first, min(n, first + rows)):
                y = top + (i - first) * (_LH + 4)
                lbl = items[i].get('label', '')[:self.cols - 2]
                if i == self.sel:
                    fb.fill_rect(_EMG - 1, y - 2, _EW - 2 * (_EMG - 1), _LH + 2, 0)
                    fb.text('>', _EMG, y, 1)
                    fb.text(lbl, _EMG + _CW + 2, y, 1)
                else:
                    fb.text(lbl, _EMG + _CW + 2, y, 0)
            fb.text('click open', _EMG, _EH - _EMG - 8, 0)
        else:
            rows = self.rows_text
            start = self.page * rows
            for i in range(start, min(len(self.lines), start + rows)):
                y = top + (i - start) * _LH
                fb.text(self.lines[i][:self.cols], _EMG, y, 0)
            ps = '{}/{}'.format(self.page + 1, self._pages())
            fb.text(ps, _EW - _EMG - len(ps) * _CW, _EH - _EMG - 8, 0)

        self.r.push(force_full=not partial)

    def run(self):
        self._enter()
        self.draw()
        self._oled()
        try:
            while True:
                a = self.ui.wait()
                if a == 'dial_long':
                    return
                if a == 'dial_double':
                    if self.stack:
                        self.cur = self.stack.pop()
                        self._enter()
                        self.draw()
                        self._oled()
                    else:
                        return
                    continue
                sc = SCREENS[self.cur]
                if sc.get('type') == 'menu':
                    items = sc.get('items', [])
                    if not items:
                        continue
                    if a == 'dial+':
                        self.sel = (self.sel + 1) % len(items)
                        self.draw(partial=True)
                    elif a == 'dial-':
                        self.sel = (self.sel - 1) % len(items)
                        self.draw(partial=True)
                    elif a == 'dial':
                        to = items[self.sel].get('to', 0)
                        if 0 <= to < len(SCREENS):
                            self.stack.append(self.cur)
                            self.cur = to
                            self._enter()
                            self.draw()
                            self._oled()
                else:
                    if a == 'dial+':
                        self.page = (self.page + 1) % self._pages()
                        self.draw(partial=True)
                    elif a == 'dial-':
                        self.page = (self.page - 1) % self._pages()
                        self.draw(partial=True)
                    elif a == 'dial':
                        if self.stack:
                            self.cur = self.stack.pop()
                            self._enter()
                            self.draw()
                            self._oled()
        finally:
            if OLED_OK:
                oled.fill(0)
                oled.show()


def main():
    App().run()


if __name__ == '__main__':
    main()
`;

let screens = [{ type: 'menu', title: 'Main', items: [] }];

function pyStr(s) {
  s = String(s)
    .replace(/\\/g, '\\\\')
    .replace(/'/g, "\\'")
    .replace(/\r/g, '')
    .replace(/\n/g, '\\n');
  return "'" + s + "'";
}

function pyScreens(scr) {
  const parts = scr.map(s => {
    if (s.type === 'menu') {
      const items = (s.items || [])
        .map(
          it =>
            `{'label': ${pyStr(it.label || '')}, 'to': ${Number.isInteger(it.to) ? it.to : 0}}`
        )
        .join(', ');
      return `  {'type': 'menu', 'title': ${pyStr(s.title || '')}, 'items': [${items}]}`;
    }
    return `  {'type': 'text', 'title': ${pyStr(s.title || '')}, 'body': ${pyStr(s.body || '')}}`;
  });
  return '[\n' + parts.join(',\n') + '\n]';
}

function safeName(n) {
  return String(n).replace(/[^\w \-]/g, '').slice(0, 24) || 'App';
}

function buildAppCode(name, start, scr) {
  return RT.replace(/%%APPNAME%%/g, safeName(name))
    .replace('%%SCREENS%%', pyScreens(scr))
    .replace('%%START%%', String(start | 0));
}

function screenOptions(to) {
  return screens
    .map(
      (s, i) =>
        `<option value="${i}" ${i === to ? 'selected' : ''}>${i}: ${esc(s.title || s.type)}</option>`
    )
    .join('');
}

let builderDrag = null;

function remapScreenTargets(from, to) {
  screens.forEach(s => {
    if (!s.items) return;
    s.items.forEach(it => {
      let t = it.to | 0;
      if (t === from) it.to = to;
      else if (from < to && t > from && t <= to) it.to = t - 1;
      else if (from > to && t >= to && t < from) it.to = t + 1;
    });
  });
}

function reorderScreens(from, to) {
  if (from === to || from < 0 || to < 0 || from >= screens.length) return;
  const item = screens.splice(from, 1)[0];
  const insertAt = to > from ? to - 1 : to;
  screens.splice(insertAt, 0, item);
  remapScreenTargets(from, insertAt);
}

function reorderMenuItem(si, from, to) {
  const items = screens[si] && screens[si].items;
  if (!items || from === to || from < 0 || to < 0 || from >= items.length) return;
  const item = items.splice(from, 1)[0];
  const insertAt = to > from ? to - 1 : to;
  items.splice(insertAt, 0, item);
}

function insertScreenAt(type, index) {
  const entry =
    type === 'menu' ? { type: 'menu', title: '', items: [] } : { type: 'text', title: '', body: '' };
  const at = Math.max(0, Math.min(index | 0, screens.length));
  screens.splice(at, 0, entry);
  screens.forEach(s => {
    if (!s.items) return;
    s.items.forEach(it => {
      if ((it.to | 0) >= at) it.to = (it.to | 0) + 1;
    });
  });
}

function builderDropIndex(container, clientY) {
  const cards = [...container.querySelectorAll('.builder-screen-card')];
  if (!cards.length) return 0;
  for (let i = 0; i < cards.length; i++) {
    const r = cards[i].getBoundingClientRect();
    if (clientY < r.top + r.height / 2) return i;
  }
  return cards.length;
}

function builderItemDropIndex(list, clientY) {
  const rows = [...list.querySelectorAll('.builder-item-row')];
  if (!rows.length) return 0;
  for (let i = 0; i < rows.length; i++) {
    const r = rows[i].getBoundingClientRect();
    if (clientY < r.top + r.height / 2) return i;
  }
  return rows.length;
}

function builderClearDropMarks() {
  document.querySelectorAll('.builder-drop-before, .builder-drop-after').forEach(el => {
    el.classList.remove('builder-drop-before', 'builder-drop-after');
  });
}

function builderBindDrag() {
  const canvas = document.getElementById('b-screens');
  if (!canvas || canvas.dataset.dragBound) return;
  canvas.dataset.dragBound = '1';

  document.querySelectorAll('.builder-chip').forEach(chip => {
    chip.addEventListener('dragstart', e => {
      builderDrag = { kind: 'palette', type: chip.dataset.screenType };
      e.dataTransfer.effectAllowed = 'copy';
      e.dataTransfer.setData('text/plain', chip.dataset.screenType);
      chip.classList.add('dragging');
    });
    chip.addEventListener('dragend', () => {
      chip.classList.remove('dragging');
      builderDrag = null;
      builderClearDropMarks();
    });
  });

  canvas.addEventListener('dragover', e => {
    if (!builderDrag) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = builderDrag.kind === 'palette' ? 'copy' : 'move';
    builderClearDropMarks();
    const idx = builderDropIndex(canvas, e.clientY);
    const cards = canvas.querySelectorAll('.builder-screen-card');
    if (cards[idx]) cards[idx].classList.add('builder-drop-before');
    else if (cards.length) cards[cards.length - 1].classList.add('builder-drop-after');
    else canvas.classList.add('builder-drop-before');
  });

  canvas.addEventListener('dragleave', e => {
    if (!canvas.contains(e.relatedTarget)) builderClearDropMarks();
  });

  canvas.addEventListener('drop', e => {
    e.preventDefault();
    builderClearDropMarks();
    if (!builderDrag) return;
    const idx = builderDropIndex(canvas, e.clientY);
    if (builderDrag.kind === 'palette') {
      insertScreenAt(builderDrag.type, idx);
      buildRender();
    } else if (builderDrag.kind === 'screen') {
      reorderScreens(builderDrag.si, idx);
      buildRender();
    }
    builderDrag = null;
  });
}

function buildRender() {
  const wrap = document.getElementById('b-screens');
  wrap.innerHTML = screens
    .map((s, si) => {
      let inner = '';
      if (s.type === 'menu') {
        const items = (s.items || [])
          .map(
            (it, ii) => `
        <div class="f builder-item-row" data-si="${si}" data-ii="${ii}" draggable="true">
          <span class="builder-handle" title="Drag to reorder">⠿</span>
          <input value="${esc(it.label || '')}" placeholder="item label"
                 oninput="screens[${si}].items[${ii}].label=this.value">
          <select style="max-width:140px" onchange="screens[${si}].items[${ii}].to=+this.value">${screenOptions(it.to || 0)}</select>
          <a href="#" class="link-danger" onclick="screens[${si}].items.splice(${ii},1);buildRender();return false">&times;</a>
        </div>`
          )
          .join('');
        inner = `<div class="builder-items" data-si="${si}">${items}</div>
        <div class="bar"><button class="btn" onclick="addItem(${si})">+ item</button></div>`;
      } else {
        inner = `<label>Body text</label>
        <textarea style="min-height:120px" oninput="screens[${si}].body=this.value">${esc(s.body || '')}</textarea>`;
      }
      return `<div class="card builder-screen-card" data-si="${si}" draggable="true">
      <div class="f builder-screen-head">
        <span class="builder-handle" title="Drag to reorder screen">⠿</span>
        <span class="pill">${si}</span>
        <select style="max-width:90px" onchange="screens[${si}].type=this.value;buildRender()">
          <option value="menu" ${s.type === 'menu' ? 'selected' : ''}>menu</option>
          <option value="text" ${s.type === 'text' ? 'selected' : ''}>text</option>
        </select>
        <input value="${esc(s.title || '')}" placeholder="screen title"
               oninput="screens[${si}].title=this.value">
        <a href="#" class="link-danger" onclick="delScreen(${si});return false">remove</a>
      </div>
      ${inner}
    </div>`;
    })
    .join('');

  wrap.querySelectorAll('.builder-screen-card').forEach(card => {
    const si = +card.dataset.si;
    card.addEventListener('dragstart', e => {
      if (e.target.closest('input, textarea, select, a, button')) {
        e.preventDefault();
        return;
      }
      builderDrag = { kind: 'screen', si };
      e.dataTransfer.effectAllowed = 'move';
      e.dataTransfer.setData('text/plain', 'screen:' + si);
      card.classList.add('dragging');
    });
    card.addEventListener('dragend', () => {
      card.classList.remove('dragging');
      builderDrag = null;
      builderClearDropMarks();
    });
  });

  wrap.querySelectorAll('.builder-item-row').forEach(row => {
    const si = +row.dataset.si;
    const ii = +row.dataset.ii;
    row.addEventListener('dragstart', e => {
      if (e.target.closest('input, select, a')) {
        e.preventDefault();
        return;
      }
      builderDrag = { kind: 'item', si, ii };
      e.dataTransfer.effectAllowed = 'move';
      e.dataTransfer.setData('text/plain', 'item:' + si + ':' + ii);
      row.classList.add('dragging');
      e.stopPropagation();
    });
    row.addEventListener('dragend', () => {
      row.classList.remove('dragging');
      builderDrag = null;
      builderClearDropMarks();
    });
    row.addEventListener('dragover', e => {
      if (!builderDrag || builderDrag.kind !== 'item' || builderDrag.si !== si) return;
      e.preventDefault();
      e.stopPropagation();
      builderClearDropMarks();
      const list = row.parentElement;
      const idx = builderItemDropIndex(list, e.clientY);
      const rows = list.querySelectorAll('.builder-item-row');
      if (rows[idx]) rows[idx].classList.add('builder-drop-before');
      else if (rows.length) rows[rows.length - 1].classList.add('builder-drop-after');
    });
    row.addEventListener('drop', e => {
      if (!builderDrag || builderDrag.kind !== 'item' || builderDrag.si !== si) return;
      e.preventDefault();
      e.stopPropagation();
      builderClearDropMarks();
      const list = row.parentElement;
      const to = builderItemDropIndex(list, e.clientY);
      reorderMenuItem(si, builderDrag.ii, to);
      buildRender();
      builderDrag = null;
    });
  });

  builderBindDrag();
}

function addScreen(t) {
  screens.push(t === 'menu' ? { type: 'menu', title: '', items: [] } : { type: 'text', title: '', body: '' });
  buildRender();
}

function delScreen(i) {
  if (screens.length <= 1) {
    toast('keep at least one screen', 'err');
    return;
  }
  screens.splice(i, 1);
  buildRender();
}

function addItem(si) {
  if (!screens[si].items) screens[si].items = [];
  screens[si].items.push({ label: 'Item', to: 0 });
  buildRender();
}

function genCode() {
  const name = document.getElementById('b-name').value;
  document.getElementById('b-code').value = buildAppCode(name, 0, screens);
  toast('generated', 'ok');
}

async function pushApp() {
  let file = document.getElementById('b-file').value.trim();
  if (!/\.py$/.test(file)) {
    toast('filename must end .py', 'err');
    return;
  }
  if (file.includes('/') || file.includes('\\')) {
    toast('no slashes in filename', 'err');
    return;
  }
  const name = document.getElementById('b-name').value;
  const code = buildAppCode(name, 0, screens);
  document.getElementById('b-code').value = code;
  try {
    await API.write(file, code);
    toast('pushed ' + file, 'ok');
    if (document.getElementById('b-launch').checked) await addToLauncher(safeName(name), file);
  } catch (e) {
    toast(e.message, 'err');
  }
}

if (document.getElementById('b-screens')) buildRender();

async function addToLauncher(name, file) {
  const src = await API.readMaybe('main.py');
  if (src === null) {
    toast('no main.py to register in', 'err');
    return;
  }
  if (src.indexOf("'" + file + "'") >= 0) {
    toast('already in launcher', 'ok');
    return;
  }
  const m = src.match(/APPS\s*=\s*\[/);
  if (!m) {
    toast('APPS list not found in main.py', 'err');
    return;
  }
  let close = src.indexOf('\n]', m.index);
  if (close < 0) {
    toast("couldn't find end of APPS list", 'err');
    return;
  }
  const entry = "\n    ('" + name + "', '" + file + "', True),";
  const out = src.slice(0, close) + entry + src.slice(close);
  await API.write('main.py', out);
  toast('added to launcher', 'ok');
}
Freppy PCB (js/freppy.js)
const FREPPY_LOCAL = 'http://127.0.0.1:8766';
const FREPPY_MCP_URL = FREPPY_LOCAL + '/mcp';
const FREPPY_STORE_KEY = 'freppy-designs';
const FREPPY_SESSION_KEY = 'freppy-session';

let freppyOnline = false;
let freppyDesigns = [];
let freppyTabReady = false;
let freppyRpcId = 0;
let freppyDesigning = false;
let freppyActivityEntries = [];

function freppyEl(id) {
  return document.getElementById(id);
}

// --- Direct MCP-over-HTTP client. The browser is an MCP client of the same
// frep-pcb server Claude Code uses — no separate REST bridge. The server runs
// stateless streamable-http with JSON responses, so a plain fetch() suffices. ---

function freppyParseMcp(text) {
  const t = text.trim();
  if (t.startsWith('{') || t.startsWith('[')) return JSON.parse(t);
  // SSE fallback: pull JSON from the last `data:` line
  let last = null;
  for (const line of t.split('\n')) {
    const s = line.trim();
    if (s.startsWith('data:')) last = s.slice(5).trim();
  }
  if (last) return JSON.parse(last);
  throw new Error('unexpected MCP response');
}

async function freppyRpc(method, params, opts) {
  const isNotification = method.startsWith('notifications/');
  const body = { jsonrpc: '2.0', method, params: params || {} };
  if (!isNotification) body.id = ++freppyRpcId;
  const r = await fetch(FREPPY_MCP_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json, text/event-stream'
    },
    body: JSON.stringify(body)
  });
  const text = await r.text();
  if (!text.trim()) return null;
  const msg = freppyParseMcp(text);
  if (msg.error) throw new Error(msg.error.message || 'MCP error');
  return msg.result;
}

async function freppyTool(name, args) {
  const result = await freppyRpc('tools/call', { name, arguments: args || {} });
  const content = (result && result.content) || [];
  if (result && result.isError) {
    const t = content.find(c => c.type === 'text');
    throw new Error(t ? t.text : 'tool error');
  }
  return content;
}

function freppyToolText(content) {
  const t = content.find(c => c.type === 'text');
  return t ? t.text : '';
}

function freppyToolImage(content) {
  const im = content.find(c => c.type === 'image');
  return im ? im.data : null;
}

function freppyShowPreview(b64OrDataUrl) {
  if (!b64OrDataUrl) return;
  const wrap = freppyEl('freppy-preview-wrap');
  const img = freppyEl('freppy-preview');
  const url = String(b64OrDataUrl).startsWith('data:')
    ? b64OrDataUrl
    : 'data:image/png;base64,' + b64OrDataUrl;
  img.src = url;
  wrap.classList.add('has-preview');
}

function freppyClearPreview() {
  const wrap = freppyEl('freppy-preview-wrap');
  const img = freppyEl('freppy-preview');
  img.removeAttribute('src');
  wrap.classList.remove('has-preview');
}

async function freppyRenderPreview(code, dpi) {
  const content = await freppyTool('render_pcb', { code, dpi });
  const b64 = freppyToolImage(content);
  if (!b64) throw new Error(freppyToolText(content) || 'no image returned');
  freppyShowPreview(b64);
  return b64;
}

function freppySanitizeCode(code) {
  let s = String(code || '').trim();
  // Strip markdown fences Claude often wraps around generated code.
  const fenced = s.match(/^```(?:python|py)?\s*\n([\s\S]*?)\n```$/i);
  if (fenced) s = fenced[1].trim();
  else if (s.startsWith('```')) {
    s = s.replace(/^```(?:python|py)?\s*\n?/i, '').replace(/\n?```$/, '').trim();
  }
  return s;
}

function freppyCodeError(err) {
  const msg = String(err.message || err);
  if (/invalid syntax/i.test(msg)) {
    return (
      'Invalid Python in the editor.\n\n' +
      'Load Template or Example first, or paste only the Python design code ' +
      '(no ```python fences or plain English).\n\n' +
      msg
    );
  }
  return msg;
}

function freppyLoadStore() {
  try {
    return JSON.parse(localStorage.getItem(FREPPY_STORE_KEY) || '[]');
  } catch (e) {
    return [];
  }
}

function freppySaveStore(list) {
  localStorage.setItem(FREPPY_STORE_KEY, JSON.stringify(list));
}

function freppyCurrentName() {
  return freppyEl('freppy-name').value.trim() || 'untitled.py';
}

function freppyUpsertLocal(name, code) {
  const list = freppyLoadStore();
  const i = list.findIndex(d => d.name === name);
  const entry = { name, code, updated: Date.now() };
  if (i >= 0) list[i] = entry;
  else list.push(entry);
  list.sort((a, b) => b.updated - a.updated);
  freppySaveStore(list);
  return list;
}

async function checkFreppyBridge() {
  const dot = freppyEl('freppy-status');
  const hint = freppyEl('freppy-status-hint');
  try {
    const info = await freppyRpc('initialize', {
      protocolVersion: '2025-06-18',
      capabilities: {},
      clientInfo: { name: 'pockety-freppy', version: '1' }
    });
    await freppyRpc('notifications/initialized');
    freppyOnline = true;
    dot.className = 'freppy-dot online';
    const si = (info && info.serverInfo) || {};
    hint.textContent = 'frep-pcb MCP connected (direct)';
    freppyEl('freppy-dir').textContent = (si.name || 'frep-pcb') + ' @ ' + FREPPY_MCP_URL;
    return true;
  } catch (e) {
    freppyOnline = false;
    dot.className = 'freppy-dot offline';
    hint.textContent = 'frep-pcb MCP offline — run scripts/start-freppy-bridge.sh';
    freppyEl('freppy-dir').textContent = '—';
    return false;
  }
}

function freppyMcpConfig() {
  const root = freppyEl('freppy-project-root').value.trim() || '~/interface';
  const freppy = root.replace(/\/$/, '') + '/freppy';
  return JSON.stringify(
    {
      mcpServers: {
        'frep-pcb': {
          command: freppy + '/.venv/bin/python',
          args: [freppy + '/mcp_server.py']
        }
      }
    },
    null,
    2
  );
}

function copyFreppyMcp() {
  navigator.clipboard.writeText(freppyMcpConfig()).then(
    () => toast('MCP config copied', 'ok'),
    () => toast('copy failed', 'err')
  );
}

function freppySaveSession() {
  try {
    const preview = freppyEl('freppy-preview');
    sessionStorage.setItem(
      FREPPY_SESSION_KEY,
      JSON.stringify({
        prompt: freppyEl('freppy-prompt').value,
        code: freppyEl('freppy-code').value,
        name: freppyEl('freppy-name').value,
        dpi: freppyEl('freppy-dpi').value,
        progress: freppyEl('freppy-progress') ? freppyEl('freppy-progress').textContent : '',
        activity: freppyActivityEntries,
        result: freppyEl('freppy-result').textContent,
        preview: preview && preview.src && preview.src.startsWith('data:') ? preview.src : ''
      })
    );
  } catch (e) {
    /* quota — ignore */
  }
}

function freppyRestoreSession() {
  try {
    const s = JSON.parse(sessionStorage.getItem(FREPPY_SESSION_KEY) || 'null');
    if (!s) return;
    if (s.prompt != null) freppyEl('freppy-prompt').value = s.prompt;
    if (s.code != null) freppyEl('freppy-code').value = s.code;
    if (s.name != null) freppyEl('freppy-name').value = s.name;
    if (s.dpi != null) freppyEl('freppy-dpi').value = s.dpi;
    if (s.progress != null && freppyEl('freppy-progress')) {
      freppyEl('freppy-progress').textContent = s.progress;
    }
    if (s.activity && Array.isArray(s.activity)) {
      freppyActivityEntries = s.activity;
      const sel = freppyEl('freppy-activity-log');
      if (sel) {
        sel.innerHTML = '<option value="">— function log —</option>';
        s.activity.slice().reverse().forEach((entry, i) => {
          const opt = document.createElement('option');
          opt.value = String(s.activity.length - 1 - i);
          opt.textContent =
            entry.function + ' — ' + String(entry.message || '').slice(0, 72);
          sel.appendChild(opt);
        });
        if (s.activity.length) {
          sel.selectedIndex = 1;
          freppyEl('freppy-activity-live').textContent =
            s.activity[s.activity.length - 1].function;
          freppyEl('freppy-activity-live').classList.add('active');
        }
      }
    }
    if (s.result != null) {
      freppyEl('freppy-result').textContent = s.result;
      freppyEl('freppy-result').className = s.result.startsWith('VALID')
        ? 'freppy-result ok'
        : 'freppy-result';
    }
    if (s.preview) freppyShowPreview(s.preview);
  } catch (e) {
    /* ignore */
  }
}

function freppyInferFunction(message) {
  const m = String(message || '').toLowerCase();
  if (m.includes('mcp tools') || m.includes('session started')) return 'claude_init()';
  if (m.includes('validating and rendering')) return 'validate_design()';
  if (m.startsWith('→')) return 'mcp_tool()';
  if (m.includes('write design')) return 'claude_generate()';
  if (m.includes('fix drc') || m.includes('repair')) return 'claude_repair()';
  if (m.includes('drc failed') || m.includes('drc still')) return 'drc_retry()';
  if (m.includes('validating')) return 'validate_design()';
  if (m.includes('rendering') || m.includes('rendered')) return 'render_pcb()';
  if (m.includes('routing fixes')) return 'auto_fix_routing()';
  if (m.includes('session started') || m.includes('claude finished')) return 'claude_init()';
  if (m.includes('error')) return 'error()';
  if (m.includes('starting design')) return 'init()';
  return 'claude_think()';
}

function freppyResetActivity() {
  freppyActivityEntries = [];
  const live = freppyEl('freppy-activity-live');
  const sel = freppyEl('freppy-activity-log');
  if (live) {
    live.textContent = 'idle';
    live.classList.remove('active');
  }
  if (sel) {
    sel.innerHTML = '<option value="">— function log —</option>';
  }
}

function freppyRecordActivity(message, ev) {
  const fn = (ev && ev.function) || freppyInferFunction(message);
  const entry = {
    function: fn,
    message: String(message || ''),
    attempt: ev && ev.attempt,
    t: Date.now()
  };
  freppyActivityEntries.push(entry);

  const live = freppyEl('freppy-activity-live');
  if (live) {
    live.textContent = fn;
    live.classList.add('active');
  }

  const sel = freppyEl('freppy-activity-log');
  if (sel) {
    const opt = document.createElement('option');
    opt.value = String(freppyActivityEntries.length - 1);
    const preview = entry.message.replace(/\s+/g, ' ').slice(0, 72);
    opt.textContent = fn + ' — ' + preview;
    sel.insertBefore(opt, sel.options[1] || null);
    sel.selectedIndex = 1;
  }

  freppyAppendProgress('[' + fn + '] ' + message);
}

function freppyAppendProgress(line) {
  const el = freppyEl('freppy-progress');
  if (!el) return;
  el.classList.add('active');
  const prev = el.textContent;
  el.textContent = prev ? prev + '\n' + line : line;
  el.scrollTop = el.scrollHeight;
  freppySaveSession();
}

async function freppyApplyDesignResult(j) {
  const code = freppySanitizeCode(j.code || '');
  if (!code) throw new Error('no code returned');

  freppyEl('freppy-code').value = code;
  let resultText = j.validation || 'done';
  if (j.attempts) resultText += '\n\nDRC attempts: ' + j.attempts;
  if (j.turns) resultText += '\nClaude turns: ' + j.turns;
  const out = freppyEl('freppy-result');
  out.textContent = resultText;
  out.className = (j.validation || '').startsWith('VALID') ? 'freppy-result ok' : 'freppy-result err';

  const valid = (j.validation || '').startsWith('VALID');
  const dpi = +freppyEl('freppy-dpi').value || 150;

  if (!valid) {
    const msg = j.drc_exhausted
      ? 'DRC still failing after ' + (j.attempts || '?') + ' attempts — edit or re-prompt'
      : 'code saved — fix validation errors, then Render';
    toast(msg, 'err');
    freppySaveSession();
    return;
  }

  try {
    if (j.png) {
      freppyShowPreview(j.png);
    } else {
      freppyRecordActivity('Rendering preview…', { function: 'render_pcb()' });
      await freppyRenderPreview(code, dpi);
    }
    out.textContent += '\n\nRendered at ' + dpi + ' DPI';
    toast('designed and rendered', 'ok');
  } catch (e) {
    toast('valid but render failed — try Render button', 'err');
    out.textContent += '\n\nRender error: ' + (j.render_err || e.message);
  }
  freppySaveSession();
}

async function designFreppyWithClaude() {
  if (freppyDesigning) return;
  const prompt = freppyEl('freppy-prompt').value.trim();
  if (!prompt) {
    toast('describe the board you want', 'err');
    return;
  }
  if (!freppyOnline && !(await checkFreppyBridge())) {
    toast('start frep-pcb bridge first', 'err');
    return;
  }

  const btn = freppyEl('freppy-design-btn');
  const status = freppyEl('freppy-design-status');
  const progress = freppyEl('freppy-progress');
  const dpi = +freppyEl('freppy-dpi').value || 150;
  const existing = freppySanitizeCode(freppyEl('freppy-code').value);

  freppyDesigning = true;
  btn.disabled = true;
  status.textContent = 'designing…';
  if (progress) {
    progress.textContent = '';
    progress.classList.add('active');
  }
  freppyResetActivity();
  freppyRecordActivity('Starting design — Claude will use frep-pcb MCP tools…', {
    function: 'init()'
  });
  freppySaveSession();

  try {
    const r = await fetch(FREPPY_LOCAL + '/api/design', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'text/event-stream'
      },
      body: JSON.stringify({ prompt, dpi, code: existing, stream: true })
    });

    if (!r.ok || !r.body) {
      const j = await r.json().catch(() => ({ err: r.statusText }));
      throw new Error(j.err || 'design request failed');
    }

    const reader = r.body.getReader();
    const dec = new TextDecoder();
    let buf = '';
    let donePayload = null;

    while (true) {
      let chunk;
      try {
        chunk = await reader.read();
      } catch (streamErr) {
        if (donePayload) break;
        throw new Error('connection lost — ' + (streamErr.message || streamErr));
      }
      const { done, value } = chunk;
      if (done) break;
      buf += dec.decode(value, { stream: true });
      const chunks = buf.split('\n\n');
      buf = chunks.pop() || '';
      for (const chunk of chunks) {
        const line = chunk.split('\n').find(l => l.startsWith('data: '));
        if (!line) continue;
        let ev;
        try {
          ev = JSON.parse(line.slice(6));
        } catch (parseErr) {
          freppyAppendProgress('(stream chunk skipped)');
          continue;
        }
        if (ev.type === 'progress' && ev.message) {
          freppyRecordActivity(ev.message, ev);
          status.textContent = (ev.function || '').replace('()', '') + ': ' + ev.message.slice(0, 28);
        } else if (ev.type === 'done') {
          donePayload = ev;
        } else if (ev.type === 'error') {
          throw new Error(ev.err || 'design failed');
        }
      }
    }

    if (!donePayload) throw new Error('stream ended without result');
    const willRender = (donePayload.validation || '').startsWith('VALID');
    if (donePayload.drc_exhausted) {
      freppyRecordActivity(
        'Stopped — DRC still failing after ' + (donePayload.attempts || 3) + ' attempts',
        { function: 'complete()' }
      );
    } else {
      freppyRecordActivity(
        willRender ? 'Done — DRC passed' : 'Done — validation failed (see Result)',
        { function: 'complete()' }
      );
    }
    await freppyApplyDesignResult(donePayload);
    status.textContent = 'done';
  } catch (e) {
    freppyEl('freppy-result').textContent = String(e.message || e);
    freppyEl('freppy-result').className = 'freppy-result err';
    freppyRecordActivity('Error: ' + (e.message || e), { function: 'error()' });
    status.textContent = 'failed';
    toast('design failed', 'err');
    freppySaveSession();
  } finally {
    freppyDesigning = false;
    btn.disabled = false;
    progress.classList.remove('active');
  }
}

function renderFreppyDesignList() {
  freppyDesigns = freppyLoadStore();
  const el = freppyEl('freppy-list');
  if (!freppyDesigns.length) {
    el.innerHTML = '<div class="empty">No local designs yet</div>';
    return;
  }
  el.innerHTML = freppyDesigns
    .map(
      d =>
        `<div class="item" onclick="openFreppyDesign('${jsStr(d.name)}',this)">
       <span>${esc(d.name)}</span><small>local</small></div>`
    )
    .join('');
}

function openFreppyDesign(name, node) {
  document.querySelectorAll('#freppy-list .item').forEach(i => i.classList.remove('sel'));
  if (node) node.classList.add('sel');
  const d = freppyLoadStore().find(x => x.name === name);
  if (!d) {
    toast('design not found locally', 'err');
    return;
  }
  freppyEl('freppy-name').value = d.name;
  freppyEl('freppy-code').value = d.code;
  freppyEl('freppy-result').textContent = '';
  freppyClearPreview();
}

function newFreppyDesign() {
  document.querySelectorAll('#freppy-list .item').forEach(i => i.classList.remove('sel'));
  freppyEl('freppy-name').value = 'myboard.py';
  freppyEl('freppy-code').value = '';
  freppyEl('freppy-result').textContent = '';
  freppyClearPreview();
  freppyEl('freppy-code').focus();
}

/** Wipe saved designs + session state for a clean demo / video take. */
function clearFreppyCache() {
  if (!confirm('Clear all Freppy saved designs and reset the tab?')) return;
  localStorage.removeItem(FREPPY_STORE_KEY);
  sessionStorage.removeItem(FREPPY_SESSION_KEY);
  freppyDesigns = [];
  freppyEl('freppy-prompt').value = '';
  freppyEl('freppy-code').value = '';
  freppyEl('freppy-name').value = 'myboard.py';
  freppyEl('freppy-dpi').value = '150';
  if (freppyEl('freppy-progress')) {
    freppyEl('freppy-progress').textContent = '';
    freppyEl('freppy-progress').classList.remove('active');
  }
  freppyResetActivity();
  freppyEl('freppy-result').textContent = '';
  freppyEl('freppy-result').className = 'freppy-result';
  freppyEl('freppy-design-status').textContent = '';
  freppyEl('freppy-pico-hint').textContent = 'not pushed yet';
  freppyClearPreview();
  renderFreppyDesignList();
  toast('Freppy cache cleared', 'ok');
}

function saveFreppyLocal() {
  const n = freppyCurrentName();
  if (!/\.py$/i.test(n)) {
    toast('designs should end in .py', 'err');
    return;
  }
  freppyUpsertLocal(n, freppyEl('freppy-code').value);
  renderFreppyDesignList();
  toast('saved locally', 'ok');
}

function delFreppyLocal() {
  const n = freppyCurrentName();
  if (!n) return;
  if (!confirm('Delete local design ' + n + '?')) return;
  const list = freppyLoadStore().filter(d => d.name !== n);
  freppySaveStore(list);
  newFreppyDesign();
  renderFreppyDesignList();
  toast('deleted locally', 'ok');
}

function downloadFreppyDesign() {
  const n = freppyCurrentName();
  const code = freppyEl('freppy-code').value;
  if (!code.trim()) {
    toast('nothing to download', 'err');
    return;
  }
  const blob = new Blob([code], { type: 'text/plain' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = n.endsWith('.py') ? n : n + '.py';
  a.click();
  setTimeout(() => URL.revokeObjectURL(url), 500);
  toast('downloaded', 'ok');
}

async function loadFreppyTemplate(kind) {
  if (!freppyOnline && !(await checkFreppyBridge())) {
    toast('start frep-pcb MCP first', 'err');
    return;
  }
  try {
    const content = await freppyTool(kind === 'example' ? 'get_example' : 'get_template', {});
    freppyEl('freppy-code').value = freppyToolText(content);
    toast('loaded ' + kind, 'ok');
  } catch (e) {
    toast(e.message, 'err');
  }
}

async function validateFreppy() {
  const code = freppySanitizeCode(freppyEl('freppy-code').value);
  if (!code) {
    toast('load Template or paste Python design code first', 'err');
    return;
  }
  freppyEl('freppy-code').value = code;
  if (!freppyOnline && !(await checkFreppyBridge())) {
    toast('start frep-pcb MCP first', 'err');
    return;
  }
  const out = freppyEl('freppy-result');
  out.textContent = 'validating…';
  try {
    const content = await freppyTool('validate_design', { code });
    const text = freppyToolText(content);
    out.textContent = text;
    out.className = text.startsWith('VALID') ? 'freppy-result ok' : 'freppy-result err';
    toast(text.startsWith('VALID') ? 'valid' : 'issues found', text.startsWith('VALID') ? 'ok' : 'err');
  } catch (e) {
    const msg = freppyCodeError(e);
    out.textContent = msg;
    out.className = 'freppy-result err';
    toast('validation failed', 'err');
  }
}

async function renderFreppy() {
  const code = freppySanitizeCode(freppyEl('freppy-code').value);
  if (!code) {
    toast('load Template or paste Python design code first', 'err');
    return;
  }
  freppyEl('freppy-code').value = code;
  if (!freppyOnline && !(await checkFreppyBridge())) {
    toast('start frep-pcb MCP first', 'err');
    return;
  }
  const dpi = +freppyEl('freppy-dpi').value || 150;
  const out = freppyEl('freppy-result');
  out.textContent = 'rendering…';
  out.className = 'freppy-result';
  try {
    await freppyRenderPreview(code, dpi);
    out.textContent = 'rendered at ' + dpi + ' DPI';
    out.className = 'freppy-result ok';
    toast('rendered', 'ok');
    freppySaveSession();
  } catch (e) {
    const msg = freppyCodeError(e);
    out.textContent = msg;
    out.className = 'freppy-result err';
    toast('render failed', 'err');
  }
}

async function pushFreppyToPico() {
  const n = freppyCurrentName();
  if (!/\.py$/i.test(n)) {
    toast('designs should end in .py', 'err');
    return;
  }
  try {
    await API.write(n, freppyEl('freppy-code').value);
    toast('pushed ' + n + ' to Pico', 'ok');
    freppyEl('freppy-pico-hint').textContent = 'last push: ' + n;
  } catch (e) {
    toast('Pico offline — ' + e.message, 'err');
  }
}

if (document.getElementById('freppy-prompt')) {
  freppyRestoreSession();
}

function initFreppyTab() {
  if (!freppyTabReady) {
    freppyTabReady = true;
    freppyRestoreSession();
    freppyEl('freppy-mcp-json').textContent = freppyMcpConfig();
    freppyEl('freppy-project-root').addEventListener('input', () => {
      freppyEl('freppy-mcp-json').textContent = freppyMcpConfig();
    });
    ['freppy-prompt', 'freppy-code', 'freppy-name', 'freppy-dpi'].forEach(id => {
      freppyEl(id).addEventListener('input', () => freppySaveSession());
    });
    const actLog = freppyEl('freppy-activity-log');
    if (actLog) {
      actLog.addEventListener('change', () => {
        const idx = +actLog.value;
        const entry = freppyActivityEntries[idx];
        if (entry && freppyEl('freppy-activity-live')) {
          freppyEl('freppy-activity-live').textContent = entry.function;
        }
      });
    }
  }
  checkFreppyBridge();
  renderFreppyDesignList();
}

Credits

  • ChatGPT (OpenAI) - early ideation (product direction, app ideas, naming) and sticker artwork generation for the enclosure vinyl labels.
  • Claude Code (Anthropic) - browser data editor UI (layout, interaction patterns, and front-end code in pockety-interface/) and general programming support during firmware and interface bring-up.
  • Cursor (IDE, with AI agents) - batch compression and reorganization of images and videos, plus scripted edits across the site during documentation cleanup.

Hardware design, enclosure CAD, PCB layout, on-device MicroPython structure, and final integration decisions are mine; all AI suggestions and generated assets were reviewed, edited, and tested before shipping.