- TypeScript 61.3%
- Svelte 33.4%
- Python 2.9%
- CSS 1.7%
- JavaScript 0.5%
- Other 0.1%
xhrUpload wrapped the file in a FormData, so the browser sent
multipart/form-data. The device's /upload/{firmware,webui} handlers
stream the raw request body straight to flash and do NOT parse
multipart, which broke uploads two ways:
- Content-Length is inflated by the multipart envelope. Storage
images are sized to the exact LittleFS partition, so the ~250 B of
boundary + Content-Disposition overhead trips the firmware's 413
size gate ({"error":"oversize","max":839680} on Rev B).
- Even under the gate, the `--boundary` preamble would be written
into flash as the start of the image, corrupting the LittleFS
superblock / failing the esp_ota app-image magic check.
Send the file as a raw octet-stream (xhr.send(file) + explicit
Content-Type) so Content-Length == the image size and the bytes land
verbatim. Verified end-to-end on a Rev B device: both WebUI
(839680 B) and firmware (1758960 B, sha256-checked) uploads succeed.
Add a unit test asserting the raw-octet-stream request shape and a
representative Playwright test that drives the real file-input ->
button flow and asserts the captured request is byte-exact (not a
multipart wrapper); the Playwright test fails against the old code.
|
||
|---|---|---|
| .forgejo/workflows | ||
| .github | ||
| .vscode | ||
| doc | ||
| extra/icons | ||
| patches | ||
| project.inlang | ||
| scripts | ||
| src | ||
| static | ||
| tests | ||
| .editorconfig | ||
| .gitignore | ||
| .nvmrc | ||
| .prettierignore | ||
| .prettierrc | ||
| AGENTS.md | ||
| Dockerfile | ||
| eslint.config.js | ||
| gzip_build.py | ||
| package.json | ||
| playwright.base.ts | ||
| playwright.config.ts | ||
| playwright.doc-screenshot.config.ts | ||
| playwright.screenshot.config.ts | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| renovate.json | ||
| svelte.config.js | ||
| tsconfig.json | ||
| vite.config.test.ts | ||
| vite.config.ts | ||
BTClock WebUI
The web user-interface for the BTClock.
Getting started
This project is managed with pnpm. Install it with
corepack enable pnpm or npm i -g pnpm if you don't have it yet.
pnpm install # applies the SvelteKit filename-shortening patch via pnpm patchedDependencies
pnpm dev # start the dev server (http://localhost:5173)
Set PUBLIC_BASE_URL in .env to the address of a real BTClock (e.g.
http://btclock-d60b14.local) so the dev server talks to it. When unset
it falls back to an empty string — i.e. same-origin requests — which is
also what a build the firmware serves from LittleFS wants, so leave it
empty at build time.
Production build
pnpm build # produces dist/ with prerendered index.html + hashed JS/CSS
python3 gzip_build.py # gzips everything under dist/ into build_gz/
mklittlefs -c build_gz -s 409600 output/littlefs.bin
The firmware serves files literally out of /lfs/www/ (see
control_server.cpp), so the build keeps that directory minimal: only
/ is prerendered, and a tiny post-build step in vite.config.ts
deletes adapter-static's bundle.html SPA fallback (the firmware
doesn't route unknown paths to it). /api and /convert opt out of
prerendering and are reached only via SvelteKit client routing. The
filename-shortening patch in patches/ keeps asset names within
LittleFS's filename limit.
Tests
pnpm test:unit # vitest unit + component tests
pnpm test:integration # playwright end-to-end suite (chromium + mobile)
pnpm test:screenshots # device/locale screenshot suite
pnpm doc:update-screenshots
Languages
The WebUI ships 15 translations. Message catalogs live in
src/lib/locales/<code>.json and are compiled by Paraglide. The language
picker lists them sorted alphabetically by their localized name; Arabic is
rendered right-to-left (<html dir="rtl">), with technical identifiers
(IP/MAC/hostname, version and commit) kept left-to-right.
🇬🇧 English (en) · 🇳🇱 Nederlands (nl) · 🇩🇪 Deutsch (de) ·
🇪🇸 Español (es) · 🇫🇷 Français (fr) · 🇮🇹 Italiano (it) ·
🇵🇹 Português (pt) · 🇵🇱 Polski (pl) · 🇨🇿 Čeština (cs) ·
🇩🇰 Dansk (da) · 🇹🇷 Türkçe (tr) · 🇷🇺 Русский (ru) ·
🇸🇦 العربية (ar) · 🇨🇳 中文 (zh) · 🇯🇵 日本語 (ja)
To add a locale: copy src/lib/locales/en.json and translate the values,
add the tag to project.inlang/settings.json, and register the locale +
flag in src/lib/i18n.svelte.ts.
Key architectural choices
- Feature-based folders — UI is organised under
src/lib/features/{control,status,firmware,settings,convert}with colocated sub-components and spec tests. Generic primitives live insrc/lib/ui/, data access insrc/lib/api/, and global state insrc/lib/stores/as Svelte 5 runes. - Discriminated-union state —
SettingsStateandStatusStateencodeloading | error | ready, so every consumer handles each phase explicitly instead of falling through nullable props. - Valibot schemas — Runtime validation for every inbound API payload
lives in
src/lib/api/schemas.ts, protecting the UI from firmware drift. - Paraglide JS v2 — Message catalogs in
src/lib/locales/are compiled per-locale and tree-shaken by the Paraglide Vite plugin. See Languages for the 15 shipped locales. - Static output only —
@sveltejs/adapter-staticwith SSR disabled; the WebUI is always mounted on-device by the firmware. - Minimal font footprint — only the
latin-400woff2 file for Ubuntu (plus compact sats-symbol subsets) is shipped, keepingbuild_gz/well below the ~420 KB LittleFS partition.

