From ee03ce7146ff4f60973947f0e669dd7c984a5d7b Mon Sep 17 00:00:00 2001 From: Mateus Demboski Date: Mon, 6 Jul 2026 20:36:24 -0300 Subject: [PATCH] feat: shared @spoolid/core package; refactor web + desktop onto it spec/ts becomes the shared client core: TS wire types generated from spec/v2 schemas (json2ts, git-ignored), deduplicated pure logic (material-DB parsing incl. the meterialType quirk, read-prefill, write status interpretation, color normalization, protocol/minor compat gate, OTA target detection), design tokens (tokens.css - single palette, both UIs; web adopts the desktop theme), and vitest coverage. Both frontends now import @spoolid/core via a root pnpm workspace (one lockfile; nested workspace files removed). The desktop serial layer types replies as a discriminated union (ok: true | ErrorReply) instead of Record; the compat gate is shared and protocol-aware, fixing the documented-but-unimplemented dev-build skip. v2 fixtures for spec/status/config/read-error re-captured from a live v2 device; serial getspec verified byte-identical to HTTP /api/spec on hardware. CI: wails build -clean (spec/ts changes don't dirty the frontend hash cache); build_web.py installs the workspace root. Refs #18 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SBnenzHAV3VVAF8b8hpwkT --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- .gitignore | 5 +- CLAUDE.md | 8 + desktop/frontend/package.json | 5 +- desktop/frontend/pnpm-workspace.yaml | 6 - desktop/frontend/src/db.ts | 48 +- desktop/frontend/src/main.ts | 148 ++--- desktop/frontend/src/serial.ts | 13 +- desktop/frontend/src/theme.css | 15 +- package.json | 5 + .../frontend/pnpm-lock.yaml => pnpm-lock.yaml | 414 +++++++++++++- ...pnpm-workspace.yaml => pnpm-workspace.yaml | 5 + spec/ts/package.json | 14 +- spec/ts/pnpm-lock.yaml | 63 --- spec/ts/src/color.ts | 33 ++ spec/ts/src/compat.ts | 63 +++ spec/ts/src/db.ts | 39 ++ spec/ts/src/index.ts | 24 + spec/ts/src/ota.ts | 3 + spec/ts/src/read.ts | 34 ++ spec/ts/src/status.ts | 30 ++ spec/ts/src/wire.ts | 21 + spec/ts/test/core.test.ts | 111 ++++ spec/ts/tokens.css | 16 + spec/ts/tsconfig.json | 13 + spec/v2/fixtures/config.reply/get.json | 9 +- spec/v2/fixtures/read.reply/err-no-tag.json | 6 +- spec/v2/fixtures/spec.reply/ok.json | 45 +- spec/v2/fixtures/status.reply/idle.json | 14 +- tools/build_web.py | 3 +- web/config.html | 1 - web/index.html | 1 - web/package.json | 5 +- web/pnpm-lock.yaml | 505 ------------------ web/src/app.ts | 84 +-- web/src/config.ts | 13 +- web/{public => src}/style.css | 24 +- web/src/vite-env.d.ts | 1 + 39 files changed, 1030 insertions(+), 821 deletions(-) delete mode 100644 desktop/frontend/pnpm-workspace.yaml create mode 100644 package.json rename desktop/frontend/pnpm-lock.yaml => pnpm-lock.yaml (76%) rename web/pnpm-workspace.yaml => pnpm-workspace.yaml (81%) delete mode 100644 spec/ts/pnpm-lock.yaml create mode 100644 spec/ts/src/color.ts create mode 100644 spec/ts/src/compat.ts create mode 100644 spec/ts/src/db.ts create mode 100644 spec/ts/src/index.ts create mode 100644 spec/ts/src/ota.ts create mode 100644 spec/ts/src/read.ts create mode 100644 spec/ts/src/status.ts create mode 100644 spec/ts/src/wire.ts create mode 100644 spec/ts/test/core.test.ts create mode 100644 spec/ts/tokens.css create mode 100644 spec/ts/tsconfig.json delete mode 100644 web/pnpm-lock.yaml rename web/{public => src}/style.css (85%) create mode 100644 web/src/vite-env.d.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bbe367..230ee6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,7 +151,7 @@ jobs: - name: Build # `wails build` generates bindings, installs frontend deps (pnpm), # type-checks (tsc) + bundles the frontend, then compiles the Go binary. - run: cd desktop && wails build + run: cd desktop && wails build -clean - name: Lint frontend (ESLint) # deps + generated bindings already present from the build above. run: cd desktop/frontend && pnpm lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f04a6f..4d343c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -122,7 +122,7 @@ jobs: if [ "$RUNNER_OS" = "Linux" ]; then sudo apt-get install -y upx-ucl; fi if [ "$RUNNER_OS" = "Windows" ]; then choco install upx -y; fi - name: Build (version from tag) - run: cd desktop && wails build ${{ matrix.upx }} ${{ matrix.platform }} -ldflags "-X main.version=${{ needs.release-please.outputs.version }}" + run: cd desktop && wails build -clean ${{ matrix.upx }} ${{ matrix.platform }} -ldflags "-X main.version=${{ needs.release-please.outputs.version }}" - name: Package artifact shell: bash run: | diff --git a/.gitignore b/.gitignore index 6895f3c..ba0453a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,9 +15,12 @@ firmware/.pio/ firmware/data/*.gz firmware/src/material_db_default.h -# spec tooling (deps + fixtures generated by the firmware native contract test) +# spec tooling: deps, json2ts output, fixtures from the firmware contract test spec/ts/node_modules/ +spec/ts/src/generated/ spec/v2/fixtures/generated/ +# root workspace deps +node_modules/ # desktop (Wails: Go backend + Vite/TS frontend) — generated, rebuilt by wails desktop/build/bin/ diff --git a/CLAUDE.md b/CLAUDE.md index d4f7c21..f0fe4cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,10 @@ sizes, baud rates, log levels). It builds AND encrypts every payload. **Web and desktop are thin clients** — they only collect the user's selections and send them. They hold no baked constants: they fetch the UI lists from the device at startup. +Shared client logic (material-DB parsing, read-prefill, status interpretation, color +normalization, compat gate, design tokens) lives in **`spec/ts` (`@spoolid/core`)**, a +pnpm-workspace package imported by both frontends; the workspace root is the repo root +(`pnpm-workspace.yaml`: web, desktop/frontend, spec/ts — one root lockfile). - web → HTTP (`web_server.cpp`): `/api/spec`, `/api/db`, `/api/write`, `/api/read`, `/api/status`, `/api/config`, `/api/ota` - desktop → line-JSON over USB CDC (`serial_proto.cpp`): `getspec`, `write`, `read`, `status`, `dump`, `getconfig`, `setconfig`, `dbbegin`/`dbdata`/`dbend`, `otabegin`/`otadata`/`otaend` @@ -93,6 +97,10 @@ copies, not the sources). After editing `material_database.json`, re-run `tools/ run `gen_db_header.py` + `build_web.py` and create `secrets.h`. CI does this in `.github/workflows/ci.yml`. - **If `material_database.json` is lost**, recover it by gunzipping the byte array in the generated `firmware/src/material_db_default.h` (content-identical, reformatted compact). +- **`spec/ts/src/generated/` is git-ignored** — TS wire types generated from + `spec/v2/schemas` by `pnpm --dir spec/ts generate` (the web/desktop build scripts run + it automatically). A `spec/ts`-only change doesn't dirty Wails' frontend hash cache — + CI uses `wails build -clean`; locally use `wails build -f` after core changes. - **Desktop (Wails) generated files are git-ignored** and rebuilt by `wails build`/`wails dev`: `desktop/frontend/wailsjs/` (Go↔JS bindings), `desktop/frontend/dist/`, `desktop/build/bin/`, and `desktop/frontend/material_database.json` (copied from the repo root by the `copy-db` pnpm diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index 2ae2810..3b0bb70 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -6,7 +6,7 @@ "scripts": { "copy-db": "node -e \"require('fs').copyFileSync('../../material_database.json','material_database.json')\"", "dev": "pnpm copy-db && vite", - "build": "pnpm copy-db && tsc && vite build", + "build": "pnpm --dir ../../spec/ts generate && pnpm copy-db && tsc && vite build", "lint": "eslint .", "preview": "vite preview" }, @@ -16,5 +16,8 @@ "typescript": "~6.0.3", "typescript-eslint": "^8.62.1", "vite": "^8.1.3" + }, + "dependencies": { + "@spoolid/core": "workspace:*" } } diff --git a/desktop/frontend/pnpm-workspace.yaml b/desktop/frontend/pnpm-workspace.yaml deleted file mode 100644 index a80ea8e..0000000 --- a/desktop/frontend/pnpm-workspace.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Allow esbuild (Vite's bundler) to run its install script. pnpm blocks build -# scripts by default; without this, `pnpm install` errors ERR_PNPM_IGNORED_BUILDS. -allowBuilds: - esbuild: true -minimumReleaseAgeExclude: - - vite@8.1.3 diff --git a/desktop/frontend/src/db.ts b/desktop/frontend/src/db.ts index 756de36..b321507 100644 --- a/desktop/frontend/src/db.ts +++ b/desktop/frontend/src/db.ts @@ -1,43 +1,11 @@ -// Material database: parsed in the frontend from the bundled Creality JSON -// (mirrors the web client, which fetches /api/db and parses in the browser). -// The JSON is copied from the repo root into frontend/ by the `copy-db` script. +// Material database: the bundled Creality JSON (copied from the repo root by +// the `copy-db` script) parsed by the shared @spoolid/core logic — same code +// path as the web client, which fetches /api/db instead. import rawDb from "../material_database.json"; +import { parseMaterialDb } from "@spoolid/core"; +import type { Material } from "@spoolid/core"; -export interface Material { - id: string; - brand: string; - name: string; - type: string; -} +export type { Material }; +export { brands, byBrand, nameFor } from "@spoolid/core"; -interface DbDoc { - result?: { - list?: Array<{ - base?: { id?: string; brand?: string; name?: string; meterialType?: string }; - }>; - }; -} - -// loadDb flattens result.list[].base into Material rows. Note the DB's -// misspelled type key, "meterialType". -export function loadDb(): Material[] { - const doc = rawDb as DbDoc; - const out: Material[] = []; - for (const it of doc.result?.list ?? []) { - const b = it.base; - if (!b?.id) continue; - out.push({ id: b.id, brand: b.brand ?? "", name: b.name ?? "", type: b.meterialType ?? "" }); - } - return out; -} - -export const brands = (items: Material[]): string[] => - [...new Set(items.map((m) => m.brand))].sort(); - -export const byBrand = (items: Material[], brand: string): Material[] => - items.filter((m) => m.brand === brand); - -export const nameFor = (items: Material[], id: string): string => { - const m = items.find((x) => x.id === id); - return m ? `${m.brand} ${m.name}` : ""; -}; +export const loadDb = (): Material[] => parseMaterialDb(rawDb); diff --git a/desktop/frontend/src/main.ts b/desktop/frontend/src/main.ts index 60d5b4f..59023bb 100644 --- a/desktop/frontend/src/main.ts +++ b/desktop/frontend/src/main.ts @@ -2,7 +2,17 @@ // UI lists (swatches, sizes, log levels, baud rates) and current config over // serial, then drive the same brand -> filament -> size -> color -> Write flow // as the web UI. Serial calls are bound Go methods (see serial.ts). +import "@spoolid/core/tokens.css"; import "./theme.css"; +import { + checkCompat, + cmpVer, + colorName, + interpretWriteStatus, + isFilesystemImage, + readToPrefill, +} from "@spoolid/core"; +import type { SpecReply, StatusReply, ReadReply, ConfigReply } from "@spoolid/core"; import { appVersion, checkUpdate, @@ -15,32 +25,12 @@ import { send, uploadDb, } from "./serial"; -import type { PortInfo, Release, Reply } from "./serial"; -import { brands, byBrand, loadDb, nameFor } from "./db"; +import type { PortInfo, Release } from "./serial"; +import { brands, byBrand, loadDb } from "./db"; import type { Material } from "./db"; const BAUD = 115200; -// Friendly names for the Creality swatch palette (presentation only; the hex -// list comes from the device spec). Unknown hex shows as "Custom". -const COLOR_NAMES: Record = { - "1200F6": "Blue", - "3894E1": "Light Blue", - "FEFF01": "Yellow", - "F8D531": "Gold", - "F38E24": "Orange", - "52D048": "Green", - "00FEBE": "Teal", - "B700F3": "Purple", - "EE301A": "Red", - "FA5959": "Coral", - "FFFFFF": "White", - "D8D8D8": "Light Gray", - "4C4C4C": "Dark Gray", - "782543": "Maroon", - "000000": "Black", -}; - const $ = (id: string): T => document.getElementById(id) as T; @@ -145,7 +135,7 @@ function show(which: "write" | "config"): void { // ---- color ---- function colorLabel(hex: string): string { - return `${COLOR_NAMES[hex.toUpperCase()] ?? "Custom"} #${hex}`; + return `${colorName(hex) ?? "Custom"} #${hex}`; } function setColor(hex: string): void { @@ -199,45 +189,23 @@ function applyCustom(): void { } // ---- device spec + config ---- -function applySpec(spec: Reply): void { - swatches = (spec.colorSwatches ?? []) as string[]; - options(el.size, (spec.weightLabels ?? []) as string[]); - options(el.logLevel, (spec.logLevels ?? []) as string[]); - options(el.baud, ((spec.baudRates ?? []) as number[]).map(String)); +function applySpec(spec: SpecReply): void { + swatches = spec.colorSwatches ?? []; + options(el.size, spec.weightLabels ?? []); + options(el.logLevel, spec.logLevels ?? []); + options(el.baud, (spec.baudRates ?? []).map(String)); buildSwatches(); if (swatches.length) selectSwatch(swatches[0]); - checkCompat(String(spec.version ?? "")); -} - -// ---- version compatibility gate ---- -// Desktop and firmware must share the same major.minor (full match preferred). -// Dev builds (0.0.0-dev / missing) skip the check. -function minorOf(v: string): string | null { - const m = /^(\d+)\.(\d+)\./.exec(v); - return m ? `${m[1]}.${m[2]}` : null; -} - -function checkCompat(fwVer: string): void { - const app = minorOf(appVer); - const fw = minorOf(fwVer); - if (!app || !fw) { - el.compat.hidden = true; // a dev build on either side — don't nag - return; - } - if (app === fw) { - el.compat.hidden = true; - return; - } - el.compat.hidden = false; - el.compat.textContent = - `⚠ version mismatch — desktop v${appVer} vs firmware v${fwVer}. ` + - `Update so both share the same minor (Config → Firmware update).`; + // Shared compatibility gate: protocol match on v2+ firmware, major.minor on 1.x. + const compat = checkCompat(appVer, spec); + el.compat.hidden = compat.compatible; + if (!compat.compatible) el.compat.textContent = `⚠ ${compat.message}`; } -function applyConfig(cfg: Reply): void { - el.wifiSsid.value = cfg.wifiSsid ?? ""; - el.hostname.value = cfg.hostname ?? ""; - el.apSsid.value = cfg.apSsid ?? ""; +function applyConfig(cfg: ConfigReply): void { + el.wifiSsid.value = cfg.wifiSsid; + el.hostname.value = cfg.hostname; + el.apSsid.value = cfg.apSsid; if (cfg.logLevel != null) el.logLevel.selectedIndex = Number(cfg.logLevel); if (cfg.baud != null) el.baud.value = String(cfg.baud); } @@ -334,9 +302,11 @@ async function doConnect(): Promise { return; } try { - const spec = await send({ cmd: "getspec" }); + const spec = await send({ cmd: "getspec" }); + if (!spec.ok) throw new Error(spec.error); applySpec(spec); - const cfg = await send({ cmd: "getconfig" }); + const cfg = await send({ cmd: "getconfig" }); + if (!cfg.ok) throw new Error(cfg.error); applyConfig(cfg); fillBrands(); } catch (e) { @@ -389,17 +359,12 @@ async function doWrite(): Promise { async function pollStatus(): Promise { try { - const st = await send({ cmd: "status" }); - const last = st.last; - if (!last || !last.done) return; + const st = await send({ cmd: "status" }); + const outcome = interpretWriteStatus(st.ok ? st : {}); + if (!outcome.done) return; if (polling != null) { clearInterval(polling); polling = null; } el.write.disabled = false; - if (last.ok) { - const enc = last.encrypted ? " (re-encrypted)" : ""; - setStatus(el.writeStatus, `written ✓ UID ${last.uid}${enc}`, "ok"); - } else { - setStatus(el.writeStatus, `error: ${last.error}`, "err"); - } + setStatus(el.writeStatus, outcome.message ?? "", outcome.ok ? "ok" : "err"); } catch (e) { if (polling != null) { clearInterval(polling); polling = null; } el.write.disabled = false; @@ -411,7 +376,7 @@ async function doRead(): Promise { el.read.disabled = true; setStatus(el.writeStatus, "reading — tap a tag on the reader…"); try { - const j = await send({ cmd: "read" }); + const j = await send({ cmd: "read" }); if (!j.ok) { setStatus(el.writeStatus, `read failed: ${j.error}`, "err"); return; @@ -425,30 +390,24 @@ async function doRead(): Promise { } } -function applyRead(j: Reply): void { - const materialId = String(j.materialId ?? ""); - const m = items.find((x) => x.id === materialId); - if (m) { - el.brand.value = m.brand; +function applyRead(j: ReadReply): void { + const pre = readToPrefill(j, items); + if (pre.material) { + el.brand.value = pre.material.brand; fillFilaments(); - el.filament.value = m.id; + el.filament.value = pre.material.id; } - if (j.weight) el.size.value = j.weight; - - const hex = String(j.color ?? "").toUpperCase(); - if (hex) { - if (swatches.some((s) => s.toUpperCase() === hex)) { - selectSwatch(hex); + if (pre.weight) el.size.value = pre.weight; + if (pre.colorHex) { + if (swatches.some((s) => s.toUpperCase() === pre.colorHex)) { + selectSwatch(pre.colorHex); } else { selectCustom(); - el.custom.value = hex; + el.custom.value = pre.colorHex; applyCustom(); } } - - const label = m ? `${m.brand} ${m.name}` : nameFor(items, materialId) || `id ${materialId}`; - const locked = j.encrypted ? " · locked" : ""; - el.readout.textContent = `read: ${label} · ${j.weight ?? "?"} · #${hex}${locked}`; + el.readout.textContent = pre.label; } // ---- config ---- @@ -575,17 +534,6 @@ async function doCheckUpdate(): Promise { } } -// Compare dotted numeric versions (prerelease suffixes ignored). >0 if a > b. -function cmpVer(a: string, b: string): number { - const pa = a.split(/[.\-+]/).map(Number); - const pb = b.split(/[.\-+]/).map(Number); - for (let i = 0; i < 3; i++) { - const d = (pa[i] || 0) - (pb[i] || 0); - if (d) return d; - } - return 0; -} - // ---- boot ---- async function boot(): Promise { el.rescan.onclick = () => void refreshPorts(); @@ -606,8 +554,8 @@ async function boot(): Promise { el.flash.onclick = () => doFlash(); // Auto-pick the image type from the URL (littlefs.bin -> fs, else app). el.otaUrl.oninput = () => { - const u = el.otaUrl.value.toLowerCase(); - if (u) el.otaTarget.value = /littlefs|spiffs|filesystem/.test(u) ? "fs" : "app"; + const u = el.otaUrl.value.trim(); + if (u) el.otaTarget.value = isFilesystemImage(u) ? "fs" : "app"; }; el.checkUpdate.onclick = () => void doCheckUpdate(); el.flashLatestApp.onclick = () => void runFlash(latest?.firmware ?? "", false); diff --git a/desktop/frontend/src/serial.ts b/desktop/frontend/src/serial.ts index 2ed1af7..c643f87 100644 --- a/desktop/frontend/src/serial.ts +++ b/desktop/frontend/src/serial.ts @@ -14,8 +14,15 @@ import { } from "../wailsjs/go/main/App"; import { EventsOn } from "../wailsjs/runtime/runtime"; +import type { ErrorReply } from "@spoolid/core"; + export type Cmd = Record; -export type Reply = Record; +// Every device reply carries the ok envelope (spec/v2/PROTOCOL.md): success +// replies have ok: true, errors are ErrorReply (ok: false + error + code) — a +// discriminated union, so `if (!r.ok)` narrows. Callers pass the concrete +// success type: send(...). +export interface Reply { ok: true } +export type Result = T | ErrorReply; export interface Release { version: string; @@ -39,8 +46,8 @@ export const connect = (port: string, baud: number): Promise => export const disconnect = (): Promise => Disconnect(); -export const send = (cmd: Cmd): Promise => - Send(cmd as { [key: string]: any }); +export const send = (cmd: Cmd): Promise> => + Send(cmd as { [key: string]: any }) as Promise>; // Desktop app version (for the firmware/desktop compatibility gate). export const appVersion = (): Promise => Version(); diff --git a/desktop/frontend/src/theme.css b/desktop/frontend/src/theme.css index 90cfeaf..190b631 100644 --- a/desktop/frontend/src/theme.css +++ b/desktop/frontend/src/theme.css @@ -1,16 +1,5 @@ -/* CrealityPrint-inspired dark theme (palette lifted from the old Tkinter gui.py). */ -:root { - --accent: #15c059; - --accent-act: #17cc5f; - --accent-text: #08130c; - --bg: #1b1b1b; - --surface: #2e2e2e; - --border: #3e3e45; - --text: #e8e8e8; - --muted: #bbbbbe; - --err: #e5484d; -} - +/* CrealityPrint-inspired dark theme. Palette comes from @spoolid/core tokens.css + (imported in main.ts before this file). */ * { box-sizing: border-box; } /* The [hidden] attribute must win over element display rules below (e.g. the diff --git a/package.json b/package.json new file mode 100644 index 0000000..6edad0b --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "spoolid-root", + "private": true, + "description": "pnpm workspace root - packages: web, desktop/frontend, spec/ts" +} diff --git a/desktop/frontend/pnpm-lock.yaml b/pnpm-lock.yaml similarity index 76% rename from desktop/frontend/pnpm-lock.yaml rename to pnpm-lock.yaml index c7906be..82a9732 100644 --- a/desktop/frontend/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,13 @@ settings: importers: - .: + .: {} + + desktop/frontend: + dependencies: + '@spoolid/core': + specifier: workspace:* + version: link:../../spec/ts devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -24,8 +30,43 @@ importers: specifier: ^8.1.3 version: 8.1.3 + spec/ts: + devDependencies: + ajv: + specifier: ^8.17.1 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) + json-schema-to-typescript: + specifier: ^15.0.4 + version: 15.0.4 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.0.7 + version: 4.1.9(vite@8.1.3) + + web: + dependencies: + '@spoolid/core': + specifier: workspace:* + version: link:../spec/ts + devDependencies: + typescript: + specifier: ~6.0.3 + version: 6.0.3 + vite: + specifier: ^8.1.3 + version: 8.1.3 + packages: + '@apidevtools/json-schema-ref-parser@11.9.3': + resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} + engines: {node: '>= 16'} + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -94,6 +135,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -201,9 +248,18 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -213,6 +269,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@typescript-eslint/eslint-plugin@8.62.1': resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -272,6 +331,35 @@ packages: resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -282,9 +370,27 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -293,6 +399,13 @@ packages: resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -313,6 +426,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + es-module-lexer@2.3.0: + resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -355,10 +471,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -368,6 +491,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -424,12 +550,24 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-to-typescript@15.0.4: + resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==} + engines: {node: '>=16.0.0'} + hasBin: true + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -518,10 +656,19 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -533,6 +680,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -553,11 +704,14 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} postcss@8.5.16: @@ -568,10 +722,19 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + engines: {node: '>=14'} + hasBin: true + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rolldown@1.1.4: resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -590,14 +753,34 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -669,11 +852,57 @@ packages: yaml: optional: true + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -684,6 +913,12 @@ packages: snapshots: + '@apidevtools/json-schema-ref-parser@11.9.3': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.3.0 + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -750,6 +985,10 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jsdevtools/ono@7.1.3': {} + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -810,17 +1049,28 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@standard-schema/spec@1.1.0': {} + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} + '@types/lodash@4.17.24': {} + '@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0)(typescript@6.0.3))(eslint@10.6.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -912,12 +1162,57 @@ snapshots: '@typescript-eslint/types': 8.62.1 eslint-visitor-keys: 5.0.1 + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@8.1.3)': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.3 + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 acorn@8.17.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -925,12 +1220,27 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + balanced-match@4.0.4: {} brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -945,6 +1255,8 @@ snapshots: detect-libc@2.1.2: {} + es-module-lexer@2.3.0: {} + escape-string-regexp@4.0.0: {} eslint-scope@9.1.2: @@ -1009,17 +1321,25 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} - fdir@6.5.0(picomatch@4.0.4): + fast-uri@3.1.3: {} + + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.5 file-entry-cache@8.0.0: dependencies: @@ -1058,10 +1378,28 @@ snapshots: isexe@2.0.0: {} + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + json-buffer@3.0.1: {} + json-schema-to-typescript@15.0.4: + dependencies: + '@apidevtools/json-schema-ref-parser': 11.9.3 + '@types/json-schema': 7.0.15 + '@types/lodash': 4.17.24 + is-glob: 4.0.3 + js-yaml: 4.3.0 + lodash: 4.18.1 + minimist: 1.2.8 + prettier: 3.9.4 + tinyglobby: 0.2.17 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} keyv@4.5.4: @@ -1126,16 +1464,26 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash@4.18.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.7 + minimist@1.2.8: {} + ms@2.1.3: {} nanoid@3.3.15: {} natural-compare@1.4.0: {} + obug@2.1.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1157,9 +1505,11 @@ snapshots: path-key@3.1.1: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} - picomatch@4.0.4: {} + picomatch@4.0.5: {} postcss@8.5.16: dependencies: @@ -1169,8 +1519,12 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.9.4: {} + punycode@2.3.1: {} + require-from-string@2.0.2: {} + rolldown@1.1.4: dependencies: '@oxc-project/types': 0.138.0 @@ -1200,12 +1554,24 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} ts-api-utils@2.5.0(typescript@6.0.3): dependencies: @@ -1238,17 +1604,47 @@ snapshots: vite@8.1.3: dependencies: lightningcss: 1.32.0 - picomatch: 4.0.4 + picomatch: 4.0.5 postcss: 8.5.16 rolldown: 1.1.4 tinyglobby: 0.2.17 optionalDependencies: fsevents: 2.3.3 + vitest@4.1.9(vite@8.1.3): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.1.3) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.3.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.3 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} yocto-queue@0.1.0: {} diff --git a/web/pnpm-workspace.yaml b/pnpm-workspace.yaml similarity index 81% rename from web/pnpm-workspace.yaml rename to pnpm-workspace.yaml index a80ea8e..0993d69 100644 --- a/web/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,8 @@ +packages: + - web + - desktop/frontend + - spec/ts + # Allow esbuild (Vite's bundler) to run its install script. pnpm blocks build # scripts by default; without this, `pnpm install` errors ERR_PNPM_IGNORED_BUILDS. allowBuilds: diff --git a/spec/ts/package.json b/spec/ts/package.json index 963e302..6b7c734 100644 --- a/spec/ts/package.json +++ b/spec/ts/package.json @@ -3,12 +3,22 @@ "private": true, "version": "1.0.0", "type": "module", - "description": "SpoolID shared core: wire-contract validation tooling (full package lands with the shared-client refactor)", + "description": "SpoolID shared core: generated wire types, client logic, design tokens, spec validation", + "exports": { + ".": "./src/index.ts", + "./tokens.css": "./tokens.css" + }, "scripts": { + "generate": "json2ts --cwd ../v2/schemas -i '../v2/schemas/*.schema.json' -o src/generated/", + "build": "pnpm generate && tsc --noEmit", + "test": "pnpm generate && vitest run", "validate": "node scripts/validate.mjs" }, "devDependencies": { "ajv": "^8.17.1", - "ajv-formats": "^3.0.1" + "ajv-formats": "^3.0.1", + "json-schema-to-typescript": "^15.0.4", + "typescript": "~6.0.3", + "vitest": "^4.0.7" } } diff --git a/spec/ts/pnpm-lock.yaml b/spec/ts/pnpm-lock.yaml deleted file mode 100644 index 588c5ff..0000000 --- a/spec/ts/pnpm-lock.yaml +++ /dev/null @@ -1,63 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - ajv: - specifier: ^8.17.1 - version: 8.20.0 - ajv-formats: - specifier: ^3.0.1 - version: 3.0.1(ajv@8.20.0) - -packages: - - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.20.0: - resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-uri@3.1.3: - resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - -snapshots: - - ajv-formats@3.0.1(ajv@8.20.0): - optionalDependencies: - ajv: 8.20.0 - - ajv@8.20.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.3 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - fast-deep-equal@3.1.3: {} - - fast-uri@3.1.3: {} - - json-schema-traverse@1.0.0: {} - - require-from-string@2.0.2: {} diff --git a/spec/ts/src/color.ts b/spec/ts/src/color.ts new file mode 100644 index 0000000..0eaab35 --- /dev/null +++ b/spec/ts/src/color.ts @@ -0,0 +1,33 @@ +// Color handling — one convention for every client: colors travel on the wire +// as bare RRGGBB; the '#' is presentation only. + +// Strip an optional '#', validate 6 hex chars, return uppercase bare hex (or null). +export function normalizeHex(input: string): string | null { + const hex = input.trim().replace(/^#/, ""); + return /^[0-9a-fA-F]{6}$/.test(hex) ? hex.toUpperCase() : null; +} + +export const withHash = (bareHex: string): string => "#" + bareHex.replace(/^#/, ""); + +// Friendly names for the Creality swatch palette (presentation only; the hex +// list itself comes from the device spec). Unknown hex -> undefined. +const COLOR_NAMES: Record = { + "1200F6": "Blue", + "3894E1": "Light Blue", + "FEFF01": "Yellow", + "F8D531": "Gold", + "F38E24": "Orange", + "52D048": "Green", + "00FEBE": "Teal", + "B700F3": "Purple", + "EE301A": "Red", + "FA5959": "Coral", + "FFFFFF": "White", + "D8D8D8": "Light Gray", + "4C4C4C": "Dark Gray", + "782543": "Maroon", + "000000": "Black", +}; + +export const colorName = (hex: string): string | undefined => + COLOR_NAMES[hex.replace(/^#/, "").toUpperCase()]; diff --git a/spec/ts/src/compat.ts b/spec/ts/src/compat.ts new file mode 100644 index 0000000..a436946 --- /dev/null +++ b/spec/ts/src/compat.ts @@ -0,0 +1,63 @@ +// Client <-> firmware compatibility gate. v2+ firmware advertises `protocol` +// (exact match required); 1.x firmware has only `version` (major.minor must +// match, the legacy lockstep rule). Dev builds skip the gate. +export const SUPPORTED_PROTOCOL = 2; + +export interface CompatResult { + compatible: boolean; + reason?: "protocol" | "minor"; + message?: string; +} + +export function minorOf(v: string): string | null { + const m = /^(\d+)\.(\d+)\./.exec(v); + return m ? `${m[1]}.${m[2]}` : null; +} + +// Compare dotted numeric versions (prerelease suffixes ignored). >0 if a > b. +export function cmpVer(a: string, b: string): number { + const pa = a.split(/[.\-+]/).map(Number); + const pb = b.split(/[.\-+]/).map(Number); + for (let i = 0; i < 3; i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d) return d; + } + return 0; +} + +interface SpecLike { + version?: unknown; + protocol?: unknown; +} + +// 0.0.0[-suffix] is the version.h fallback for local/dev builds. +const isDevBuild = (v: string): boolean => !v || v.startsWith("0.0.0"); + +export function checkCompat(appVersion: string, spec: SpecLike): CompatResult { + const fwVer = String(spec.version ?? ""); + if (isDevBuild(appVersion) || isDevBuild(fwVer)) return { compatible: true }; + if (typeof spec.protocol === "number") { + return spec.protocol === SUPPORTED_PROTOCOL + ? { compatible: true } + : { + compatible: false, + reason: "protocol", + message: + `firmware speaks protocol ${spec.protocol}, this app expects ` + + `${SUPPORTED_PROTOCOL} — update firmware and app to matching releases`, + }; + } + // Legacy 1.x firmware: major.minor lockstep. Dev builds on either side skip. + const app = minorOf(appVersion); + const fw = minorOf(fwVer); + if (!app || !fw) return { compatible: true }; + return app === fw + ? { compatible: true } + : { + compatible: false, + reason: "minor", + message: + `version mismatch — app v${appVersion} vs firmware v${fwVer}. ` + + `Update so both share the same minor.`, + }; +} diff --git a/spec/ts/src/db.ts b/spec/ts/src/db.ts new file mode 100644 index 0000000..83b95c9 --- /dev/null +++ b/spec/ts/src/db.ts @@ -0,0 +1,39 @@ +// Material database parsing — single owner of the `result.list[].base` walk +// and of the DB's misspelled type key, "meterialType". +export interface Material { + id: string; + brand: string; + name: string; + type: string; +} + +interface DbDoc { + result?: { + list?: Array<{ + base?: { id?: string; brand?: string; name?: string; meterialType?: string }; + }>; + }; +} + +// Flattens result.list[].base into Material rows (entries without an id are skipped). +export function parseMaterialDb(doc: unknown): Material[] { + const d = doc as DbDoc; + const out: Material[] = []; + for (const it of d?.result?.list ?? []) { + const b = it.base; + if (!b?.id) continue; + out.push({ id: b.id, brand: b.brand ?? "", name: b.name ?? "", type: b.meterialType ?? "" }); + } + return out; +} + +export const brands = (items: Material[]): string[] => + [...new Set(items.map((m) => m.brand))].sort(); + +export const byBrand = (items: Material[], brand: string): Material[] => + items.filter((m) => m.brand === brand); + +export const nameFor = (items: Material[], id: string): string => { + const m = items.find((x) => x.id === id); + return m ? `${m.brand} ${m.name}` : ""; +}; diff --git a/spec/ts/src/index.ts b/spec/ts/src/index.ts new file mode 100644 index 0000000..5a7b7b3 --- /dev/null +++ b/spec/ts/src/index.ts @@ -0,0 +1,24 @@ +// @spoolid/core — shared client core: generated wire types (from spec/v2 +// schemas; run `pnpm generate`), deduplicated pure logic, design tokens +// (import "@spoolid/core/tokens.css"). +export type { Material } from "./db.js"; +export { parseMaterialDb, brands, byBrand, nameFor } from "./db.js"; +export { normalizeHex, withHash, colorName } from "./color.js"; +export type { ReadPrefill } from "./read.js"; +export { readToPrefill } from "./read.js"; +export type { WriteOutcome } from "./status.js"; +export { interpretWriteStatus } from "./status.js"; +export type { CompatResult } from "./compat.js"; +export { SUPPORTED_PROTOCOL, checkCompat, minorOf, cmpVer } from "./compat.js"; +export { isFilesystemImage } from "./ota.js"; +export type { ErrorCode, ErrorReply } from "./wire.js"; +export { isErrorReply } from "./wire.js"; + +// Generated wire types (json2ts output, git-ignored): +export type { SpecReply } from "./generated/spec.reply.schema.js"; +export type { ReadReply } from "./generated/read.reply.schema.js"; +export type { StatusReply } from "./generated/status.reply.schema.js"; +export type { ConfigReply } from "./generated/config.reply.schema.js"; +export type { WriteReply } from "./generated/write.reply.schema.js"; +export type { WriteParams } from "./generated/write.params.schema.js"; +export type { DumpReply } from "./generated/dump.reply.schema.js"; diff --git a/spec/ts/src/ota.ts b/spec/ts/src/ota.ts new file mode 100644 index 0000000..8145075 --- /dev/null +++ b/spec/ts/src/ota.ts @@ -0,0 +1,3 @@ +// Pick the OTA target from an image file name or URL. +export const isFilesystemImage = (nameOrUrl: string): boolean => + /littlefs|spiffs|filesystem/.test(nameOrUrl.toLowerCase()); diff --git a/spec/ts/src/read.ts b/spec/ts/src/read.ts new file mode 100644 index 0000000..b7c76c5 --- /dev/null +++ b/spec/ts/src/read.ts @@ -0,0 +1,34 @@ +// Map a read/tag reply onto write-form prefill values — the shared core of the +// web and desktop "read a tag, review, write back" flow. UIs keep only DOM glue. +import type { Material } from "./db.js"; +import { nameFor } from "./db.js"; + +export interface ReadPrefill { + material?: Material; // matched DB row (brand/filament selects) + weight?: string; // size select value + colorHex?: string; // bare RRGGBB, uppercase + label: string; // human line for the readout, e.g. "read: Brand Name · 1KG · #FF0000" + locked: boolean; // tag was already encrypted to its derived key +} + +interface ReadLike { + materialId?: unknown; + color?: unknown; + weight?: unknown; + encrypted?: unknown; +} + +export function readToPrefill(reply: ReadLike, items: Material[]): ReadPrefill { + const materialId = String(reply.materialId ?? ""); + const material = items.find((m) => m.id === materialId); + const weight = reply.weight ? String(reply.weight) : undefined; + const hex = String(reply.color ?? "").toUpperCase(); + const colorHex = /^[0-9A-F]{6}$/.test(hex) ? hex : undefined; + + const name = material + ? `${material.brand} ${material.name}` + : nameFor(items, materialId) || `id ${materialId}`; + const locked = Boolean(reply.encrypted); + const label = `read: ${name} · ${weight ?? "?"} · #${hex}${locked ? " · locked" : ""}`; + return { material, weight, colorHex, label, locked }; +} diff --git a/spec/ts/src/status.ts b/spec/ts/src/status.ts new file mode 100644 index 0000000..6998f17 --- /dev/null +++ b/spec/ts/src/status.ts @@ -0,0 +1,30 @@ +// Interpret a status reply while polling for a staged-write outcome — the +// shared core of the two pollStatus loops. +interface StatusLike { + last?: { done?: unknown; ok?: unknown; encrypted?: unknown; uid?: unknown; error?: unknown }; +} + +export interface WriteOutcome { + done: boolean; // false -> keep polling + ok?: boolean; + message?: string; // ready-to-display status line + uid?: string; + encrypted?: boolean; +} + +export function interpretWriteStatus(reply: StatusLike): WriteOutcome { + const last = reply.last; + if (!last || !last.done) return { done: false }; + if (last.ok) { + const uid = String(last.uid ?? ""); + const encrypted = Boolean(last.encrypted); + return { + done: true, + ok: true, + uid, + encrypted, + message: `written ✓ UID ${uid}${encrypted ? " (re-encrypted)" : ""}`, + }; + } + return { done: true, ok: false, message: `error: ${String(last.error ?? "unknown")}` }; +} diff --git a/spec/ts/src/wire.ts b/spec/ts/src/wire.ts new file mode 100644 index 0000000..133ff18 --- /dev/null +++ b/spec/ts/src/wire.ts @@ -0,0 +1,21 @@ +// Wire envelope types (mirrors $defs in spec/v2/schemas/common.schema.json — +// json2ts only emits schema roots, so these are declared by hand). +export type ErrorCode = + | "bad_json" + | "invalid_params" + | "no_tag" + | "db_failed" + | "ota_failed" + | "size_required" + | "bad_chunk" + | "unknown_cmd" + | "internal"; + +// `code` is optional client-side: v1 firmware errors carry only `error`. +export interface ErrorReply { + ok: false; + error: string; + code?: ErrorCode; +} + +export const isErrorReply = (r: { ok: boolean }): r is ErrorReply => !r.ok; diff --git a/spec/ts/test/core.test.ts b/spec/ts/test/core.test.ts new file mode 100644 index 0000000..5234d60 --- /dev/null +++ b/spec/ts/test/core.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import rawDb from "../../../material_database.json"; +import { + brands, + byBrand, + checkCompat, + cmpVer, + colorName, + interpretWriteStatus, + isFilesystemImage, + normalizeHex, + parseMaterialDb, + readToPrefill, + withHash, +} from "../src/index.js"; + +describe("parseMaterialDb", () => { + const items = parseMaterialDb(rawDb); + it("parses the real repo database", () => { + expect(items.length).toBeGreaterThan(10); + for (const m of items.slice(0, 5)) { + expect(m.id).toBeTruthy(); + expect(m.brand).toBeTruthy(); + expect(m.type).toBeTruthy(); // came from the misspelled meterialType key + } + }); + it("brands are unique and sorted", () => { + const b = brands(items); + expect(b).toEqual([...new Set(b)].sort()); + expect(byBrand(items, b[0]).length).toBeGreaterThan(0); + }); + it("tolerates junk", () => { + expect(parseMaterialDb(null)).toEqual([]); + expect(parseMaterialDb({})).toEqual([]); + expect(parseMaterialDb({ result: { list: [{ base: {} }] } })).toEqual([]); + }); +}); + +describe("color", () => { + it("normalizes", () => { + expect(normalizeHex("#ff00aa")).toBe("FF00AA"); + expect(normalizeHex(" 1200F6 ")).toBe("1200F6"); + expect(normalizeHex("nope")).toBeNull(); + expect(normalizeHex("#12345")).toBeNull(); + }); + it("names known swatches only", () => { + expect(colorName("#1200f6")).toBe("Blue"); + expect(colorName("123456")).toBeUndefined(); + expect(withHash("FF0000")).toBe("#FF0000"); + }); +}); + +describe("checkCompat", () => { + it("protocol exact match wins", () => { + expect(checkCompat("2.0.0", { version: "2.0.0", protocol: 2 }).compatible).toBe(true); + const r = checkCompat("2.0.0", { version: "9.9.9", protocol: 3 }); + expect(r.compatible).toBe(false); + expect(r.reason).toBe("protocol"); + }); + it("legacy firmware falls back to major.minor", () => { + expect(checkCompat("1.2.3", { version: "1.2.9" }).compatible).toBe(true); + const r = checkCompat("1.3.0", { version: "1.2.0" }); + expect(r.compatible).toBe(false); + expect(r.reason).toBe("minor"); + }); + it("dev builds skip the legacy gate", () => { + expect(checkCompat("0.0.0-dev", { version: "1.2.0" }).compatible).toBe(true); + expect(checkCompat("1.2.0", { version: "0.0.0-dev" }).compatible).toBe(true); + }); +}); + +describe("readToPrefill", () => { + const items = [{ id: "01001", brand: "Hyper", name: "PLA", type: "PLA" }]; + it("matches a known material", () => { + const p = readToPrefill( + { materialId: "01001", color: "f38e24", weight: "1KG", encrypted: true }, items); + expect(p.material?.brand).toBe("Hyper"); + expect(p.colorHex).toBe("F38E24"); + expect(p.label).toBe("read: Hyper PLA · 1KG · #F38E24 · locked"); + expect(p.locked).toBe(true); + }); + it("unknown material still renders", () => { + const p = readToPrefill({ materialId: "99999", color: "000000", weight: "1KG" }, items); + expect(p.material).toBeUndefined(); + expect(p.label).toContain("id 99999"); + }); +}); + +describe("interpretWriteStatus", () => { + it("keeps polling until done", () => { + expect(interpretWriteStatus({}).done).toBe(false); + expect(interpretWriteStatus({ last: { done: false } }).done).toBe(false); + }); + it("reports success + failure", () => { + const ok = interpretWriteStatus({ last: { done: true, ok: true, uid: "AA", encrypted: true } }); + expect(ok.message).toBe("written ✓ UID AA (re-encrypted)"); + const err = interpretWriteStatus({ last: { done: true, ok: false, error: "boom" } }); + expect(err).toMatchObject({ done: true, ok: false, message: "error: boom" }); + }); +}); + +describe("misc", () => { + it("ota target autodetect", () => { + expect(isFilesystemImage("Littlefs.bin")).toBe(true); + expect(isFilesystemImage("https://x/firmware.bin")).toBe(false); + }); + it("cmpVer", () => { + expect(cmpVer("1.10.0", "1.9.9")).toBeGreaterThan(0); + expect(cmpVer("2.0.0", "2.0.0")).toBe(0); + }); +}); diff --git a/spec/ts/tokens.css b/spec/ts/tokens.css new file mode 100644 index 0000000..c5296ba --- /dev/null +++ b/spec/ts/tokens.css @@ -0,0 +1,16 @@ +/* SpoolID design tokens — the shared palette + names for every client UI. + * Token NAMES are part of the wire-spec standard (spec/v2/PROTOCOL.md); + * values are themeable. A future printer-brand theme overrides values in a + * [data-brand="..."] block; component CSS stays untouched. */ +:root { + --bg: #1b1b1b; + --surface: #2e2e2e; + --text: #e8e8e8; + --muted: #bbbbbe; + --accent: #15c059; + --accent-act: #17cc5f; + --accent-text: #08130c; + --ok: #52d048; + --err: #e5484d; + --border: #3e3e45; +} diff --git a/spec/ts/tsconfig.json b/spec/ts/tsconfig.json new file mode 100644 index 0000000..afe6fdb --- /dev/null +++ b/spec/ts/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src", "test"] +} diff --git a/spec/v2/fixtures/config.reply/get.json b/spec/v2/fixtures/config.reply/get.json index d220715..7167155 100644 --- a/spec/v2/fixtures/config.reply/get.json +++ b/spec/v2/fixtures/config.reply/get.json @@ -1 +1,8 @@ -{"ok": true, "wifiSsid": "MyWifi", "apSsid": "SpoolID", "hostname": "spoolid", "logLevel": 3, "baud": 115200} +{ + "ok": true, + "wifiSsid": "MyWifi", + "apSsid": "SpoolID", + "hostname": "spoolid", + "logLevel": 3, + "baud": 115200 +} diff --git a/spec/v2/fixtures/read.reply/err-no-tag.json b/spec/v2/fixtures/read.reply/err-no-tag.json index dd77d5e..e853324 100644 --- a/spec/v2/fixtures/read.reply/err-no-tag.json +++ b/spec/v2/fixtures/read.reply/err-no-tag.json @@ -1 +1,5 @@ -{"ok": false, "error": "no tag / auth failed", "code": "no_tag"} +{ + "ok": false, + "error": "no tag / auth failed", + "code": "no_tag" +} diff --git a/spec/v2/fixtures/spec.reply/ok.json b/spec/v2/fixtures/spec.reply/ok.json index 5924fe0..5737bec 100644 --- a/spec/v2/fixtures/spec.reply/ok.json +++ b/spec/v2/fixtures/spec.reply/ok.json @@ -1,9 +1,44 @@ { "ok": true, "protocol": 2, - "version": "2.0.0", - "colorSwatches": ["1200F6", "3894E1", "FEFF01", "F8D531", "F38E24", "52D048", "00FEBE", "B700F3", "EE301A", "FA5959", "FFFFFF", "D8D8D8", "4C4C4C", "782543", "000000"], - "weightLabels": ["1KG", "750G", "600G", "500G", "250G"], - "baudRates": [9600, 57600, 115200, 230400, 460800, 921600], - "logLevels": ["0 none", "1 error", "2 warn", "3 info", "4 debug"] + "version": "0.0.0-dev", + "colorSwatches": [ + "1200F6", + "3894E1", + "FEFF01", + "F8D531", + "F38E24", + "52D048", + "00FEBE", + "B700F3", + "EE301A", + "FA5959", + "FFFFFF", + "D8D8D8", + "4C4C4C", + "782543", + "000000" + ], + "weightLabels": [ + "1KG", + "750G", + "600G", + "500G", + "250G" + ], + "baudRates": [ + 9600, + 57600, + 115200, + 230400, + 460800, + 921600 + ], + "logLevels": [ + "0 none", + "1 error", + "2 warn", + "3 info", + "4 debug" + ] } diff --git a/spec/v2/fixtures/status.reply/idle.json b/spec/v2/fixtures/status.reply/idle.json index f991860..0b9dcef 100644 --- a/spec/v2/fixtures/status.reply/idle.json +++ b/spec/v2/fixtures/status.reply/idle.json @@ -1 +1,13 @@ -{"ok": true, "pending": false, "mode": "sta", "ip": "192.168.1.50", "last": {"done": false, "ok": false, "encrypted": false, "uid": "", "error": ""}} +{ + "ok": true, + "pending": false, + "mode": "sta", + "ip": "192.168.0.183", + "last": { + "done": false, + "ok": false, + "encrypted": false, + "uid": "", + "error": "" + } +} diff --git a/tools/build_web.py b/tools/build_web.py index 066dc5c..bdb6de5 100644 --- a/tools/build_web.py +++ b/tools/build_web.py @@ -27,7 +27,8 @@ def build_web() -> None: pnpm = shutil.which("pnpm") if not pnpm: sys.exit("pnpm not found — install Node + run `corepack enable`, or pass --skip-build") - subprocess.run([pnpm, "--dir", str(WEB), "install", "--prefer-offline"], check=True) + # Root pnpm workspace: installs web + spec/ts (the shared @spoolid/core). + subprocess.run([pnpm, "--dir", str(ROOT), "install", "--prefer-offline"], check=True) subprocess.run([pnpm, "--dir", str(WEB), "build"], check=True) diff --git a/web/config.html b/web/config.html index 1fc7175..3ca4c7c 100644 --- a/web/config.html +++ b/web/config.html @@ -4,7 +4,6 @@ SpoolID · Config -
diff --git a/web/index.html b/web/index.html index accef95..867c84c 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,6 @@ SpoolID -
diff --git a/web/package.json b/web/package.json index eaf98fc..25ac2c8 100644 --- a/web/package.json +++ b/web/package.json @@ -5,11 +5,14 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "pnpm --dir ../spec/ts generate && tsc && vite build", "preview": "vite preview" }, "devDependencies": { "typescript": "~6.0.3", "vite": "^8.1.3" + }, + "dependencies": { + "@spoolid/core": "workspace:*" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml deleted file mode 100644 index 4a4187a..0000000 --- a/web/pnpm-lock.yaml +++ /dev/null @@ -1,505 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - typescript: - specifier: ~6.0.3 - version: 6.0.3 - vite: - specifier: ^8.1.3 - version: 8.1.3 - -packages: - - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@oxc-project/types@0.138.0': - resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} - - '@rolldown/binding-android-arm64@1.1.4': - resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.1.4': - resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.1.4': - resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.1.4': - resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.1.4': - resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.1.4': - resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-musl@1.1.4': - resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-ppc64-gnu@1.1.4': - resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-s390x-gnu@1.1.4': - resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.1.4': - resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-musl@1.1.4': - resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.1.4': - resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.1.4': - resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.4': - resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.1.4': - resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.1': - resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} - engines: {node: ^10 || ^12 || >=14} - - rolldown@1.1.4: - resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} - hasBin: true - - vite@8.1.3: - resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 - esbuild: ^0.27.0 || ^0.28.0 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - -snapshots: - - '@emnapi/core@1.11.1': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.11.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true - - '@oxc-project/types@0.138.0': {} - - '@rolldown/binding-android-arm64@1.1.4': - optional: true - - '@rolldown/binding-darwin-arm64@1.1.4': - optional: true - - '@rolldown/binding-darwin-x64@1.1.4': - optional: true - - '@rolldown/binding-freebsd-x64@1.1.4': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.1.4': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.1.4': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.1.4': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.1.4': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.1.4': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.1.4': - optional: true - - '@rolldown/binding-linux-x64-musl@1.1.4': - optional: true - - '@rolldown/binding-openharmony-arm64@1.1.4': - optional: true - - '@rolldown/binding-wasm32-wasi@1.1.4': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.1.4': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.1.4': - optional: true - - '@rolldown/pluginutils@1.0.1': {} - - '@tybys/wasm-util@0.10.3': - dependencies: - tslib: 2.8.1 - optional: true - - detect-libc@2.1.2: {} - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - fsevents@2.3.3: - optional: true - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - nanoid@3.3.15: {} - - picocolors@1.1.1: {} - - picomatch@4.0.4: {} - - postcss@8.5.16: - dependencies: - nanoid: 3.3.15 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - rolldown@1.1.4: - dependencies: - '@oxc-project/types': 0.138.0 - '@rolldown/pluginutils': 1.0.1 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.4 - '@rolldown/binding-darwin-arm64': 1.1.4 - '@rolldown/binding-darwin-x64': 1.1.4 - '@rolldown/binding-freebsd-x64': 1.1.4 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 - '@rolldown/binding-linux-arm64-gnu': 1.1.4 - '@rolldown/binding-linux-arm64-musl': 1.1.4 - '@rolldown/binding-linux-ppc64-gnu': 1.1.4 - '@rolldown/binding-linux-s390x-gnu': 1.1.4 - '@rolldown/binding-linux-x64-gnu': 1.1.4 - '@rolldown/binding-linux-x64-musl': 1.1.4 - '@rolldown/binding-openharmony-arm64': 1.1.4 - '@rolldown/binding-wasm32-wasi': 1.1.4 - '@rolldown/binding-win32-arm64-msvc': 1.1.4 - '@rolldown/binding-win32-x64-msvc': 1.1.4 - - source-map-js@1.2.1: {} - - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tslib@2.8.1: - optional: true - - typescript@6.0.3: {} - - vite@8.1.3: - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.16 - rolldown: 1.1.4 - tinyglobby: 0.2.17 - optionalDependencies: - fsevents: 2.3.3 diff --git a/web/src/app.ts b/web/src/app.ts index ad9e10f..eec420d 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -1,9 +1,20 @@ // SpoolID device web UI — main page (brand -> filament -> size -> color -> Write). -// Firmware is the source of truth: constants come from /api/spec, data from /api/db. -// (style.css is served as a static asset via , not imported.) +// Firmware is the source of truth: constants come from /api/spec, data from +// /api/db. Shared parsing/prefill/status logic lives in @spoolid/core; this file +// is DOM glue only. +import "@spoolid/core/tokens.css"; +import "./style.css"; +import { + brands, + byBrand, + interpretWriteStatus, + normalizeHex, + parseMaterialDb, + readToPrefill, + withHash, +} from "@spoolid/core"; +import type { Material, ReadReply, SpecReply, StatusReply } from "@spoolid/core"; -// Helpers are inlined (not a shared module) so each page bundles to a single -// self-contained file — no shared chunk for the device to route. const $ = (id: string): T => document.getElementById(id) as T; const sel = (id: string) => $(id); @@ -16,13 +27,6 @@ function setStatus(msg: string, err = false): void { s.className = "status" + (err ? " err" : msg ? " ok" : ""); } -interface Material { - id: string; - brand: string; - name: string; - type: string; -} - let items: Material[] = []; let swatches: string[] = []; // hex strings with a leading '#' let color = "#1200F6"; @@ -48,13 +52,16 @@ function buildSwatches(): void { d.onclick = () => { setColor(hex); inp("custom").value = hex; }; wrap.appendChild(d); }); - inp("custom").oninput = (e) => setColor((e.target as HTMLInputElement).value); + inp("custom").oninput = (e) => { + const hex = normalizeHex((e.target as HTMLInputElement).value); + if (hex) setColor(withHash(hex)); + }; } async function loadSpec(): Promise { try { - const spec = await (await fetch("/api/spec")).json(); - swatches = (spec.colorSwatches || []).map((h: string) => "#" + h); + const spec = (await (await fetch("/api/spec")).json()) as SpecReply; + swatches = (spec.colorSwatches || []).map(withHash); sel("size").innerHTML = (spec.weightLabels || []) .map((w: string) => ``).join(""); buildSwatches(); @@ -65,24 +72,19 @@ async function loadSpec(): Promise { } function fillFilaments(): void { - const brand = sel("brand").value; - const list = items.filter((i) => i.brand === brand); + const list = byBrand(items, sel("brand").value); sel("filament").innerHTML = list .map((i) => ``).join(""); } function fillBrands(): void { - const brands = [...new Set(items.map((i) => i.brand))].sort(); - sel("brand").innerHTML = brands.map((b) => ``).join(""); + sel("brand").innerHTML = brands(items).map((b) => ``).join(""); fillFilaments(); } async function loadDb(): Promise { try { - const doc = await (await fetch("/api/db")).json(); // gzip auto-decoded - items = (doc.result?.list || []).map((it: any) => ({ - id: it.base.id, brand: it.base.brand, name: it.base.name, type: it.base.meterialType, - })); + items = parseMaterialDb(await (await fetch("/api/db")).json()); // gzip auto-decoded fillBrands(); } catch { setStatus("failed to load material DB", true); @@ -90,26 +92,24 @@ async function loadDb(): Promise { } async function pollStatus(): Promise { - const st = await (await fetch("/api/status")).json(); - if (st.last && st.last.done) { - if (polling !== null) { clearInterval(polling); polling = null; } - btn("write").disabled = false; - if (st.last.ok) { - setStatus(`written ✓ UID ${st.last.uid}${st.last.encrypted ? " (re-encrypted)" : ""}`, false); - } else { - setStatus(`error: ${st.last.error}`, true); - } - } + const st = (await (await fetch("/api/status")).json()) as StatusReply; + const outcome = interpretWriteStatus(st); + if (!outcome.done) return; + if (polling !== null) { clearInterval(polling); polling = null; } + btn("write").disabled = false; + setStatus(outcome.message ?? "", !outcome.ok); } -function applyRead(j: any): void { - const item = items.find((i) => i.id === j.materialId); - if (item) { sel("brand").value = item.brand; fillFilaments(); sel("filament").value = item.id; } - if (j.weight) sel("size").value = j.weight; - if (j.color) { setColor("#" + j.color); inp("custom").value = "#" + j.color; } - const name = item ? `${item.brand} ${item.name}` : `id ${j.materialId}`; - $("readout").textContent = - `read: ${name} · ${j.weight || "?"} · #${j.color}${j.encrypted ? " · locked" : ""}`; +function applyRead(j: ReadReply): void { + const pre = readToPrefill(j, items); + if (pre.material) { + sel("brand").value = pre.material.brand; + fillFilaments(); + sel("filament").value = pre.material.id; + } + if (pre.weight) sel("size").value = pre.weight; + if (pre.colorHex) { setColor(withHash(pre.colorHex)); inp("custom").value = withHash(pre.colorHex); } + $("readout").textContent = pre.label; } async function readTag(): Promise { @@ -118,7 +118,7 @@ async function readTag(): Promise { try { const j = await (await fetch("/api/read")).json(); if (!j.ok) { setStatus(`read failed: ${j.error}`, true); return; } - applyRead(j); + applyRead(j as ReadReply); setStatus("tag read ✓ — review values and Write", false); } catch { setStatus("read error", true); @@ -132,7 +132,7 @@ async function write(): Promise { setStatus("staging… tap a tag on the reader", false); const body = { materialId: sel("filament").value, - color: color.replace("#", ""), + color: normalizeHex(color) ?? "", weight: sel("size").value, }; const j = await (await fetch("/api/write", { diff --git a/web/src/config.ts b/web/src/config.ts index c4a27ab..82355b2 100644 --- a/web/src/config.ts +++ b/web/src/config.ts @@ -1,9 +1,10 @@ // SpoolID device web UI — config page: Device / Material database / OTA sub-tabs. -// Firmware is the source of truth: the selects come from /api/spec. -// (style.css is served as a static asset via , not imported.) +// Firmware is the source of truth: the selects come from /api/spec. Shared +// logic lives in @spoolid/core; this file is DOM glue only. +import "@spoolid/core/tokens.css"; +import "./style.css"; +import { isFilesystemImage } from "@spoolid/core"; -// Helpers are inlined (not a shared module) so each page bundles to a single -// self-contained file — no shared chunk for the device to route. const $ = (id: string): T => document.getElementById(id) as T; const sel = (id: string) => $(id); @@ -89,8 +90,8 @@ btn("upload").onclick = async () => { // ---- firmware OTA (upload a .bin; the device self-flashes via Update) ---- // Auto-pick the image type from the file name (littlefs.bin -> fs, else app). inp("otaFile").onchange = () => { - const name = (inp("otaFile").files?.[0]?.name || "").toLowerCase(); - if (name) sel("otaTarget").value = /littlefs|spiffs|filesystem/.test(name) ? "fs" : "app"; + const name = inp("otaFile").files?.[0]?.name || ""; + if (name) sel("otaTarget").value = isFilesystemImage(name) ? "fs" : "app"; }; // XHR (not fetch) for upload progress. MD5 is optional firmware-side, so the diff --git a/web/public/style.css b/web/src/style.css similarity index 85% rename from web/public/style.css rename to web/src/style.css index a080768..df6d551 100644 --- a/web/public/style.css +++ b/web/src/style.css @@ -1,12 +1,4 @@ -:root { - --bg: #14161a; - --card: #1e2127; - --fg: #e7e9ee; - --muted: #9aa0aa; - --accent: #3894e1; - --ok: #52d048; - --err: #ee301a; -} +/* Palette comes from @spoolid/core tokens.css (imported by each page). */ * { box-sizing: border-box; } /* [hidden] must win over the element display rules below (e.g. .pane grid). */ @@ -15,7 +7,7 @@ /* Config sub-tabs */ .subtabs { display: flex; gap: 8px; margin-bottom: 16px; } .subtab { - background: var(--card); + background: var(--surface); color: var(--muted); border: 1px solid #2a2e36; border-radius: 8px; @@ -24,21 +16,21 @@ font: inherit; font-size: 14px; } -.subtab:hover { color: var(--fg); } +.subtab:hover { color: var(--text); } .subtab.active { background: var(--accent); color: #fff; border-color: var(--accent); } .pane { display: grid; gap: 16px; } body { margin: 0; font: 16px/1.5 system-ui, sans-serif; background: var(--bg); - color: var(--fg); + color: var(--text); } header { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; - background: var(--card); + background: var(--surface); border-bottom: 1px solid #2a2e36; } header h1 { font-size: 20px; margin: 0; } @@ -47,8 +39,8 @@ main { max-width: 460px; margin: 0 auto; padding: 18px; display: grid; gap: 16px label { display: grid; gap: 6px; font-size: 14px; color: var(--muted); } select, input, button { font: inherit; - color: var(--fg); - background: var(--card); + color: var(--text); + background: var(--surface); border: 1px solid #2a2e36; border-radius: 8px; padding: 10px; @@ -62,7 +54,7 @@ legend { color: var(--muted); font-size: 14px; padding: 0 6px; } border: 2px solid transparent; cursor: pointer; } -.swatch.sel { border-color: var(--fg); outline: 2px solid var(--accent); } +.swatch.sel { border-color: var(--text); outline: 2px solid var(--accent); } .custom { flex-direction: row; align-items: center; margin-top: 12px; } .preview { display: flex; align-items: center; gap: 10px; margin-top: 10px; } .chip { width: 28px; height: 28px; border-radius: 6px; border: 1px solid #2a2e36; display: inline-block; } diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +///