Pockety
Planted May 26, 2026

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.

Inside

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

On-device demo
Encoder navigation and e-ink menu refresh:
BOM
Bill of materials aligned with the integrated hardware documented in Week 15: System Integration.
Compute and interfaces
| Item | Qty | Notes |
|---|---|---|
| Raspberry Pi Pico 2 W | 1 | Main MCU and Wi-Fi |
| Custom PCB (bare board) | 1 | Interconnect and breakout |
Displays and input
| Item | Qty | Notes |
|---|---|---|
| Waveshare 2.13 inch e-ink module | 1 | Primary display |
| 0.96 inch SSD1306 OLED | 1 | Secondary status / UI |
| ALPS EC11 magnetic encoder | 1 | With cable to PCB |
| IC184 red push button | 1 | Tactile input |
Power
| Item | Qty | Notes |
|---|---|---|
| 1S 3.7 V LiPo battery (350 mAh) | 1 | As used in integration |
| TP4056 LiPo charging module | 1 | Charging / protection (module as built) |
PCB population (SMD / through-hole on custom board)
| Part | Qty |
|---|---|
| LED 1206 (orange) | 2 |
| C 1206 0.1 µF | 2 |
| R 1206 100 Ω | 1 |
| C 1206 10 µF | 1 |
| Pin header 2.54 mm | 13 |
| 5-position vertical SMD socket | 8 |
| 4-position vertical SMD socket | 1 |
| 3-position vertical SMD socket | 1 |
| 2-position vertical SMD socket | 1 |
Mechanical and assembly
| Item | Qty | Notes |
|---|---|---|
| 3D-printed parts (body, top, screen cap, encoder shaft, guides, etc.) | 1 set | Design-specific enclosure |
| 3D printing filament | As needed | e.g. PLA or PETG |
| Magnets (~0.5 inch diameter) | Set | Magnetic retention for e-ink cap / top |
| M2 screws | Set | Top–body assembly |
| Brass threaded inserts | Set | Installed in top for screw retention |
PCB
Main board
CAD

3D

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

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 layout | On the bench | Draw position |
|---|---|---|
![]() | ![]() | ![]() |
5 Pad EngelBart ChordBoard
Five touch pads, dual switches, XIAO footprint - PLA top, 3 mm wood base, ecoflex silicone buttons.
| PCB layout | Assembled module | Silicone buttons |
|---|---|---|
![]() | ![]() | ![]() |
Feyncorder
Magnetic encoder daughterboard - ALPS EC11 breakout wired to the main PCB.
| Resource | Link |
|---|---|
| KiCad project | feyncorder design files |
| Top plate molding | Project development → |

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

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
| Books | Flashcards |
|---|---|
|  |  |
Layout and tabs
| Tab | On-device data | What you do |
|---|---|---|
| Books | *.txt in flash | List, edit, create, delete reader library files |
| Flashcards | flashcards.json | Deck picker, front/back cards, SRS fields (due, etc.) |
| Weather | weather.txt | Name,lat,lon rows for Open-Meteo |
| Voice | *.raw clips | List 16-bit mono 16 kHz recordings; play in-browser or export WAV |
| All files | any filename | Raw read/write/delete for debugging |
| Build app | generated *.py | Drag-and-drop menu/text screens → MicroPython app; optional main.py launcher entry |
| Freppy | local + optional Pico | PCB 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)
| Endpoint | Method | Role |
|---|---|---|
/api/info | GET | SSID, IP, free/total bytes (JSON) |
/api/files | GET | File list with sizes (JSON) |
/api/read?name= | GET | File body (text or binary) |
/api/write?name= | POST | Write body to flash |
/api/delete?name= | POST | Delete 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">—</b></span>
<span>IP <b id="m-ip">—</b></span>
<span>FREE <b id="m-free">—</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 · clips</h2>
<div class="bar">
<button class="btn" onclick="loadClips()">Refresh</button>
<span class="pill" id="clip-fmt">16-bit · 16 kHz · 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 <repo> 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 · generate & 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, '&')
.replace(/"/g, '"')
.replace(/</g, '<');
}
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">×</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.





