From ce8e6df618a447b0e118a8d3360b8fdbde0fb420 Mon Sep 17 00:00:00 2001 From: Travis Bumgarner <11890057+TravisBumgarner@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:47:00 -0600 Subject: [PATCH 01/10] Add Electron desktop shell and dark mode Packages the app as an Electron desktop build (main/preload, dev launcher script, electron-builder config) so the plotter can be driven without a browser, and pipes main-process logs into the in-app log panel. Adds a light/dark theme provider with a persisted mode toggle, replacing the hardcoded light theme and the scattered literal border colors. Co-Authored-By: Claude Opus 5 (1M context) --- paint-app/.gitignore | 2 + paint-app/README.md | 63 +- paint-app/electron/main.ts | 317 ++ paint-app/electron/preload.cts | 15 + paint-app/electron/tsconfig.json | 19 + paint-app/package-lock.json | 3774 ++++++++++++++++++++- paint-app/package.json | 33 +- paint-app/scripts/dev-electron.mjs | 36 + paint-app/src/App.tsx | 16 +- paint-app/src/components/BottomBar.tsx | 6 +- paint-app/src/components/Canvas.tsx | 6 +- paint-app/src/components/LayersPanel.tsx | 18 +- paint-app/src/components/PrintModal.tsx | 9 +- paint-app/src/components/SettingsMenu.tsx | 23 +- paint-app/src/connection.tsx | 27 +- paint-app/src/index.css | 3 +- paint-app/src/serial.ts | 15 +- paint-app/src/theme.tsx | 73 + 18 files changed, 4248 insertions(+), 207 deletions(-) create mode 100644 paint-app/electron/main.ts create mode 100644 paint-app/electron/preload.cts create mode 100644 paint-app/electron/tsconfig.json create mode 100644 paint-app/scripts/dev-electron.mjs create mode 100644 paint-app/src/theme.tsx diff --git a/paint-app/.gitignore b/paint-app/.gitignore index 74eb732..ddde9a5 100644 --- a/paint-app/.gitignore +++ b/paint-app/.gitignore @@ -1,6 +1,8 @@ node_modules dist +dist-electron dist-ssr +release *.local *.tsbuildinfo .vite diff --git a/paint-app/README.md b/paint-app/README.md index 6fa6c1b..e1b5957 100644 --- a/paint-app/README.md +++ b/paint-app/README.md @@ -1,35 +1,67 @@ # paint-app -A browser-based paint app that draws on virtual pages with stacked, color-coded -layers and emits G-code to a USB-connected pen plotter (a Creality Ender-3 V3 -SE running stock Marlin in this repo). +A paint app that draws on virtual pages with stacked, color-coded layers and +emits G-code to a USB-connected pen plotter (a Creality Ender-3 V3 SE running +stock Marlin in this repo). It ships two ways from one codebase: an Electron +desktop app and a static site for Chrome / Edge. ## Stack -- Vite + React + TypeScript +- Electron (desktop shell) + Vite + React + TypeScript - Material UI (theme + dialogs + menus) - Dexie (IndexedDB) for project persistence - Zod for runtime schemas - Framer Motion for layer drag-and-drop reordering - Biome for lint + format -- [Web Serial API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API) for plotter I/O — Chrome / Edge only +- [Web Serial API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API) for plotter I/O — built into the desktop app; Chrome / Edge only in the browser +- electron-builder for desktop packaging ## Develop ```sh npm install -npm run dev +npm run dev:electron # desktop app (Vite dev server + Electron, HMR) +npm run dev # browser only, http://localhost:5173 ``` -The dev server runs on `http://localhost:5173`. Web Serial requires a secure -context, which `localhost` qualifies as. +`dev:electron` starts Vite and launches Electron against it in one process, so +quitting the app also stops the server. Web Serial requires a secure context; +`localhost` qualifies, as does the desktop app's custom scheme (below). ```sh -npm run build # production build -npm run check # biome lint + format with --write -npm run lint # biome check (no writes) +npm run build # renderer only (static site) +npm run build:electron # renderer + main process +npm run package # installers into release/ (dmg + zip, nsis, AppImage) +npm run package:dir # unpacked app into release/, skips installer creation +npm run check # biome lint + format with --write +npm run lint # biome check (no writes) ``` +Packaging builds for the host platform only; pass `electron-builder`'s +`--mac` / `--win` / `--linux` flags to target others. macOS builds are signed +with whatever local identity is available and are **not** notarized. + +## Desktop shell + +`electron/main.ts` is the whole shell — there is no preload and no IPC, because +the renderer is the same code the browser build runs. + +- **Custom scheme, not `file://`.** The packaged app serves `dist/` over + `paint-app://app/`, registered as a standard + secure scheme. `file://` pages + are an opaque origin, which makes `localStorage` and IndexedDB (where every + project and plotter lives) unreliable and leaves Web Serial without its + required secure context. The scheme also keeps the storage origin stable + across app updates. +- **Serial port picker.** Chromium's port chooser doesn't exist in Electron, so + main handles `select-serial-port`: ports reporting a USB `vendorId` are + preferred (this drops noise like macOS's Bluetooth-Incoming-Port), a lone + match is auto-selected, and anything more opens a native picker. +- **Unsaved-changes prompt.** Electron cancels `beforeunload` silently instead + of showing Chromium's leave-site dialog, so main answers `will-prevent-unload` + with a real confirm dialog. +- **Single instance.** A second launch focuses the existing window rather than + fighting it over the serial port and the IndexedDB lock. + ## Plotters Plotters are first-class entities, separate from projects. Each plotter has a @@ -83,8 +115,9 @@ strokes drawn while connected get plotted. the current project is `saved`, `saving…`, `saving soon`, or `unsaved`. - **Export / import JSON.** `Settings → Export JSON` downloads `.json`. `Import JSON` validates with Zod and creates a new project. -- **Beforeunload guard.** Closing the tab while changes are unsaved triggers - the browser's "leave this page?" prompt. +- **Beforeunload guard.** Closing while changes are unsaved prompts first — the + browser's "leave this page?" dialog on the web, a native confirm in the + desktop app. ## Features @@ -127,6 +160,10 @@ prototypes that predate this app. ## Layout ``` +electron/ + main.ts # Electron main process (protocol, serial, dialogs) +scripts/ + dev-electron.mjs # Vite dev server + Electron, one process src/ App.tsx # shell + connection wiring store.tsx # Context + useReducer app state diff --git a/paint-app/electron/main.ts b/paint-app/electron/main.ts new file mode 100644 index 0000000..1c64dda --- /dev/null +++ b/paint-app/electron/main.ts @@ -0,0 +1,317 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { Session } from 'electron'; +import { app, BrowserWindow, dialog, net, protocol, shell } from 'electron'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** Set by scripts/dev-electron.mjs; absent in a packaged build. */ +const DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL; + +/** Built renderer output (`vite build`), sibling of this file's dist-electron. */ +const RENDERER_DIR = path.join(dirname, '..', 'dist'); + +// The renderer is served from a custom scheme rather than file:// for two +// reasons: file:// pages are an opaque origin, so localStorage and IndexedDB +// (Dexie — every project and plotter lives there) are unreliable or outright +// blocked, and Web Serial requires a secure context. A registered standard + +// secure scheme gives a stable origin that persists across app updates. +const APP_SCHEME = 'paint-app'; +const APP_HOST = 'app'; +const APP_ORIGIN = `${APP_SCHEME}://${APP_HOST}`; +const APP_URL = `${APP_ORIGIN}/index.html`; + +protocol.registerSchemesAsPrivileged([ + { + scheme: APP_SCHEME, + privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true }, + }, +]); + +function registerAppProtocol() { + protocol.handle(APP_SCHEME, async (request) => { + const url = new URL(request.url); + if (url.host !== APP_HOST) return new Response('Not found', { status: 404 }); + + const relative = decodeURIComponent(url.pathname); + const resolved = path.normalize(path.join(RENDERER_DIR, relative)); + // Reject anything that escapes the bundle (../ traversal). + const inBundle = resolved === RENDERER_DIR || resolved.startsWith(RENDERER_DIR + path.sep); + + // SPA fallback: unknown paths serve index.html so client-side routing and + // a bare `paint-app://app/` both work. + const filePath = + inBundle && existsSync(resolved) && !resolved.endsWith(path.sep) + ? resolved + : path.join(RENDERER_DIR, 'index.html'); + + return net.fetch(pathToFileURL(filePath).toString()); + }); +} + +/** + * Main-process progress (port enumeration, permission checks, picker choices) + * is invisible to the renderer's DevTools and to stdout in a packaged app, so + * it goes both to the terminal and, via the preload bridge, to the app's own + * connection log. + */ +function mainLog(line: string) { + console.log(line); + for (const w of BrowserWindow.getAllWindows()) { + w.webContents.send('main-log', line); + } +} + +/** + * USB vendor IDs (decimal, as Chromium reports them) of the chips that show up + * on Marlin/GRBL boards: Arduino, FTDI, CH340, CP210x, STM32, Teensy, Atmel, + * RP2040, SparkFun. A port from one of these outranks anything else, which is + * what lets a single plotter be picked without asking. + */ +const PLOTTER_VENDOR_IDS = new Set([ + 0x2341, 0x2a03, 0x0403, 0x1a86, 0x10c4, 0x0483, 0x16c0, 0x03eb, 0x2e8a, 0x1b4f, 0x1d50, +]); + +/** How long to keep waiting for a device that isn't enumerated yet. */ +const PORT_WAIT_MS = 2500; + +/** + * One physical board can surface as several device nodes (a CH340 is both + * /dev/cu.usbserial-N and /dev/cu.wchusbserial-N), which would otherwise turn + * a single plotter into a two-button prompt. Same VID/PID/serial → same board. + */ +function dedupe(ports: Electron.SerialPort[]) { + const byDevice = new Map(); + for (const p of ports) { + const key = `${p.vendorId}:${p.productId}:${p.serialNumber ?? ''}`; + const existing = byDevice.get(key); + // Prefer the vendor-driver node (wchusbserial) over the generic alias; + // both work, but it's the one the board's own driver publishes. + if (!existing || /wch|usbmodem/i.test(p.portName ?? '')) byDevice.set(key, p); + } + return [...byDevice.values()]; +} + +/** + * Best-first grouping: known plotter chips, then any other USB device, then + * everything else (macOS's Bluetooth-Incoming-Port and friends). Only the best + * non-empty group is offered, so the noise never reaches the user. + */ +function rankPorts(ports: Electron.SerialPort[]) { + const known = dedupe(ports.filter((p) => PLOTTER_VENDOR_IDS.has(Number(p.vendorId)))); + if (known.length > 0) return known; + const usb = ports.filter((p) => p.vendorId && Number(p.vendorId) !== 0); + if (usb.length > 0) return usb; + return ports; +} + +/** `dialog.showMessageBoxSync` has no overload taking an optional parent. */ +function showBox(parent: BrowserWindow | null, options: Electron.MessageBoxSyncOptions) { + return parent ? dialog.showMessageBoxSync(parent, options) : dialog.showMessageBoxSync(options); +} + +function portLabel(p: Electron.SerialPort) { + const name = p.displayName || p.portName || p.portId; + return p.displayName && p.portName && p.displayName !== p.portName + ? `${p.displayName} (${p.portName})` + : name; +} + +/** + * Web Serial in Chromium hands port selection to the browser's own chooser; + * in Electron the main process must pick instead. `navigator.serial` also + * stays dark unless the session grants the `serial` permission, so both are + * wired here. + */ +function configureSerial(session: Session, getWindow: () => BrowserWindow | null) { + const allowedOrigins = new Set([APP_ORIGIN]); + if (DEV_SERVER_URL) allowedOrigins.add(new URL(DEV_SERVER_URL).origin); + + session.setPermissionCheckHandler((_wc, permission, requestingOrigin, details) => { + if (permission !== 'serial') return false; + // For `serial` the origin arrives on `details.securityOrigin`; + // `requestingOrigin` is empty, and treating that as a mismatch denies the + // request before `select-serial-port` ever fires — surfacing in the + // renderer as the misleading "No port selected by the user". + const origin = + requestingOrigin || + (details as { securityOrigin?: string }).securityOrigin || + (details as { origin?: string }).origin || + ''; + const allowed = allowedOrigins.has(origin.replace(/\/$/, '')); + if (!allowed) mainLog(`[serial] permission denied for origin: ${JSON.stringify(origin)}`); + return allowed; + }); + + session.setDevicePermissionHandler((details) => details.deviceType === 'serial'); + + session.on('select-serial-port', (event, portList, _webContents, callback) => { + event.preventDefault(); + + // Chromium's initial list is whatever it happens to have enumerated when + // requestPort() ran — frequently empty on the first call of a session, or + // when the board was plugged in moments ago. Keep the request open and + // fold in `serial-port-added` events instead of failing straight away. + const seen = new Map(); + for (const p of portList) seen.set(p.portId, p); + mainLog(`[serial] request; ${portList.length} port(s) enumerated`); + for (const p of portList) + mainLog(`[serial] ${portLabel(p)} vid=${p.vendorId} pid=${p.productId}`); + + let settled = false; + const timer = setTimeout(() => decide(true), PORT_WAIT_MS); + + const onAdded = (_e: unknown, port: Electron.SerialPort) => { + mainLog(`[serial] port appeared: ${portLabel(port)} vid=${port.vendorId}`); + seen.set(port.portId, port); + decide(false); + }; + const onRemoved = (_e: unknown, port: Electron.SerialPort) => { + seen.delete(port.portId); + }; + + const finish = (portId: string) => { + if (settled) return; + const chosen = portId ? seen.get(portId) : undefined; + mainLog(`[serial] selected: ${chosen ? portLabel(chosen) : 'none'}`); + settled = true; + clearTimeout(timer); + session.removeListener('serial-port-added', onAdded); + session.removeListener('serial-port-removed', onRemoved); + callback(portId); + }; + + /** + * `expired` is the difference between "nothing plausible yet, keep + * waiting" and "nothing plausible is coming, tell the user". + */ + function decide(expired: boolean) { + if (settled) return; + const candidates = rankPorts([...seen.values()]); + + if (candidates.length === 0) { + if (!expired) return; + showBox(getWindow(), { + type: 'warning', + title: 'No plotter found', + message: 'No serial devices were detected.', + detail: + 'Plug the plotter into USB and switch it on, then try connecting again. If it is already connected, close any other program using it (Arduino IDE, a serial monitor) and replug the cable.', + buttons: ['OK'], + }); + finish(''); + return; + } + + // Exactly one plausible device: connect to it without a prompt. A + // still-open hotplug window can't add a *better* candidate than a + // known plotter chip, so this is safe to take immediately. + if (candidates.length === 1) { + finish(candidates[0].portId); + return; + } + + // Several plausible devices — the one case that genuinely needs a human. + const labels = candidates.map(portLabel); + const picked = showBox(getWindow(), { + type: 'question', + title: 'Select plotter', + message: 'Which serial port is the plotter on?', + buttons: [...labels, 'Cancel'], + cancelId: labels.length, + defaultId: 0, + }); + finish(picked >= 0 && picked < candidates.length ? candidates[picked].portId : ''); + } + + session.on('serial-port-added', onAdded); + session.on('serial-port-removed', onRemoved); + decide(false); + }); +} + +/** + * The renderer guards unsaved work with `beforeunload`. Electron cancels the + * close silently in that case instead of showing Chromium's leave-site prompt, + * which would make the close button look broken — so ask here and force the + * close through when the user confirms. + */ +function confirmUnload(window: BrowserWindow) { + window.webContents.on('will-prevent-unload', (event) => { + const choice = dialog.showMessageBoxSync(window, { + type: 'warning', + title: 'Unsaved changes', + message: 'This project has unsaved changes.', + detail: 'Closing now will discard them.', + buttons: ['Cancel', 'Discard and close'], + cancelId: 0, + defaultId: 0, + }); + if (choice === 1) event.preventDefault(); + }); +} + +function createWindow() { + const window = new BrowserWindow({ + width: 1440, + height: 960, + minWidth: 900, + minHeight: 600, + backgroundColor: '#121212', + show: false, + webPreferences: { + preload: path.join(dirname, 'preload.cjs'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + window.once('ready-to-show', () => window.show()); + confirmUnload(window); + + // Anything that tries to open a new window (docs links, etc.) goes to the + // real browser rather than a chrome-less Electron window. + window.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: 'deny' }; + }); + + if (DEV_SERVER_URL) { + window.loadURL(DEV_SERVER_URL); + window.webContents.openDevTools({ mode: 'detach' }); + } else { + window.loadURL(APP_URL); + } + + return window; +} + +// A second instance would fight the first over the serial port and the +// IndexedDB lock, so hand off to the running window instead. +if (!app.requestSingleInstanceLock()) { + app.quit(); +} else { + let mainWindow: BrowserWindow | null = null; + + app.on('second-instance', () => { + if (!mainWindow) return; + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + }); + + app.whenReady().then(() => { + registerAppProtocol(); + mainWindow = createWindow(); + configureSerial(mainWindow.webContents.session, () => mainWindow); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) mainWindow = createWindow(); + }); + }); + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); + }); +} diff --git a/paint-app/electron/preload.cts b/paint-app/electron/preload.cts new file mode 100644 index 0000000..6d64282 --- /dev/null +++ b/paint-app/electron/preload.cts @@ -0,0 +1,15 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +/** + * The main process owns everything the renderer can't see — port enumeration, + * permission checks, the picker. Those steps used to be visible only on the + * terminal's stdout, which is invisible in a packaged app and easy to miss in + * dev, so they're forwarded here into the app's own connection log. + */ +contextBridge.exposeInMainWorld('desktop', { + onMainLog: (handler: (line: string) => void) => { + const listener = (_event: unknown, line: string) => handler(line); + ipcRenderer.on('main-log', listener); + return () => ipcRenderer.removeListener('main-log', listener); + }, +}); diff --git a/paint-app/electron/tsconfig.json b/paint-app/electron/tsconfig.json new file mode 100644 index 0000000..7b9938a --- /dev/null +++ b/paint-app/electron/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "nodenext", + "outDir": "../dist-electron", + "rootDir": ".", + "sourceMap": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + "skipLibCheck": true + }, + "include": ["."] +} diff --git a/paint-app/package-lock.json b/paint-app/package-lock.json index dc7c4c3..b0cdb55 100644 --- a/paint-app/package-lock.json +++ b/paint-app/package-lock.json @@ -23,6 +23,8 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "electron": "^43.2.0", + "electron-builder": "^26.15.3", "typescript": "^6.0.3", "vite": "^8.0.11" } @@ -341,6 +343,310 @@ "node": ">=14.21.3" } }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz", + "integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -521,6 +827,19 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -556,6 +875,61 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@mui/core-downloads-tracker": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.0.1.tgz", @@ -808,6 +1182,19 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oxc-project/types": { "version": "0.128.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", @@ -818,6 +1205,58 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -1110,6 +1549,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1121,38 +1586,105 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, "license": "MIT", "dependencies": { - "csstype": "^3.2.2" + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" } }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" + "dependencies": { + "@types/ms": "*" } }, - "node_modules/@types/react-transition-group": { + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-transition-group": { "version": "4.4.12", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", @@ -1161,145 +1693,1199 @@ "@types/react": "*" } }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-lib": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.3", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dexie": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz", + "integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==", + "license": "Apache-2.0" + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" - }, + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=12" }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, - "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { - "node": ">=10", - "npm": ">=6" + "node": ">= 0.4" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "license": "MIT", + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "node_modules/electron": { + "version": "43.2.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.2.0.tgz", + "integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==", + "dev": true, "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, "engines": { - "node": ">=6" + "node": ">= 22.12.0" } }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT" - }, - "node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "node_modules/electron-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "dev": true, "license": "MIT", "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" }, "engines": { - "node": ">=10" + "node": ">=14.0.0" } }, - "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "license": "ISC", - "engines": { - "node": ">= 6" + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "node_modules/electron-publish": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { - "ms": "^2.1.3" + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" }, "engines": { - "node": ">=6.0" + "node": ">=8.0.0" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, "engines": { - "node": ">=8" + "node": ">=6 <7 || >=8" } }, - "node_modules/dexie": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz", - "integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==", - "license": "Apache-2.0" + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -1309,6 +2895,16 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -1318,6 +2914,53 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1330,6 +2973,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1342,10 +3016,50 @@ "peerDependencies": { "picomatch": "^3 || ^4" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" } }, "node_modules/find-root": { @@ -1354,6 +3068,23 @@ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", "license": "MIT" }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/framer-motion": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", @@ -1381,6 +3112,28 @@ } } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1405,10 +3158,264 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1432,6 +3439,68 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -1448,6 +3517,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -1469,12 +3557,103 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1487,12 +3666,77 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1772,6 +4016,13 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1784,6 +4035,162 @@ "loose-envify": "cli.js" } }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/motion-dom": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", @@ -1824,6 +4231,136 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-abi": { + "version": "4.33.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", + "integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1833,6 +4370,53 @@ "node": ">=0.10.0" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -1863,6 +4447,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1878,6 +4482,21 @@ "node": ">=8" } }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1897,6 +4516,52 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -1926,6 +4591,77 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -1943,6 +4679,62 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -1986,6 +4778,73 @@ "react-dom": ">=16.6.0" } }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -2007,6 +4866,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2016,53 +4882,218 @@ "node": ">=4" } }, - "node_modules/rolldown": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", - "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", + "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.128.0", + "@rolldown/pluginutils": "1.0.0-rc.18" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-x64": "1.0.0-rc.18", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.128.0", - "@rolldown/pluginutils": "1.0.0-rc.18" - }, - "bin": { - "rolldown": "bin/cli.mjs" + "semver": "^7.5.3" }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.18", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", - "@rolldown/binding-darwin-x64": "1.0.0-rc.18", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" + "node": ">=10" } }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.18", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", - "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", - "dev": true, - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -2082,12 +5113,115 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "license": "MIT" }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -2100,6 +5234,79 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -2117,12 +5324,56 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -2137,6 +5388,77 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.0.11", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", @@ -2215,6 +5537,130 @@ } } }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/paint-app/package.json b/paint-app/package.json index 112e51c..e9d833a 100644 --- a/paint-app/package.json +++ b/paint-app/package.json @@ -3,13 +3,38 @@ "private": true, "version": "0.0.0", "type": "module", + "main": "dist-electron/main.js", "scripts": { "dev": "vite", + "dev:electron": "npm run build:main && node scripts/dev-electron.mjs", "build": "tsc -b && vite build", + "build:main": "tsc -p electron/tsconfig.json", + "build:electron": "npm run build && npm run build:main", + "package": "npm run build:electron && electron-builder", + "package:dir": "npm run build:electron && electron-builder --dir", "preview": "vite preview", - "lint": "biome check src", - "format": "biome format --write src", - "check": "biome check --write src" + "lint": "biome check src electron scripts", + "format": "biome format --write src electron scripts", + "check": "biome check --write src electron scripts" + }, + "build": { + "appId": "com.travisbumgarner.paint-app", + "productName": "paint-app", + "directories": { + "output": "release" + }, + "files": ["dist/**/*", "dist-electron/**/*", "package.json"], + "mac": { + "target": ["dmg", "zip"], + "category": "public.app-category.graphics-design" + }, + "win": { + "target": ["nsis"] + }, + "linux": { + "target": ["AppImage"], + "category": "Graphics" + } }, "dependencies": { "@emotion/react": "^11.14.0", @@ -27,6 +52,8 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "electron": "^43.2.0", + "electron-builder": "^26.15.3", "typescript": "^6.0.3", "vite": "^8.0.11" } diff --git a/paint-app/scripts/dev-electron.mjs b/paint-app/scripts/dev-electron.mjs new file mode 100644 index 0000000..26342f1 --- /dev/null +++ b/paint-app/scripts/dev-electron.mjs @@ -0,0 +1,36 @@ +// Dev runner for the desktop build: starts the Vite dev server, then launches +// Electron pointed at it so the renderer keeps HMR. Doing it in one process +// (rather than `concurrently` + `wait-on`) means the Electron window never +// races ahead of the server, and quitting the app tears the server down. +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import electron from 'electron'; +import { createServer } from 'vite'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const server = await createServer({ root, configFile: path.join(root, 'vite.config.ts') }); +await server.listen(); + +const url = server.resolvedUrls?.local?.[0]; +if (!url) { + await server.close(); + throw new Error('Vite dev server started without a local URL.'); +} +server.printUrls(); + +const child = spawn(electron, ['.'], { + cwd: root, + stdio: 'inherit', + env: { ...process.env, VITE_DEV_SERVER_URL: url }, +}); + +const shutdown = async (code) => { + await server.close().catch(() => {}); + process.exit(code ?? 0); +}; + +child.on('close', (code) => shutdown(code ?? 0)); +process.on('SIGINT', () => child.kill('SIGINT')); +process.on('SIGTERM', () => child.kill('SIGTERM')); diff --git a/paint-app/src/App.tsx b/paint-app/src/App.tsx index aaaf3e0..94b0ba2 100644 --- a/paint-app/src/App.tsx +++ b/paint-app/src/App.tsx @@ -1,4 +1,4 @@ -import { CssBaseline, createTheme, ThemeProvider } from '@mui/material'; +import { useTheme } from '@mui/material'; import { useCallback, useEffect, useRef, useState } from 'react'; import { BottomBar } from './components/BottomBar'; import { Canvas } from './components/Canvas'; @@ -11,17 +11,12 @@ import { useInteractiveSync } from './interactive'; import { PlottersProvider } from './plotters'; import { INTERACTIVE_PROJECT_ID, ProjectProvider, useProject } from './project'; import { StoreProvider } from './store'; +import { AppThemeProvider } from './theme'; import { UIProvider } from './ui'; -const theme = createTheme({ - palette: { mode: 'light' }, - shape: { borderRadius: 6 }, -}); - export const App = () => { return ( - - + @@ -33,7 +28,7 @@ export const App = () => { - + ); }; @@ -71,6 +66,7 @@ const LOG_HEIGHT_LS_KEY = 'paint-app:logHeight'; const MIN_LOG_HEIGHT = 60; const LogPanel = ({ lines }: { lines: string[] }) => { + const theme = useTheme(); const [height, setHeight] = useState(() => { const raw = Number(localStorage.getItem(LOG_HEIGHT_LS_KEY)); return raw >= MIN_LOG_HEIGHT ? raw : 120; @@ -110,7 +106,7 @@ const LogPanel = ({ lines }: { lines: string[] }) => {
{ borderRadius: '50%', cursor: 'pointer', background: c, - border: + border: (t) => c.toLowerCase() === activeColor.toLowerCase() - ? '2px solid #1976d2' - : '1px solid rgba(0,0,0,0.3)', + ? `2px solid ${t.palette.primary.main}` + : `1px solid ${t.palette.divider}`, }} /> ))} diff --git a/paint-app/src/components/Canvas.tsx b/paint-app/src/components/Canvas.tsx index 09cd49c..9bbd889 100644 --- a/paint-app/src/components/Canvas.tsx +++ b/paint-app/src/components/Canvas.tsx @@ -1,3 +1,4 @@ +import { useTheme } from '@mui/material'; import { useEffect, useMemo, useRef, useState } from 'react'; import { INTERACTIVE_PROJECT_ID, useProject } from '../project'; import { buildEllipse, buildLine, buildPolygon, buildRect, buildStar } from '../shapes'; @@ -19,6 +20,7 @@ type DrawingState = | { kind: 'shape'; tool: Exclude; start: Point; current: Point }; export const Canvas = () => { + const theme = useTheme(); const { state, addPage, addStroke, setActivePage, removePage } = useStore(); const { project } = useProject(); const { @@ -256,7 +258,9 @@ export const Canvas = () => { style={{ flex: 1, overflow: isInteractive ? 'hidden' : 'auto', - background: '#f0f0f0', + // Pages themselves stay white — they stand in for physical paper — + // so only the surrounding backdrop follows the theme. + background: theme.palette.mode === 'dark' ? '#181818' : '#f0f0f0', position: 'relative', }} > diff --git a/paint-app/src/components/LayersPanel.tsx b/paint-app/src/components/LayersPanel.tsx index d3f5d82..7efc8ee 100644 --- a/paint-app/src/components/LayersPanel.tsx +++ b/paint-app/src/components/LayersPanel.tsx @@ -2,12 +2,22 @@ import AddIcon from '@mui/icons-material/Add'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined'; import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; -import { Box, Button, IconButton, InputBase, TextField, Tooltip, Typography } from '@mui/material'; +import { + Box, + Button, + IconButton, + InputBase, + TextField, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; import { Reorder } from 'framer-motion'; import { useStore } from '../store'; import type { Layer } from '../types'; export const LayersPanel = () => { + const theme = useTheme(); const { state, addLayer, removeLayer, setActiveLayer, updateLayer, reorderLayers } = useStore(); const { layers, activeLayerId } = state; @@ -63,8 +73,10 @@ export const LayersPanel = () => { value={layer} style={{ padding: 10, - borderBottom: '1px solid #eee', - background: isActive ? '#eef6ff' : '#fff', + borderBottom: `1px solid ${theme.palette.divider}`, + background: isActive + ? theme.palette.action.selected + : theme.palette.background.paper, cursor: 'grab', }} onPointerDown={() => setActiveLayer(layer.id)} diff --git a/paint-app/src/components/PrintModal.tsx b/paint-app/src/components/PrintModal.tsx index b9314fe..8917925 100644 --- a/paint-app/src/components/PrintModal.tsx +++ b/paint-app/src/components/PrintModal.tsx @@ -131,7 +131,8 @@ export const PrintModal = ({ onClose }: Props) => { height: 14, borderRadius: '50%', background: p.color, - border: '1px solid #ccc', + border: '1px solid', + borderColor: 'divider', }} /> {p.layerName} @@ -155,7 +156,8 @@ export const PrintModal = ({ onClose }: Props) => { height: 14, borderRadius: '50%', background: phase.color, - border: '1px solid #ccc', + border: '1px solid', + borderColor: 'divider', }} /> @@ -173,7 +175,8 @@ export const PrintModal = ({ onClose }: Props) => { height: 14, borderRadius: '50%', background: phase.nextColor, - border: '1px solid #ccc', + border: '1px solid', + borderColor: 'divider', }} /> diff --git a/paint-app/src/components/SettingsMenu.tsx b/paint-app/src/components/SettingsMenu.tsx index 5c49568..9e55ca4 100644 --- a/paint-app/src/components/SettingsMenu.tsx +++ b/paint-app/src/components/SettingsMenu.tsx @@ -1,4 +1,5 @@ import AddIcon from '@mui/icons-material/Add'; +import DarkModeIcon from '@mui/icons-material/DarkMode'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined'; import DownloadIcon from '@mui/icons-material/Download'; import EditIcon from '@mui/icons-material/Edit'; @@ -31,6 +32,7 @@ import { db, type Project } from '../db'; import { usePlotters } from '../plotters'; import { INTERACTIVE_PROJECT_ID, useProject } from '../project'; import { useStore } from '../store'; +import { type ThemeMode, useThemeMode } from '../theme'; import { AppStateSchema, PlotterSchema } from '../types'; import { GRID_SIZE_MAX, GRID_SIZE_MIN, useUI } from '../ui'; import { PlottersModal } from './PlottersModal'; @@ -52,6 +54,8 @@ type MenuCtx = { setGridSize: (n: number) => void; showLog: boolean; setShowLog: (v: boolean) => void; + themeMode: ThemeMode; + toggleThemeMode: () => void; // project file ops autosave: boolean; setAutosave: (v: boolean) => void; @@ -124,6 +128,16 @@ const ShowGridItem: MenuItemComponent = ({ ctx }) => ( ); +const DarkModeItem: MenuItemComponent = ({ ctx }) => ( + + + + + + + +); + const ShowLogItem: MenuItemComponent = ({ ctx }) => ( ctx.setShowLog(!ctx.showLog)}> @@ -210,15 +224,15 @@ const CloseItem: MenuItemComponent = ({ ctx }) => ( type MenuKind = 'home' | 'project' | 'interactive'; const MENUS: Record = { - home: [[ShowLogItem], [ImportItem]], + home: [[DarkModeItem, ShowLogItem], [ImportItem]], project: [ - [AutosaveItem, ShowGridItem, ShowLogItem, SaveNowItem], + [AutosaveItem, ShowGridItem, DarkModeItem, ShowLogItem, SaveNowItem], [PlottersItem], [ExportItem, ImportItem], [RenameItem, DeleteItem], [CloseItem], ], - interactive: [[ShowGridItem, ShowLogItem], [PlottersItem], [CloseItem]], + interactive: [[ShowGridItem, DarkModeItem, ShowLogItem], [PlottersItem], [CloseItem]], }; const itemKey = (Comp: MenuItemComponent) => Comp.displayName ?? Comp.name; @@ -230,6 +244,7 @@ export const SettingsMenu = () => { const { ingestPlotter, getPlotter } = usePlotters(); const { showLog, setShowLog } = useConnection(); const { showGrid, setShowGrid, gridSize, setGridSize } = useUI(); + const { mode: themeMode, toggleMode: toggleThemeMode } = useThemeMode(); const { project, autosave, @@ -357,6 +372,8 @@ export const SettingsMenu = () => { setGridSize, showLog, setShowLog, + themeMode, + toggleThemeMode, autosave, setAutosave, isDirty, diff --git a/paint-app/src/connection.tsx b/paint-app/src/connection.tsx index ff24e56..c0751cb 100644 --- a/paint-app/src/connection.tsx +++ b/paint-app/src/connection.tsx @@ -1,6 +1,21 @@ -import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react'; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import { type ConnectPhase, PlotterConnection } from './serial'; +/** Exposed by electron/preload.cts; absent when running in a plain browser. */ +declare global { + interface Window { + desktop?: { onMainLog: (handler: (line: string) => void) => () => void }; + } +} + const SHOW_LOG_LS_KEY = 'paint-app:showLog'; type ConnectionCtx = { @@ -66,6 +81,16 @@ export const ConnectionProvider = ({ children }: { children: ReactNode }) => { [], ); + // Port enumeration and picking happen in Electron's main process, so those + // lines never reach this log on their own — the desktop bridge feeds them in. + useEffect(() => { + if (!window.desktop) return; + return window.desktop.onMainLog((line) => { + const ts = (performance.now() / 1000).toFixed(3).padStart(8, ' '); + setLog((prev) => [...prev.slice(-200), `${ts} · ${line}`]); + }); + }, []); + const connect = useCallback(async () => { setConnectPhase('connecting'); setConnectError(null); diff --git a/paint-app/src/index.css b/paint-app/src/index.css index dc54cb8..1983f58 100644 --- a/paint-app/src/index.css +++ b/paint-app/src/index.css @@ -1,8 +1,7 @@ :root { font-family: -apple-system, system-ui, "Segoe UI", Roboto, sans-serif; font-size: 14px; - color: #1a1a1a; - background: #fafafa; + /* Colors come from the MUI theme via CssBaseline so both modes work. */ } * { diff --git a/paint-app/src/serial.ts b/paint-app/src/serial.ts index 294de19..5edba4a 100644 --- a/paint-app/src/serial.ts +++ b/paint-app/src/serial.ts @@ -89,7 +89,20 @@ export class PlotterConnection { if (this.port) { throw new Error('Already connected. Disconnect before connecting again.'); } - const port = await navigator.serial.requestPort(); + let port: SerialPort; + try { + port = await navigator.serial.requestPort(); + } catch (e) { + // Fires both when no device could be offered and when the picker was + // dismissed; the raw DOMException ("No port selected by the user") reads + // like a bug rather than "plug the plotter in". + if ((e as Error).name === 'NotFoundError') { + throw new Error( + 'No plotter selected. Make sure it is plugged in over USB and powered on, then try again.', + ); + } + throw e; + } this.onPhase('connecting'); // The browser keeps a port open across our own teardown bugs, other tabs, // or a prior connect() that threw after open() — re-opening such a port diff --git a/paint-app/src/theme.tsx b/paint-app/src/theme.tsx new file mode 100644 index 0000000..352054a --- /dev/null +++ b/paint-app/src/theme.tsx @@ -0,0 +1,73 @@ +import { CssBaseline, createTheme, ThemeProvider } from '@mui/material'; +import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react'; + +export type ThemeMode = 'light' | 'dark'; + +const THEME_MODE_LS_KEY = 'paint-app:themeMode'; + +const readStoredMode = (): ThemeMode => { + const raw = localStorage.getItem(THEME_MODE_LS_KEY); + if (raw === 'light' || raw === 'dark') return raw; + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +}; + +type ThemeModeCtx = { + mode: ThemeMode; + setMode: (m: ThemeMode) => void; + toggleMode: () => void; +}; + +const ThemeModeContext = createContext(null); + +export const AppThemeProvider = ({ children }: { children: ReactNode }) => { + const [mode, setModeState] = useState(readStoredMode); + + const setMode = useCallback((m: ThemeMode) => { + setModeState(m); + localStorage.setItem(THEME_MODE_LS_KEY, m); + }, []); + + const value = useMemo( + () => ({ + mode, + setMode, + toggleMode: () => setMode(mode === 'dark' ? 'light' : 'dark'), + }), + [mode, setMode], + ); + + const theme = useMemo( + () => + createTheme({ + palette: + mode === 'dark' + ? { + mode: 'dark', + background: { default: '#121212', paper: '#1e1e1e' }, + } + : { mode: 'light' }, + shape: { borderRadius: 6 }, + components: { + // Tells the browser to render native widgets (scrollbars, the + // color/number inputs used in the layer + color pickers) to match. + MuiCssBaseline: { styleOverrides: { ':root': { colorScheme: mode } } }, + }, + }), + [mode], + ); + + return ( + + + + {children} + + + ); +}; + +export const useThemeMode = () => { + const ctx = useContext(ThemeModeContext); + if (!ctx) throw new Error('useThemeMode must be used within AppThemeProvider'); + return ctx; +}; From ae1e20b3b12e586d27856d82cdcefd1f3d70564e Mon Sep 17 00:00:00 2001 From: Travis Bumgarner <11890057+TravisBumgarner@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:47:28 -0600 Subject: [PATCH 02/10] Treat plotters as printers rather than document properties Starting an interactive session required a configured plotter and a live connection first, because a document was created *from* a plotter: its page was sized to the bed and its state stored a plotterId. That made a machine a prerequisite for drawing at all. Documents are now plotter-independent. A page size is chosen at creation (from a plotter's bed, a standard paper size, or custom, defaulting to whatever was used last), and which plotter output goes to is a global session choice, the way a printer is picked when printing a document. plotterId is dropped from AppState; v1 `settings` and v2 `plotterId` states still load, since zod strips the stale field and the page geometry we need was already there. That also retires the migration path that fabricated a plotter from a legacy project, and the delete guard that blocked removing an in-use plotter. Consequently the plotter picker, connect/disconnect, pause, and emergency stop move out of the setup screen into a top bar mounted above the document/home swap, so they work from anywhere and survive closing a document. Start, Create, and Open no longer gate on a connection, and acknowledging an emergency stop keeps your work open instead of kicking you back to setup. Since a page can now be larger than the plotter it's sent to, printing warns on overflow and offers to clip to the bed; the interactive stream clips silently, as there's no place to prompt per stroke. Co-Authored-By: Claude Opus 5 (1M context) --- paint-app/src/App.tsx | 20 +- paint-app/src/components/DebugModal.tsx | 3 +- paint-app/src/components/PlotterControls.tsx | 192 ++++++++ paint-app/src/components/PlottersModal.tsx | 57 +-- paint-app/src/components/PrintModal.tsx | 53 ++- paint-app/src/components/ProjectGate.tsx | 465 ++++++++----------- paint-app/src/components/SettingsMenu.tsx | 12 +- paint-app/src/components/Toolbar.tsx | 127 ++--- paint-app/src/gcode.ts | 41 +- paint-app/src/interactive.tsx | 20 +- paint-app/src/pageSizes.ts | 44 ++ paint-app/src/plotters.tsx | 72 ++- paint-app/src/store.tsx | 15 +- paint-app/src/types.ts | 32 +- 14 files changed, 673 insertions(+), 480 deletions(-) create mode 100644 paint-app/src/components/PlotterControls.tsx create mode 100644 paint-app/src/pageSizes.ts diff --git a/paint-app/src/App.tsx b/paint-app/src/App.tsx index 94b0ba2..bfd9ccd 100644 --- a/paint-app/src/App.tsx +++ b/paint-app/src/App.tsx @@ -1,4 +1,4 @@ -import { useTheme } from '@mui/material'; +import { Alert, useTheme } from '@mui/material'; import { useCallback, useEffect, useRef, useState } from 'react'; import { BottomBar } from './components/BottomBar'; import { Canvas } from './components/Canvas'; @@ -8,7 +8,7 @@ import { ProjectGate } from './components/ProjectGate'; import { Toolbar } from './components/Toolbar'; import { ConnectionProvider, useConnection } from './connection'; import { useInteractiveSync } from './interactive'; -import { PlottersProvider } from './plotters'; +import { PlottersProvider, usePlotters } from './plotters'; import { INTERACTIVE_PROJECT_ID, ProjectProvider, useProject } from './project'; import { StoreProvider } from './store'; import { AppThemeProvider } from './theme'; @@ -35,29 +35,39 @@ export const App = () => { const Root = () => { const { project } = useProject(); const { log, showLog } = useConnection(); + const [printing, setPrinting] = useState(false); + return (
+ {/* One bar for the whole app: plotter selection and the connection are + global, so they must outlive the document being open or closed. */} + setPrinting(true)} />
{project ? : }
{showLog && } + {printing && project && setPrinting(false)} />}
); }; const Shell = () => { - const [printing, setPrinting] = useState(false); const { project } = useProject(); + const { activePlotter } = usePlotters(); const isInteractive = project?.id === INTERACTIVE_PROJECT_ID; useInteractiveSync(); return (
- setPrinting(true)} /> + {!activePlotter && ( + + No plotter configured — drawing works, but nothing can be sent. Choose{' '} + Add plotter in the top bar when you're ready to plot. + + )}
{!isInteractive && }
- {printing && setPrinting(false)} />}
); }; diff --git a/paint-app/src/components/DebugModal.tsx b/paint-app/src/components/DebugModal.tsx index e339575..a9206dd 100644 --- a/paint-app/src/components/DebugModal.tsx +++ b/paint-app/src/components/DebugModal.tsx @@ -35,8 +35,7 @@ const JOG_STEPS = [0.1, 1, 10]; export const DebugModal = ({ onClose }: Props) => { const { connection } = useConnection(); const { state } = useStore(); - const { getPlotter } = usePlotters(); - const plotter = getPlotter(state.plotterId) ?? null; + const { activePlotter: plotter } = usePlotters(); const activePage = state.pages.find((p) => p.id === state.activePageId) ?? state.pages[0]; const [busy, setBusy] = useState(false); diff --git a/paint-app/src/components/PlotterControls.tsx b/paint-app/src/components/PlotterControls.tsx new file mode 100644 index 0000000..bc975a1 --- /dev/null +++ b/paint-app/src/components/PlotterControls.tsx @@ -0,0 +1,192 @@ +import DangerousIcon from '@mui/icons-material/Dangerous'; +import PauseIcon from '@mui/icons-material/Pause'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import PrintIcon from '@mui/icons-material/Print'; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Tooltip, +} from '@mui/material'; +import { useState } from 'react'; +import { useConnection } from '../connection'; +import { usePlotters } from '../plotters'; +import { PlottersModal } from './PlottersModal'; + +/** + * Which plotter output goes to, and the live state of the link to it. Global + * rather than per-document — the same session can draw one thing and send it + * to whichever machine is plugged in, so these controls live in the top bar + * and stay mounted whether or not a document is open. + */ +export const PlotterControls = () => { + const { plotters, activePlotter, setActivePlotter } = usePlotters(); + const { + connected, + connecting, + connectPhase, + connect, + disconnect, + connectError, + dismissConnectError, + paused, + pause, + resume, + emergencyStop, + emergencyStopped, + acknowledgeEmergencyStop, + } = useConnection(); + + const [anchor, setAnchor] = useState(null); + const [plottersOpen, setPlottersOpen] = useState(false); + + const connectLabel = + connectPhase === 'homing' + ? 'Homing…' + : connectPhase === 'connecting' + ? 'Connecting…' + : 'Connect'; + + return ( + <> + + + + setAnchor(null)}> + {plotters.map((p) => ( + { + setActivePlotter(p.id); + setAnchor(null); + }} + > + + + ))} + {plotters.length > 0 && } + { + setPlottersOpen(true); + setAnchor(null); + }} + > + + + + + + + + {connected ? ( + + ) : ( + + )} + + {connected && ( + + + + )} + + {connected && ( + <> + {/* Kept at the far end of the bar, away from Disconnect — a + misclick here costs a power cycle. */} + + + + + + )} + + setPlottersOpen(false)} /> + + + Connection failed + + {connectError} + + + + + + + + Emergency stop triggered + + + The plotter received an emergency stop (M112) and its firmware is now halted. It will + not respond to any commands until you power cycle the 3D printer (turn + it off, wait a few seconds, then turn it back on). + + + After power cycling, reconnect from the top bar. Your work stays open. + + + + + + + + ); +}; diff --git a/paint-app/src/components/PlottersModal.tsx b/paint-app/src/components/PlottersModal.tsx index 2a25e77..9f805fb 100644 --- a/paint-app/src/components/PlottersModal.tsx +++ b/paint-app/src/components/PlottersModal.tsx @@ -7,6 +7,7 @@ import { Box, Button, ButtonBase, + Chip, Dialog, DialogActions, DialogContent, @@ -21,8 +22,7 @@ import { TextField, Typography, } from '@mui/material'; -import { useEffect, useState } from 'react'; -import { db } from '../db'; +import { useState } from 'react'; import { type PlotterDraft, usePlotters } from '../plotters'; import { PlotterCalibration } from './PlotterCalibration'; @@ -41,29 +41,11 @@ type Mode = 'manual' | 'calibrate' | null; type Props = { open: boolean; onClose: () => void }; export const PlottersModal = ({ open, onClose }: Props) => { - const { plotters, createPlotter, updatePlotter, deletePlotter } = usePlotters(); + const { plotters, activePlotter, setActivePlotter, createPlotter, updatePlotter, deletePlotter } = + usePlotters(); const [draft, setDraft] = useState(EMPTY_DRAFT); const [mode, setMode] = useState(null); const [editingId, setEditingId] = useState(null); - const [usageById, setUsageById] = useState>({}); - - useEffect(() => { - if (!open) return; - let cancelled = false; - (async () => { - const all = await db.projects.toArray(); - const counts: Record = {}; - for (const p of all) { - const id = p.state.plotterId; - if (!id) continue; - counts[id] = (counts[id] ?? 0) + 1; - } - if (!cancelled) setUsageById(counts); - })(); - return () => { - cancelled = true; - }; - }, [open]); const onSubmit = async () => { if (!draft.name.trim()) return; @@ -88,13 +70,6 @@ export const PlottersModal = ({ open, onClose }: Props) => { }; const onDelete = async (id: string) => { - const inUse = usageById[id] ?? 0; - if (inUse > 0) { - alert( - `This plotter is used by ${inUse} project${inUse === 1 ? '' : 's'} and can't be deleted.`, - ); - return; - } if (!confirm('Delete this plotter? This cannot be undone.')) return; await deletePlotter(id); }; @@ -110,10 +85,9 @@ export const PlottersModal = ({ open, onClose }: Props) => { Plotters - - Projects reference a plotter by id. Editing one changes every project that uses it the - next time it builds G-code — create a separate plotter instead if you only want the change - for a new project. + + Plotters are output targets, like printers — drawings don't belong to one. Pick which + plotter to send to from the top bar at any time. @@ -126,13 +100,20 @@ export const PlottersModal = ({ open, onClose }: Props) => { ) : ( {plotters.map((p) => { - const usage = usageById[p.id] ?? 0; + const isActive = activePlotter?.id === p.id; return ( + + {isActive ? ( + + ) : ( + + )} onEdit(p.id)} title="Edit"> @@ -140,10 +121,7 @@ export const PlottersModal = ({ open, onClose }: Props) => { edge="end" size="small" onClick={() => onDelete(p.id)} - disabled={usage > 0} - title={ - usage > 0 ? `Used by ${usage} project${usage === 1 ? '' : 's'}` : 'Delete' - } + title="Delete" > @@ -156,7 +134,6 @@ export const PlottersModal = ({ open, onClose }: Props) => { <> Bed {p.bedWidth}×{p.bedHeight}mm · travel {p.travelFeed} · draw {p.drawFeed}{' '} · Z up {p.penUpZ} / down {p.penDownZ} - {usage > 0 && ` · used by ${usage} project${usage === 1 ? '' : 's'}`} } /> diff --git a/paint-app/src/components/PrintModal.tsx b/paint-app/src/components/PrintModal.tsx index 8917925..aa83fe9 100644 --- a/paint-app/src/components/PrintModal.tsx +++ b/paint-app/src/components/PrintModal.tsx @@ -1,16 +1,20 @@ import { + Alert, + AlertTitle, Box, Button, + Checkbox, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, + FormControlLabel, Typography, } from '@mui/material'; import { useEffect, useRef, useState } from 'react'; import { useConnection } from '../connection'; -import { buildLayerPrograms, EPILOGUE, PROLOGUE } from '../gcode'; +import { buildLayerPrograms, EPILOGUE, PROLOGUE, pageFitsBed } from '../gcode'; import { usePlotters } from '../plotters'; import { useStore } from '../store'; @@ -27,12 +31,20 @@ type Phase = export const PrintModal = ({ onClose }: Props) => { const { state } = useStore(); - const { getPlotter } = usePlotters(); + const { activePlotter: plotter } = usePlotters(); const { connection } = useConnection(); - const { pages, layers, activePageId, plotterId } = state; + const { pages, layers, activePageId } = state; const activePage = pages.find((p) => p.id === activePageId); - const plotter = getPlotter(plotterId) ?? null; - const programs = activePage && plotter ? buildLayerPrograms(layers, activePage, plotter) : []; + + // A document is sized independently of any machine, so the page can be + // bigger than the plotter it's being sent to. Printing is blocked until the + // user decides what should happen to the overflow. + const [clipToBed, setClipToBed] = useState(false); + const overflows = Boolean(activePage && plotter && !pageFitsBed(activePage, plotter)); + const blockedByOverflow = overflows && !clipToBed; + + const programs = + activePage && plotter ? buildLayerPrograms(layers, activePage, plotter, clipToBed) : []; const [phase, setPhase] = useState({ kind: 'idle' }); const cancelled = useRef(false); @@ -66,9 +78,10 @@ export const PrintModal = ({ onClose }: Props) => { const start = async () => { if (!plotter) { - setPhase({ kind: 'error', message: 'No plotter selected for this project.' }); + setPhase({ kind: 'error', message: 'No plotter selected. Choose one from the top bar.' }); return; } + if (blockedByOverflow) return; if (!activePage || programs.length === 0) { setPhase({ kind: 'error', message: 'Nothing to print on the active page.' }); return; @@ -119,9 +132,33 @@ export const PrintModal = ({ onClose }: Props) => { Plotter: {plotter?.name ?? '— none —'} + {overflows && activePage && plotter && ( + + Page is larger than the bed + This page is {activePage.width}×{activePage.height}mm but {plotter.name} can only + reach {plotter.bedWidth}×{plotter.bedHeight}mm + {activePage.width > plotter.bedWidth && activePage.height > plotter.bedHeight + ? ' — it overflows in both directions.' + : activePage.width > plotter.bedWidth + ? ' — it overflows horizontally.' + : ' — it overflows vertically.'} + setClipToBed(e.target.checked)} + /> + } + label="Clip to the bed and print what fits" + /> + + )} About to print {programs.length} visible layer - {programs.length === 1 ? '' : 's'} on the active page. + {programs.length === 1 ? '' : 's'} on the active page + {clipToBed && overflows ? ', clipped to the bed' : ''}. {programs.map((p) => ( @@ -192,7 +229,7 @@ export const PrintModal = ({ onClose }: Props) => { diff --git a/paint-app/src/components/ProjectGate.tsx b/paint-app/src/components/ProjectGate.tsx index 0df9eab..380797a 100644 --- a/paint-app/src/components/ProjectGate.tsx +++ b/paint-app/src/components/ProjectGate.tsx @@ -1,15 +1,8 @@ import BoltIcon from '@mui/icons-material/Bolt'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined'; import { - AppBar, Box, Button, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, Divider, FormControl, IconButton, @@ -18,71 +11,77 @@ import { ListItem, ListItemButton, ListItemText, + ListSubheader, MenuItem, - Toolbar as MuiToolbar, Paper, Select, Stack, TextField, - Tooltip, Typography, } from '@mui/material'; -import { useCallback, useEffect, useState } from 'react'; -import { useConnection } from '../connection'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { db, type Project } from '../db'; -import { type PlotterDraft, usePlotters } from '../plotters'; +import { + DEFAULT_PAGE_SIZE, + formatSize, + loadLastPageSize, + type PageSize, + plotterPageSize, + STANDARD_SIZES, + saveLastPageSize, +} from '../pageSizes'; +import { usePlotters } from '../plotters'; import { INTERACTIVE_PROJECT_ID, useProject } from './../project'; import { createInitialState } from '../store'; -import { type AppState, AppStateSchema, LegacyAppStateSchema } from '../types'; -import { PlottersModal } from './PlottersModal'; -import { SettingsMenu } from './SettingsMenu'; +import { AppStateSchema } from '../types'; const uid = () => Math.random().toString(36).slice(2, 10); -/** - * Bring a stored AppState onto the current schema. Returns `[migratedState, plotterToCreate?]` - * where plotterToCreate is non-null if we had to fabricate a plotter from a - * legacy project's inline `settings` block. - */ -const migrateState = (raw: unknown): { state: AppState; plotterDraft?: PlotterDraft } | null => { - const direct = AppStateSchema.safeParse(raw); - if (direct.success) return { state: direct.data }; - const legacy = LegacyAppStateSchema.safeParse(raw); - if (legacy.success) { - const tempPlotterId = uid(); - return { - state: { - pages: legacy.data.pages, - layers: legacy.data.layers, - activePageId: legacy.data.activePageId, - activeLayerId: legacy.data.activeLayerId, - plotterId: tempPlotterId, - }, - plotterDraft: { - name: 'Migrated plotter', - ...legacy.data.settings, - }, - }; - } - return null; -}; +const CUSTOM_KEY = 'custom'; export const ProjectGate = () => { const { setProject } = useProject(); - const { plotters, ready: plottersReady, createPlotter } = usePlotters(); - const { - connected, - connecting, - connectPhase, - connect, - disconnect, - connectError, - dismissConnectError, - } = useConnection(); + const { plotters } = usePlotters(); const [projects, setProjects] = useState(null); const [name, setName] = useState(''); - const [pickedPlotterId, setPickedPlotterId] = useState(''); - const [plottersOpen, setPlottersOpen] = useState(false); + + // Page size selection. The key drives the dropdown; `custom` holds the + // hand-entered dimensions so switching to Custom and back is lossless. + const [sizeKey, setSizeKey] = useState(CUSTOM_KEY); + const [custom, setCustom] = useState(DEFAULT_PAGE_SIZE); + + const options = useMemo(() => { + const fromPlotters = plotters.map((p) => ({ + key: `plotter:${p.id}`, + label: `${p.name} (bed)`, + size: plotterPageSize(p), + })); + const standard = STANDARD_SIZES.map((s) => ({ + key: `std:${s.label}`, + label: s.label, + size: s.size, + })); + return { fromPlotters, standard }; + }, [plotters]); + + // Seed from the last-used size: preselect whichever option matches it so the + // common case (same paper every time) is already correct on arrival. Plotters + // arrive asynchronously, so this re-runs when they land — but never after the + // user has made a choice of their own. + const touched = useRef(false); + useEffect(() => { + if (touched.current) return; + const last = loadLastPageSize(); + setCustom(last); + const all = [...options.fromPlotters, ...options.standard]; + const match = all.find((o) => o.size.width === last.width && o.size.height === last.height); + setSizeKey(match?.key ?? CUSTOM_KEY); + }, [options]); + + const size: PageSize = useMemo(() => { + const all = [...options.fromPlotters, ...options.standard]; + return all.find((o) => o.key === sizeKey)?.size ?? custom; + }, [sizeKey, custom, options]); const refresh = useCallback(async () => { const all = await db.projects.orderBy('updatedAt').reverse().toArray(); @@ -93,23 +92,15 @@ export const ProjectGate = () => { refresh(); }, [refresh]); - // Default the new-project plotter selector to the most recent plotter. - useEffect(() => { - if (!pickedPlotterId && plotters.length > 0) { - setPickedPlotterId(plotters[plotters.length - 1].id); - } - }, [plotters, pickedPlotterId]); - const onCreate = async () => { const trimmed = name.trim(); - if (!trimmed || !pickedPlotterId) return; - const plotter = plotters.find((p) => p.id === pickedPlotterId); - if (!plotter) return; + if (!trimmed || size.width <= 0 || size.height <= 0) return; + saveLastPageSize(size); const now = Date.now(); const project: Project = { id: uid(), name: trimmed, - state: createInitialState(plotter), + state: createInitialState(size), createdAt: now, updatedAt: now, }; @@ -120,31 +111,23 @@ export const ProjectGate = () => { const onOpen = async (id: string) => { const project = await db.projects.get(id); if (!project) return; - const migrated = migrateState(project.state); - if (!migrated) { + // Older documents carried plotter references (v1 `settings`, v2 + // `plotterId`); the schema drops them and keeps the page geometry. + const parsed = AppStateSchema.safeParse(project.state); + if (!parsed.success) { alert('This project has an unrecognised state shape and cannot be opened.'); return; } - let { state } = migrated; - if (migrated.plotterDraft) { - const newPlotter = await createPlotter(migrated.plotterDraft); - state = { ...state, plotterId: newPlotter.id }; - } - if (state !== project.state) { - await db.projects.update(id, { state }); - } - setProject({ id: project.id, name: project.name }, state); + setProject({ id: project.id, name: project.name }, parsed.data); }; const onStartInteractive = async () => { - const plotter = plotters[plotters.length - 1]; - if (!plotter) return; // Interactive sessions are ephemeral. Wipe any leftover persisted record // (from earlier app versions or aborted runs) and start in memory only. await db.projects.delete(INTERACTIVE_PROJECT_ID).catch(() => {}); setProject( { id: INTERACTIVE_PROJECT_ID, name: 'Interactive session' }, - createInitialState(plotter), + createInitialState(loadLastPageSize()), ); }; @@ -157,207 +140,151 @@ export const ProjectGate = () => { }; return ( - - - - - - paint-app - - - - - - - - {!plottersReady ? ( - - Loading plotters… - - ) : ( - - - {plotters.length === 0 ? ( - - No plotters configured — define one before starting. - - ) : ( - - Plotter - - - )} - - {connected ? ( - - ) : ( - - )} - - {connected && ( - - Connected — homed and ready. - - )} - - )} + + + + + + + + Interactive session + + + One live document — every stroke is sent to the plotter as soon as you draw it. + Choose and connect a plotter from the top bar once you're in. + + + - + - 0} - arrow - > - - - - - Interactive session - - - One live document — every stroke is sent to the plotter as soon as you draw it. - - - - - + - + {sizeKey === CUSTOM_KEY && ( + + { + touched.current = true; + setCustom((c) => ({ ...c, width: Number.parseFloat(e.target.value) || 0 })); + }} + sx={{ width: 140 }} + /> + × + { + touched.current = true; + setCustom((c) => ({ ...c, height: Number.parseFloat(e.target.value) || 0 })); + }} + sx={{ width: 140 }} + /> + + )} - - - Projects - - {!plottersReady ? ( - - Loading plotters… - - ) : plotters.length === 0 ? ( + {projects === null ? ( - Create a plotter above first. + Loading… + + ) : visibleProjects.length === 0 ? ( + + No saved projects yet. ) : ( - - - setName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && onCreate()} - autoFocus - /> - - - {projects === null ? ( - - Loading… - - ) : visibleProjects.length === 0 ? ( - - No saved projects yet. - - ) : ( - - {visibleProjects.map((p) => ( - onDelete(p.id)} - > - - - } + + {visibleProjects.map((p) => ( + onDelete(p.id)} > - onOpen(p.id)} disabled={!connected}> - - - - ))} - - )} - + + + } + > + onOpen(p.id)}> + + + + ))} + )} - - - - - - setPlottersOpen(false)} /> - - - Connection failed - - {connectError} - - - - - + + + + ); }; diff --git a/paint-app/src/components/SettingsMenu.tsx b/paint-app/src/components/SettingsMenu.tsx index 9e55ca4..58b5e4a 100644 --- a/paint-app/src/components/SettingsMenu.tsx +++ b/paint-app/src/components/SettingsMenu.tsx @@ -241,7 +241,7 @@ const itemKey = (Comp: MenuItemComponent) => Comp.displayName ?? Comp.name; export const SettingsMenu = () => { const { state } = useStore(); - const { ingestPlotter, getPlotter } = usePlotters(); + const { ingestPlotter } = usePlotters(); const { showLog, setShowLog } = useConnection(); const { showGrid, setShowGrid, gridSize, setGridSize } = useUI(); const { mode: themeMode, toggleMode: toggleThemeMode } = useThemeMode(); @@ -270,12 +270,12 @@ export const SettingsMenu = () => { const onExport = () => { if (!project) return; - const plotter = getPlotter(state.plotterId); + // v3 documents carry no plotter: which machine they print on is a + // session choice, not a property of the drawing. const payload = { - version: 2, + version: 3, name: project.name, state, - plotter: plotter ?? null, }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); @@ -293,8 +293,8 @@ export const SettingsMenu = () => { const parsed = JSON.parse(text); const importedState = AppStateSchema.parse(parsed?.state); - // If the export bundled a plotter snapshot, ingest it so the imported - // project's plotterId resolves locally. + // v2 exports bundled a plotter snapshot. The document no longer + // references it, but adopting the machine definition is still useful. const rawPlotter = parsed?.plotter; if (rawPlotter) { const plotterParsed = PlotterSchema.safeParse(rawPlotter); diff --git a/paint-app/src/components/Toolbar.tsx b/paint-app/src/components/Toolbar.tsx index 6dd028c..183b064 100644 --- a/paint-app/src/components/Toolbar.tsx +++ b/paint-app/src/components/Toolbar.tsx @@ -1,9 +1,6 @@ import BoltIcon from '@mui/icons-material/Bolt'; import BugReportIcon from '@mui/icons-material/BugReport'; -import DangerousIcon from '@mui/icons-material/Dangerous'; import GitHubIcon from '@mui/icons-material/GitHub'; -import PauseIcon from '@mui/icons-material/Pause'; -import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PrintIcon from '@mui/icons-material/Print'; import { AppBar, @@ -11,11 +8,7 @@ import { Button, Chip, CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, + Divider, IconButton, Toolbar as MuiToolbar, Tooltip, @@ -23,33 +16,28 @@ import { } from '@mui/material'; import { useState } from 'react'; import { useConnection } from '../connection'; +import { usePlotters } from '../plotters'; import { INTERACTIVE_PROJECT_ID, useProject } from '../project'; import { DebugModal } from './DebugModal'; +import { PlotterControls } from './PlotterControls'; import { SettingsMenu } from './SettingsMenu'; type Props = { onPrint: () => void; }; +/** + * The application's single top bar. Mounted above the document/home swap so + * plotter selection and the connection live at app scope: you can connect, + * switch machines, or emergency-stop from anywhere, including the home screen. + */ export const Toolbar = ({ onPrint }: Props) => { - const { project, isDirty, isSaving, autosave, closeProject } = useProject(); - const { - connected, - paused, - pause, - resume, - emergencyStop, - emergencyStopped, - acknowledgeEmergencyStop, - } = useConnection(); - - const acknowledgeStop = () => { - acknowledgeEmergencyStop(); - closeProject(); - }; + const { project, isDirty, isSaving, autosave } = useProject(); + const { connected } = useConnection(); + const { activePlotter } = usePlotters(); const [debugOpen, setDebugOpen] = useState(false); - const isInteractive = project?.id === INTERACTIVE_PROJECT_ID; + const isInteractive = project?.id === INTERACTIVE_PROJECT_ID; const status = isSaving ? 'saving…' : isDirty ? (autosave ? 'saving soon' : 'unsaved') : 'saved'; return ( @@ -67,33 +55,35 @@ export const Toolbar = ({ onPrint }: Props) => { )} - - {connected && ( + {isInteractive && ( - + sx={{ ml: 1 }} + /> )} - {!isInteractive && ( + + + + {project && !isInteractive && ( @@ -110,24 +100,11 @@ export const Toolbar = ({ onPrint }: Props) => { )} - - {isInteractive && ( - - } - label={connected ? 'Connected' : 'Disconnected'} - color={connected ? 'success' : 'error'} - size="small" - /> - - )} - + + + + + { - {connected && ( - - - - )} {debugOpen && setDebugOpen(false)} />} - - Emergency stop triggered - - - The plotter received an emergency stop (M112) and its firmware is now halted. It will - not respond to any commands until you power cycle the 3D printer (turn - it off, wait a few seconds, then turn it back on). - - - After power cycling, reconnect from the setup screen. - - - - - - ); }; diff --git a/paint-app/src/gcode.ts b/paint-app/src/gcode.ts index c222c4c..fd43df2 100644 --- a/paint-app/src/gcode.ts +++ b/paint-app/src/gcode.ts @@ -33,13 +33,47 @@ export type LayerProgram = { lines: string[]; }; +// Page dimensions are user-entered mm; a page exactly the size of the bed +// shouldn't read as overflowing because of float noise. +const FIT_EPSILON = 1e-6; + +/** + * Whether the page fits inside the plotter's bed. Documents are sized + * independently of any machine, so a page can legitimately be larger than the + * plotter it's being sent to. + */ +export const pageFitsBed = (page: Page, plotter: Plotter): boolean => + page.width <= plotter.bedWidth + FIT_EPSILON && page.height <= plotter.bedHeight + FIT_EPSILON; + +/** + * The rect geometry is clipped against, in world coordinates. Always the page; + * additionally capped to the bed when the page overflows and the caller opted + * into clipping. The bed is anchored at the page's top-left because that + * corner maps to the machine origin (see `toMachine`). + */ +const clipRectFor = (page: Page, plotter: Plotter, clipToBed: boolean): Rect => { + const full: Rect = { x: page.x, y: page.y, width: page.width, height: page.height }; + if (!clipToBed || pageFitsBed(page, plotter)) return full; + return { + x: page.x, + y: page.y, + width: Math.min(page.width, plotter.bedWidth), + height: Math.min(page.height, plotter.bedHeight), + }; +}; + /** * Build G-code for a single freshly-drawn stroke, clipped to the page rect * and translated into machine coordinates. Used by interactive mode where we * stream individual strokes as the user finishes them. */ -export const buildStrokeLines = (points: Point[], page: Page, plotter: Plotter): string[] => { - const rect: Rect = { x: page.x, y: page.y, width: page.width, height: page.height }; +export const buildStrokeLines = ( + points: Point[], + page: Page, + plotter: Plotter, + clipToBed = false, +): string[] => { + const rect = clipRectFor(page, plotter, clipToBed); const subs = clipPolyline(points, rect); const lines: string[] = []; for (const sub of subs) { @@ -53,8 +87,9 @@ export const buildLayerPrograms = ( layers: Layer[], page: Page, plotter: Plotter, + clipToBed = false, ): LayerProgram[] => { - const rect: Rect = { x: page.x, y: page.y, width: page.width, height: page.height }; + const rect = clipRectFor(page, plotter, clipToBed); return layers .filter((l) => l.visible) .map((layer) => { diff --git a/paint-app/src/interactive.tsx b/paint-app/src/interactive.tsx index 705076f..32a30fc 100644 --- a/paint-app/src/interactive.tsx +++ b/paint-app/src/interactive.tsx @@ -14,21 +14,25 @@ import { useStore } from './store'; * The first stroke after a fresh connection sends the prologue (G21/G90/G28). * Strokes are sent through a serialized promise queue so concurrent draws * don't interleave G-code on the bus. + * + * Geometry outside the target plotter's bed is clipped away silently — there's + * no point prompting per stroke the way the print flow does, and driving the + * machine past its limits is worse than dropping the overflow. */ export const useInteractiveSync = () => { const { state } = useStore(); const { project } = useProject(); const { connection, connected } = useConnection(); - const { getPlotter } = usePlotters(); + const { activePlotter } = usePlotters(); const isInteractive = project?.id === INTERACTIVE_PROJECT_ID; const sentRef = useRef>(new Set()); const queueRef = useRef>(Promise.resolve()); const sessionKeyRef = useRef(null); - // (Re-)prime the "already sent" set whenever we enter the mode or the - // connection comes back. This is what stops the existing project history - // from being plotted on load. + // (Re-)prime the "already sent" set whenever we enter the mode, the + // connection comes back, or the target plotter changes. This is what stops + // the existing document from being replotted from the top. // biome-ignore lint/correctness/useExhaustiveDependencies: only re-prime on session-boundary changes useEffect(() => { if (!isInteractive || !connected) { @@ -42,12 +46,12 @@ export const useInteractiveSync = () => { } sentRef.current = ids; sessionKeyRef.current = null; - }, [isInteractive, connected, project?.id]); + }, [isInteractive, connected, project?.id, activePlotter?.id]); // Stream new strokes whenever the store changes. useEffect(() => { if (!isInteractive || !connected || !project) return; - const plotter = getPlotter(state.plotterId); + const plotter = activePlotter; if (!plotter) return; const activePage = state.pages.find((p) => p.id === state.activePageId); if (!activePage) return; @@ -60,7 +64,7 @@ export const useInteractiveSync = () => { for (const stroke of layer.strokes) { if (sentRef.current.has(stroke.id)) continue; sentRef.current.add(stroke.id); - const lines = buildStrokeLines(stroke.points, activePage, plotter); + const lines = buildStrokeLines(stroke.points, activePage, plotter, true); if (lines.length > 0) newPrograms.push(lines); } } @@ -80,5 +84,5 @@ export const useInteractiveSync = () => { console.error('interactive stream failed', e); } }); - }, [state, isInteractive, connected, project, connection, getPlotter]); + }, [state, isInteractive, connected, project, connection, activePlotter]); }; diff --git a/paint-app/src/pageSizes.ts b/paint-app/src/pageSizes.ts new file mode 100644 index 0000000..615fd92 --- /dev/null +++ b/paint-app/src/pageSizes.ts @@ -0,0 +1,44 @@ +import type { Plotter } from './types'; + +/** Page dimensions in mm. Origin/position is decided by the store. */ +export type PageSize = { width: number; height: number }; + +const LAST_SIZE_LS_KEY = 'paint-app:lastPageSize'; + +export const DEFAULT_PAGE_SIZE: PageSize = { width: 210, height: 297 }; + +export const STANDARD_SIZES: { label: string; size: PageSize }[] = [ + { label: 'A4', size: { width: 210, height: 297 } }, + { label: 'A5', size: { width: 148, height: 210 } }, + { label: 'Letter', size: { width: 216, height: 279 } }, + { label: 'Square 200mm', size: { width: 200, height: 200 } }, +]; + +export const formatSize = (size: PageSize) => `${size.width}×${size.height}mm`; + +/** A plotter's bed, offered as a page size so "fill the machine" stays one click. */ +export const plotterPageSize = (plotter: Plotter): PageSize => ({ + width: plotter.bedWidth, + height: plotter.bedHeight, +}); + +/** + * The size a new document defaults to — whatever was used last, so the common + * case (same paper every time) needs no interaction. Interactive sessions use + * this directly instead of prompting. + */ +export const loadLastPageSize = (): PageSize => { + try { + const raw = localStorage.getItem(LAST_SIZE_LS_KEY); + if (!raw) return DEFAULT_PAGE_SIZE; + const parsed = JSON.parse(raw); + const width = Number(parsed?.width); + const height = Number(parsed?.height); + if (width > 0 && height > 0) return { width, height }; + } catch {} + return DEFAULT_PAGE_SIZE; +}; + +export const saveLastPageSize = (size: PageSize) => { + localStorage.setItem(LAST_SIZE_LS_KEY, JSON.stringify(size)); +}; diff --git a/paint-app/src/plotters.tsx b/paint-app/src/plotters.tsx index f1bd725..49f803d 100644 --- a/paint-app/src/plotters.tsx +++ b/paint-app/src/plotters.tsx @@ -12,11 +12,20 @@ import type { Plotter } from './types'; const uid = () => Math.random().toString(36).slice(2, 10); +const ACTIVE_PLOTTER_LS_KEY = 'paint-app:activePlotterId'; + export type PlotterDraft = Omit; type PlottersCtx = { plotters: Plotter[]; ready: boolean; + /** + * The plotter output currently goes to — global and session-scoped, like the + * selected printer in a word processor. Documents never reference it, so + * switching targets mid-session is free. Null until one is configured. + */ + activePlotter: Plotter | null; + setActivePlotter: (id: string | null) => void; createPlotter: (draft: PlotterDraft) => Promise; /** * Overwrite an existing plotter's parameters in place (id and createdAt @@ -39,6 +48,15 @@ const PlottersContext = createContext(null); export const PlottersProvider = ({ children }: { children: ReactNode }) => { const [plotters, setPlotters] = useState([]); const [ready, setReady] = useState(false); + const [activePlotterId, setActivePlotterIdState] = useState(() => + localStorage.getItem(ACTIVE_PLOTTER_LS_KEY), + ); + + const setActivePlotter = useCallback((id: string | null) => { + setActivePlotterIdState(id); + if (id) localStorage.setItem(ACTIVE_PLOTTER_LS_KEY, id); + else localStorage.removeItem(ACTIVE_PLOTTER_LS_KEY); + }, []); useEffect(() => { let cancelled = false; @@ -54,12 +72,26 @@ export const PlottersProvider = ({ children }: { children: ReactNode }) => { }; }, []); - const createPlotter = useCallback(async (draft: PlotterDraft) => { - const plotter: Plotter = { id: uid(), ...draft, createdAt: Date.now() }; - await db.plotters.put(plotter); - setPlotters((prev) => [...prev, plotter].sort((a, b) => a.createdAt - b.createdAt)); - return plotter; - }, []); + // Keep the selection pointing at something real: fall back to the most + // recent plotter when none is chosen or the stored id has been deleted, so + // single-plotter users never have to touch the picker. + useEffect(() => { + if (!ready || plotters.length === 0) return; + if (activePlotterId && plotters.some((p) => p.id === activePlotterId)) return; + setActivePlotter(plotters[plotters.length - 1].id); + }, [ready, plotters, activePlotterId, setActivePlotter]); + + const createPlotter = useCallback( + async (draft: PlotterDraft) => { + const plotter: Plotter = { id: uid(), ...draft, createdAt: Date.now() }; + await db.plotters.put(plotter); + setPlotters((prev) => [...prev, plotter].sort((a, b) => a.createdAt - b.createdAt)); + // A freshly configured plotter is almost always the one you meant to use. + setActivePlotter(plotter.id); + return plotter; + }, + [setActivePlotter], + ); const ingestPlotter = useCallback(async (snapshot: Plotter) => { const existing = await db.plotters.get(snapshot.id); @@ -87,17 +119,34 @@ export const PlottersProvider = ({ children }: { children: ReactNode }) => { const getPlotter = useCallback((id: string) => plotters.find((p) => p.id === id), [plotters]); + const activePlotter = useMemo( + () => (activePlotterId ? (plotters.find((p) => p.id === activePlotterId) ?? null) : null), + [plotters, activePlotterId], + ); + const value = useMemo( () => ({ plotters, ready, + activePlotter, + setActivePlotter, createPlotter, updatePlotter, ingestPlotter, deletePlotter, getPlotter, }), - [plotters, ready, createPlotter, updatePlotter, ingestPlotter, deletePlotter, getPlotter], + [ + plotters, + ready, + activePlotter, + setActivePlotter, + createPlotter, + updatePlotter, + ingestPlotter, + deletePlotter, + getPlotter, + ], ); return {children}; @@ -108,12 +157,3 @@ export const usePlotters = () => { if (!ctx) throw new Error('usePlotters must be used within PlottersProvider'); return ctx; }; - -/** - * Resolve the active project's plotter. Returns null if no plotter has been - * selected yet, or if the referenced plotter no longer exists. - */ -export const useActivePlotter = (plotterId: string): Plotter | null => { - const { getPlotter } = usePlotters(); - return getPlotter(plotterId) ?? null; -}; diff --git a/paint-app/src/store.tsx b/paint-app/src/store.tsx index 74de231..06333b1 100644 --- a/paint-app/src/store.tsx +++ b/paint-app/src/store.tsx @@ -1,20 +1,21 @@ import { createContext, type ReactNode, useCallback, useContext, useMemo, useReducer } from 'react'; -import type { AppState, Layer, Page, Plotter, Point, Stroke } from './types'; +import type { PageSize } from './pageSizes'; +import type { AppState, Layer, Page, Point, Stroke } from './types'; const uid = () => Math.random().toString(36).slice(2, 10); const HISTORY_LIMIT = 100; /** - * Create a fresh AppState for a new project. The plotter governs both the - * referenced id and the initial page dimensions (which match the bed so the - * default canvas fills the printable area). + * Create a fresh AppState at the given page size. Documents are + * plotter-independent — the size is picked when the document is created, and + * which plotter it eventually prints to is decided separately. */ -export const createInitialState = (plotter: Plotter): AppState => { +export const createInitialState = (size: PageSize): AppState => { const pageId = uid(); const layerId = uid(); return { - pages: [{ id: pageId, x: 0, y: 0, width: plotter.bedWidth, height: plotter.bedHeight }], + pages: [{ id: pageId, x: 0, y: 0, width: size.width, height: size.height }], layers: [ { id: layerId, @@ -27,7 +28,6 @@ export const createInitialState = (plotter: Plotter): AppState => { ], activePageId: pageId, activeLayerId: layerId, - plotterId: plotter.id, }; }; @@ -45,7 +45,6 @@ const PLACEHOLDER_STATE: AppState = { ], activePageId: 'placeholder', activeLayerId: 'placeholder-layer', - plotterId: '', }; export type AddDirection = 'top' | 'right' | 'bottom' | 'left'; diff --git a/paint-app/src/types.ts b/paint-app/src/types.ts index 1fb5d13..11f7681 100644 --- a/paint-app/src/types.ts +++ b/paint-app/src/types.ts @@ -59,32 +59,20 @@ export const PlotterSchema = z.object({ }); export type Plotter = z.infer; -export const AppStateSchema = z.object({ - pages: z.array(PageSchema).min(1), - layers: z.array(LayerSchema).min(1), - activePageId: z.string(), - activeLayerId: z.string(), - plotterId: z.string(), -}); -export type AppState = z.infer; - /** - * Pre-plotter v1 state shape — kept around so we can migrate older projects - * (which embedded plotter params inline as `settings`) into the new - * plotter-by-id model. + * A document is plotter-independent: it owns its page geometry and nothing + * else. The plotter is a print target chosen at output time (see + * `plotters.tsx`), the way a printer is picked when printing a document. + * + * Two older shapes parse into this one for free, because zod strips unknown + * keys: v1 embedded plotter params inline as `settings`, and v2 referenced a + * plotter by `plotterId`. Both carry the page dimensions we actually need, so + * both are read as-is and simply lose the stale field on the next save. */ -export const LegacyAppStateSchema = z.object({ +export const AppStateSchema = z.object({ pages: z.array(PageSchema).min(1), layers: z.array(LayerSchema).min(1), activePageId: z.string(), activeLayerId: z.string(), - settings: z.object({ - bedWidth: z.number().positive(), - bedHeight: z.number().positive(), - travelFeed: z.number().positive(), - drawFeed: z.number().positive(), - penUpZ: z.number(), - penDownZ: z.number(), - }), }); -export type LegacyAppState = z.infer; +export type AppState = z.infer; From 96e2e4c0659ef5bc531e570359816cacef3ced43 Mon Sep 17 00:00:00 2001 From: Travis Bumgarner <11890057+TravisBumgarner@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:10:32 -0600 Subject: [PATCH 03/10] Add Custom Utils section with a Connected Data util MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connected Data polls a JSON endpoint and plots fields from it, configured through a three-step wizard on the home screen: fetch a URL, pick which fields to extract and how often, then choose how they land on the page. HTML is present as a disabled format placeholder. A probe fetch is walked once to discover what can be plotted. Scalar numbers become live series, sampled on the interval; arrays (of numbers, or of objects with chosen X/Y keys) become snapshots drawn from a single response. Everything else is listed but not selectable. Live series are emitted as one short segment per poll — previous sample to new sample — rather than as a growing polyline. That is what makes them plottable: the interactive streamer sends each new stroke exactly once, so the pen extends the curve instead of retracing it from the origin every tick. It also means a series cannot auto-range, since earlier points are already on paper by the time a new extreme arrives; the range is fixed up front and seeded from the probe. Snapshots re-emit only when their data actually changes. A run is an interactive session whose geometry comes from the poll loop instead of the pointer, so it reuses the existing live document and streaming path. Closing the document ends the session. Fetches go through a new main-process IPC bridge, because renderer fetch to a third-party feed is subject to CORS and most feeds don't opt in. The browser build falls back to direct fetch and says so when it fails. Page-size selection is extracted into a shared PageSizePicker, which derives its dropdown state from the value and so drops the seeding effect ProjectGate needed. Co-Authored-By: Claude Opus 5 (1M context) --- paint-app/electron/main.ts | 54 +- paint-app/electron/preload.cts | 2 + paint-app/src/App.tsx | 8 +- .../src/components/ConnectedDataWizard.tsx | 649 ++++++++++++++++++ paint-app/src/components/PageSizePicker.tsx | 117 ++++ paint-app/src/components/ProjectGate.tsx | 180 ++--- paint-app/src/components/Toolbar.tsx | 35 + paint-app/src/connectedData.ts | 285 ++++++++ paint-app/src/connectedDataSession.tsx | 183 +++++ paint-app/src/connection.tsx | 8 +- paint-app/src/desktop.ts | 64 ++ 11 files changed, 1457 insertions(+), 128 deletions(-) create mode 100644 paint-app/src/components/ConnectedDataWizard.tsx create mode 100644 paint-app/src/components/PageSizePicker.tsx create mode 100644 paint-app/src/connectedData.ts create mode 100644 paint-app/src/connectedDataSession.tsx create mode 100644 paint-app/src/desktop.ts diff --git a/paint-app/electron/main.ts b/paint-app/electron/main.ts index 1c64dda..493075c 100644 --- a/paint-app/electron/main.ts +++ b/paint-app/electron/main.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import type { Session } from 'electron'; -import { app, BrowserWindow, dialog, net, protocol, shell } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, net, protocol, shell } from 'electron'; const dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -63,6 +63,57 @@ function mainLog(line: string) { } } +/** Ceiling on a proxied response body; a data feed that big is a mistake. */ +const MAX_FETCH_BYTES = 8 * 1024 * 1024; +const FETCH_TIMEOUT_MS = 20_000; + +/** + * Renderer-side `fetch` to a third-party API is subject to CORS, which most + * data feeds don't opt into — from the app's custom scheme every such request + * fails before it leaves. The main process is not bound by CORS, so Connected + * Data goes through here instead. Restricted to http/https so the bridge can't + * be turned into a file:// reader. + */ +function registerHttpBridge() { + ipcMain.handle('http-fetch', async (_event, rawUrl: unknown) => { + if (typeof rawUrl !== 'string') return { ok: false, status: 0, error: 'Invalid URL' }; + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return { ok: false, status: 0, error: 'Not a valid URL' }; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return { ok: false, status: 0, error: `Unsupported protocol: ${url.protocol}` }; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const response = await net.fetch(url.toString(), { + signal: controller.signal, + headers: { Accept: 'application/json, text/html;q=0.9, */*;q=0.8' }, + }); + const buffer = await response.arrayBuffer(); + if (buffer.byteLength > MAX_FETCH_BYTES) { + return { ok: false, status: response.status, error: 'Response too large (over 8 MB)' }; + } + return { + ok: response.ok, + status: response.status, + contentType: response.headers.get('content-type') ?? '', + body: new TextDecoder().decode(buffer), + }; + } catch (e) { + const message = + (e as Error).name === 'AbortError' ? 'Request timed out' : (e as Error).message; + return { ok: false, status: 0, error: message }; + } finally { + clearTimeout(timer); + } + }); +} + /** * USB vendor IDs (decimal, as Chromium reports them) of the chips that show up * on Marlin/GRBL boards: Arduino, FTDI, CH340, CP210x, STM32, Teensy, Atmel, @@ -303,6 +354,7 @@ if (!app.requestSingleInstanceLock()) { app.whenReady().then(() => { registerAppProtocol(); + registerHttpBridge(); mainWindow = createWindow(); configureSerial(mainWindow.webContents.session, () => mainWindow); diff --git a/paint-app/electron/preload.cts b/paint-app/electron/preload.cts index 6d64282..ad33a99 100644 --- a/paint-app/electron/preload.cts +++ b/paint-app/electron/preload.cts @@ -12,4 +12,6 @@ contextBridge.exposeInMainWorld('desktop', { ipcRenderer.on('main-log', listener); return () => ipcRenderer.removeListener('main-log', listener); }, + /** CORS-free HTTP GET, for Connected Data. See `registerHttpBridge` in main. */ + fetchUrl: (url: string) => ipcRenderer.invoke('http-fetch', url), }); diff --git a/paint-app/src/App.tsx b/paint-app/src/App.tsx index bfd9ccd..86e7180 100644 --- a/paint-app/src/App.tsx +++ b/paint-app/src/App.tsx @@ -6,6 +6,7 @@ import { LayersPanel } from './components/LayersPanel'; import { PrintModal } from './components/PrintModal'; import { ProjectGate } from './components/ProjectGate'; import { Toolbar } from './components/Toolbar'; +import { ConnectedDataProvider, useConnectedDataSync } from './connectedDataSession'; import { ConnectionProvider, useConnection } from './connection'; import { useInteractiveSync } from './interactive'; import { PlottersProvider, usePlotters } from './plotters'; @@ -22,7 +23,9 @@ export const App = () => { - + + + @@ -36,6 +39,9 @@ const Root = () => { const { project } = useProject(); const { log, showLog } = useConnection(); const [printing, setPrinting] = useState(false); + // Lives at Root, not in Shell: the poll loop has to keep running across + // anything that remounts the document view. + useConnectedDataSync(); return (
diff --git a/paint-app/src/components/ConnectedDataWizard.tsx b/paint-app/src/components/ConnectedDataWizard.tsx new file mode 100644 index 0000000..4087bdb --- /dev/null +++ b/paint-app/src/components/ConnectedDataWizard.tsx @@ -0,0 +1,649 @@ +import ShowChartIcon from '@mui/icons-material/ShowChart'; +import TimelineIcon from '@mui/icons-material/Timeline'; +import { + Alert, + Box, + Button, + Checkbox, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Step, + StepLabel, + Stepper, + TextField, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from '@mui/material'; +import { useMemo, useState } from 'react'; +import { + type ConnectedDataConfig, + DEFAULT_INTERVAL_SECONDS, + DEFAULT_MARGIN_MM, + DEFAULT_WINDOW_MINUTES, + type DiscoveredField, + discoverFields, + getNumber, + plotArea, + SERIES_COLORS, + type Selection, + seedRange, + snapshotPoints, + valueToY, +} from '../connectedData'; +import { fetchText } from '../desktop'; +import { loadLastPageSize, type PageSize } from '../pageSizes'; +import { PageSizePicker } from './PageSizePicker'; + +const STEPS = ['Source', 'Data', 'Plot']; + +const INTERVAL_PRESETS = [ + { label: '5s', value: 5 }, + { label: '30s', value: 30 }, + { label: '1m', value: 60 }, + { label: '5m', value: 300 }, + { label: '1h', value: 3600 }, +]; + +type Probe = { + json: unknown; + fields: DiscoveredField[]; + viaDesktop: boolean; +}; + +type Props = { + open: boolean; + onClose: () => void; + onStart: (config: ConnectedDataConfig) => void; +}; + +/** Whether a discovered field can be plotted at all. */ +const isPlottable = (field: DiscoveredField) => + (field.kind === 'scalar' && field.valueKind === 'number') || + field.kind === 'numberArray' || + field.kind === 'objectArray'; + +const numericItemFields = (field: DiscoveredField) => + field.kind === 'objectArray' ? field.itemFields.filter((f) => f.kind === 'number') : []; + +export const ConnectedDataWizard = ({ open, onClose, onStart }: Props) => { + const [step, setStep] = useState(0); + + // Step 1 + const [url, setUrl] = useState(''); + const [format, setFormat] = useState<'json' | 'html'>('json'); + const [probing, setProbing] = useState(false); + const [probeError, setProbeError] = useState(null); + const [probe, setProbe] = useState(null); + + // Step 2 + const [selections, setSelections] = useState>({}); + const [intervalSeconds, setIntervalSeconds] = useState(DEFAULT_INTERVAL_SECONDS); + + // Step 3 + const [pageSize, setPageSize] = useState(() => loadLastPageSize()); + const [marginMm, setMarginMm] = useState(DEFAULT_MARGIN_MM); + const [windowMinutes, setWindowMinutes] = useState(DEFAULT_WINDOW_MINUTES); + + const chosen = useMemo(() => Object.values(selections), [selections]); + const hasSeries = chosen.some((s) => s.mode === 'series'); + + const reset = () => { + setStep(0); + setUrl(''); + setProbe(null); + setProbeError(null); + setSelections({}); + setIntervalSeconds(DEFAULT_INTERVAL_SECONDS); + setMarginMm(DEFAULT_MARGIN_MM); + setWindowMinutes(DEFAULT_WINDOW_MINUTES); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const runProbe = async () => { + const trimmed = url.trim(); + if (!trimmed) return; + setProbing(true); + setProbeError(null); + setProbe(null); + setSelections({}); + try { + const { body, viaDesktop } = await fetchText(trimmed); + const json = JSON.parse(body); + const fields = discoverFields(json); + if (fields.length === 0) { + setProbeError('Fetched successfully, but no plottable fields were found in the response.'); + } else { + setProbe({ json, fields, viaDesktop }); + } + } catch (e) { + const message = (e as Error).message; + setProbeError(message.includes('JSON') ? `Response was not valid JSON: ${message}` : message); + } finally { + setProbing(false); + } + }; + + const toggleField = (field: DiscoveredField) => { + setSelections((prev) => { + if (prev[field.path]) { + const { [field.path]: _removed, ...rest } = prev; + return rest; + } + const color = SERIES_COLORS[Object.keys(prev).length % SERIES_COLORS.length]; + if (field.kind === 'scalar') { + const value = probe ? (getNumber(probe.json, field.path) ?? 0) : 0; + return { + ...prev, + [field.path]: { + id: field.path, + path: field.path, + label: field.label, + mode: 'series', + color, + xField: null, + yField: null, + autoRange: false, + ...seedRange(value), + }, + }; + } + const numeric = numericItemFields(field); + return { + ...prev, + [field.path]: { + id: field.path, + path: field.path, + label: field.label, + mode: 'snapshot', + color, + xField: null, + yField: field.kind === 'objectArray' ? (numeric[0]?.path ?? null) : null, + autoRange: true, + yMin: 0, + yMax: 1, + }, + }; + }); + }; + + const patch = (id: string, changes: Partial) => + setSelections((prev) => ({ ...prev, [id]: { ...prev[id], ...changes } })); + + const canAdvance = + step === 0 + ? probe !== null + : step === 1 + ? chosen.length > 0 && + chosen.every((s) => s.mode !== 'snapshot' || s.yField !== null || isNumberArray(s, probe)) + : true; + + const finish = () => { + onStart({ + url: url.trim(), + format: 'json', + intervalSeconds, + pageSize, + marginMm, + windowMinutes, + selections: chosen, + }); + reset(); + }; + + return ( + + Connected Data + + + {STEPS.map((label) => ( + + {label} + + ))} + + + {step === 0 && ( + + v && setFormat(v)} + > + JSON + + + + HTML + + + + + + setUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && runProbe()} + autoFocus + /> + + + + + + {probeError && {probeError}} + {probe && ( + + Found {probe.fields.length} field + {probe.fields.length === 1 ? '' : 's'}, of which{' '} + {probe.fields.filter(isPlottable).length} can be plotted. + {!probe.viaDesktop && + ' Fetched directly from the browser — the desktop app can reach endpoints that block CORS.'} + + )} + + )} + + {step === 1 && probe && ( + + + Numbers are sampled repeatedly to build a live series. Arrays are drawn all at once + from a single response. + + + + }> + {probe.fields.map((field) => { + const selected = selections[field.path]; + const plottable = isPlottable(field); + return ( + + + toggleField(field)} + sx={{ p: 0.5 }} + /> + + + {field.label} + {field.kind !== 'scalar' && '[]'} + + + {field.sample} + + + : } + label={ + field.kind === 'scalar' + ? field.valueKind === 'number' + ? 'live series' + : field.valueKind + : field.kind === 'numberArray' + ? `${field.length} numbers` + : `${field.length} objects` + } + /> + + + {selected && ( + + {field.kind === 'objectArray' && ( + <> + + X axis + + + + Y axis + + + + )} + + {selected.mode === 'snapshot' && ( + + patch(selected.id, { autoRange: e.target.checked }) + } + /> + } + label={Fit to data} + /> + )} + + {!selected.autoRange && ( + <> + + patch(selected.id, { + yMin: Number.parseFloat(e.target.value) || 0, + }) + } + sx={{ width: 110 }} + /> + + patch(selected.id, { + yMax: Number.parseFloat(e.target.value) || 0, + }) + } + sx={{ width: 110 }} + /> + + )} + + + + )} + + ); + })} + + + + + Fetch every + {INTERVAL_PRESETS.map((preset) => ( + setIntervalSeconds(preset.value)} + /> + ))} + + setIntervalSeconds(Math.max(1, Number.parseInt(e.target.value, 10) || 1)) + } + sx={{ width: 110 }} + /> + + + {hasSeries && ( + + A live series can't auto-range: earlier points are already drawn (and possibly + plotted) by the time a new extreme arrives, so the Y range is fixed up front. The + defaults come from the value in the response you just fetched. + + )} + + )} + + {step === 2 && probe && ( + + + + setMarginMm(Math.max(0, Number.parseFloat(e.target.value) || 0))} + sx={{ width: 130 }} + /> + {hasSeries && ( + + setWindowMinutes(Math.max(1, Number.parseFloat(e.target.value) || 1)) + } + sx={{ width: 180 }} + /> + )} + + + + + + + )} + + + + + + {step > 0 && } + {step < STEPS.length - 1 ? ( + + ) : ( + + )} + + + ); +}; + +/** numberArray selections need no Y field; objectArray ones do. */ +const isNumberArray = (selection: Selection, probe: Probe | null) => + probe?.fields.find((f) => f.path === selection.path)?.kind === 'numberArray'; + +const PlotPreview = ({ + json, + selections, + pageSize, + marginMm, +}: { + json: unknown; + selections: Selection[]; + pageSize: PageSize; + marginMm: number; +}) => { + const area = plotArea(pageSize, marginMm); + + return ( + + + Plot preview + + + {selections.map((selection) => { + if (selection.mode === 'snapshot') { + const points = snapshotPoints(json, selection, area); + if (points.length < 2) return null; + return ( + `${p.x},${p.y}`).join(' ')} + fill="none" + stroke={selection.color} + strokeWidth={0.8} + strokeLinejoin="round" + /> + ); + } + // A live series has exactly one real point so far; show where it + // starts and which way it will grow. + const value = getNumber(json, selection.path); + if (value === null) return null; + const y = valueToY(value, selection.yMin, selection.yMax, area); + return ( + + + + + ); + })} + + + ); +}; + +const PlotSummary = ({ + selections, + intervalSeconds, + windowMinutes, +}: { + selections: Selection[]; + intervalSeconds: number; + windowMinutes: number; +}) => { + const series = selections.filter((s) => s.mode === 'series'); + const snapshots = selections.filter((s) => s.mode === 'snapshot'); + const samples = Math.floor((windowMinutes * 60) / Math.max(1, intervalSeconds)); + + return ( + + + {series.length > 0 && ( + + {series.length} live series ({series.map((s) => s.label).join(', ')}) — + one segment drawn per fetch, about {samples} points before the page is + full at {intervalSeconds}s intervals. + + )} + {snapshots.length > 0 && ( + + {snapshots.length} snapshot series ( + {snapshots.map((s) => s.label).join(', ')}) — redrawn whenever the fetched data changes. + + )} + + Strokes go to the plotter as they're drawn, exactly like an interactive session. Connect a + plotter from the top bar; anything drawn while disconnected stays on screen only. + + + + ); +}; diff --git a/paint-app/src/components/PageSizePicker.tsx b/paint-app/src/components/PageSizePicker.tsx new file mode 100644 index 0000000..0a5e0d8 --- /dev/null +++ b/paint-app/src/components/PageSizePicker.tsx @@ -0,0 +1,117 @@ +import { + FormControl, + InputLabel, + ListSubheader, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { useMemo, useState } from 'react'; +import { formatSize, type PageSize, plotterPageSize, STANDARD_SIZES } from '../pageSizes'; +import { usePlotters } from '../plotters'; + +const CUSTOM_KEY = 'custom'; + +type Props = { + value: PageSize; + onChange: (size: PageSize) => void; + label?: string; + minWidth?: number; +}; + +/** + * Page size as a single dropdown over plotter beds and standard papers, with + * hand-entered dimensions as the fallback. The selected key is derived from + * the value rather than stored, so the control has no state to get out of sync + * — except the deliberate choice to sit on Custom while typing a size that + * happens to coincide with a preset. + */ +export const PageSizePicker = ({ value, onChange, label = 'Page size', minWidth = 200 }: Props) => { + const { plotters } = usePlotters(); + const [forceCustom, setForceCustom] = useState(false); + + const options = useMemo(() => { + const fromPlotters = plotters.map((p) => ({ + key: `plotter:${p.id}`, + group: 'From a plotter', + label: `${p.name} (bed)`, + size: plotterPageSize(p), + })); + const standard = STANDARD_SIZES.map((s) => ({ + key: `std:${s.label}`, + group: 'Standard', + label: s.label, + size: s.size, + })); + return [...fromPlotters, ...standard]; + }, [plotters]); + + const matched = options.find( + (o) => o.size.width === value.width && o.size.height === value.height, + ); + const sizeKey = forceCustom || !matched ? CUSTOM_KEY : matched.key; + + const fromPlotters = options.filter((o) => o.group === 'From a plotter'); + const standard = options.filter((o) => o.group === 'Standard'); + + return ( + + + {label} + + + + {sizeKey === CUSTOM_KEY && ( + + onChange({ ...value, width: Number.parseFloat(e.target.value) || 0 })} + sx={{ width: 130 }} + /> + × + onChange({ ...value, height: Number.parseFloat(e.target.value) || 0 })} + sx={{ width: 130 }} + /> + + )} + + ); +}; diff --git a/paint-app/src/components/ProjectGate.tsx b/paint-app/src/components/ProjectGate.tsx index 380797a..8e54ca5 100644 --- a/paint-app/src/components/ProjectGate.tsx +++ b/paint-app/src/components/ProjectGate.tsx @@ -1,87 +1,41 @@ import BoltIcon from '@mui/icons-material/Bolt'; +import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined'; import { Box, Button, Divider, - FormControl, IconButton, - InputLabel, List, ListItem, ListItemButton, ListItemText, - ListSubheader, - MenuItem, Paper, - Select, Stack, TextField, Typography, } from '@mui/material'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import type { ConnectedDataConfig } from '../connectedData'; +import { useConnectedData } from '../connectedDataSession'; import { db, type Project } from '../db'; -import { - DEFAULT_PAGE_SIZE, - formatSize, - loadLastPageSize, - type PageSize, - plotterPageSize, - STANDARD_SIZES, - saveLastPageSize, -} from '../pageSizes'; -import { usePlotters } from '../plotters'; +import { loadLastPageSize, type PageSize, saveLastPageSize } from '../pageSizes'; import { INTERACTIVE_PROJECT_ID, useProject } from './../project'; import { createInitialState } from '../store'; import { AppStateSchema } from '../types'; +import { ConnectedDataWizard } from './ConnectedDataWizard'; +import { PageSizePicker } from './PageSizePicker'; const uid = () => Math.random().toString(36).slice(2, 10); -const CUSTOM_KEY = 'custom'; - export const ProjectGate = () => { const { setProject } = useProject(); - const { plotters } = usePlotters(); + const { start: startConnectedData } = useConnectedData(); const [projects, setProjects] = useState(null); const [name, setName] = useState(''); - // Page size selection. The key drives the dropdown; `custom` holds the - // hand-entered dimensions so switching to Custom and back is lossless. - const [sizeKey, setSizeKey] = useState(CUSTOM_KEY); - const [custom, setCustom] = useState(DEFAULT_PAGE_SIZE); - - const options = useMemo(() => { - const fromPlotters = plotters.map((p) => ({ - key: `plotter:${p.id}`, - label: `${p.name} (bed)`, - size: plotterPageSize(p), - })); - const standard = STANDARD_SIZES.map((s) => ({ - key: `std:${s.label}`, - label: s.label, - size: s.size, - })); - return { fromPlotters, standard }; - }, [plotters]); - - // Seed from the last-used size: preselect whichever option matches it so the - // common case (same paper every time) is already correct on arrival. Plotters - // arrive asynchronously, so this re-runs when they land — but never after the - // user has made a choice of their own. - const touched = useRef(false); - useEffect(() => { - if (touched.current) return; - const last = loadLastPageSize(); - setCustom(last); - const all = [...options.fromPlotters, ...options.standard]; - const match = all.find((o) => o.size.width === last.width && o.size.height === last.height); - setSizeKey(match?.key ?? CUSTOM_KEY); - }, [options]); - - const size: PageSize = useMemo(() => { - const all = [...options.fromPlotters, ...options.standard]; - return all.find((o) => o.key === sizeKey)?.size ?? custom; - }, [sizeKey, custom, options]); + const [size, setSize] = useState(() => loadLastPageSize()); + const [connectedDataOpen, setConnectedDataOpen] = useState(false); const refresh = useCallback(async () => { const all = await db.projects.orderBy('updatedAt').reverse().toArray(); @@ -121,14 +75,25 @@ export const ProjectGate = () => { setProject({ id: project.id, name: project.name }, parsed.data); }; - const onStartInteractive = async () => { - // Interactive sessions are ephemeral. Wipe any leftover persisted record - // (from earlier app versions or aborted runs) and start in memory only. + /** Ephemeral: wipe any leftover persisted record and start in memory only. */ + const startLiveSession = async (name: string, pageSize: PageSize) => { await db.projects.delete(INTERACTIVE_PROJECT_ID).catch(() => {}); - setProject( - { id: INTERACTIVE_PROJECT_ID, name: 'Interactive session' }, - createInitialState(loadLastPageSize()), - ); + setProject({ id: INTERACTIVE_PROJECT_ID, name }, createInitialState(pageSize)); + }; + + const onStartInteractive = () => startLiveSession('Interactive session', loadLastPageSize()); + + // A Connected Data run is an interactive session whose strokes come from a + // poll loop rather than the pointer, so it reuses the same live document and + // streaming path — only the source of the geometry differs. + const onStartConnectedData = async (config: ConnectedDataConfig) => { + setConnectedDataOpen(false); + let host = config.url; + try { + host = new URL(config.url).host; + } catch {} + await startLiveSession(`Connected Data · ${host}`, config.pageSize); + startConnectedData(config); }; const visibleProjects = (projects ?? []).filter((p) => p.id !== INTERACTIVE_PROJECT_ID); @@ -185,67 +150,12 @@ export const ProjectGate = () => { onKeyDown={(e) => e.key === 'Enter' && onCreate()} autoFocus /> - - Page size - - + - {sizeKey === CUSTOM_KEY && ( - - { - touched.current = true; - setCustom((c) => ({ ...c, width: Number.parseFloat(e.target.value) || 0 })); - }} - sx={{ width: 140 }} - /> - × - { - touched.current = true; - setCustom((c) => ({ ...c, height: Number.parseFloat(e.target.value) || 0 })); - }} - sx={{ width: 140 }} - /> - - )} - {projects === null ? ( Loading… @@ -283,8 +193,40 @@ export const ProjectGate = () => { )} + + + + + + Custom Utils + + + + + + Connected Data + + + Poll a JSON endpoint and plot fields from it — numbers as a live series, arrays as + a chart. + + + + + + + setConnectedDataOpen(false)} + onStart={onStartConnectedData} + /> ); }; diff --git a/paint-app/src/components/Toolbar.tsx b/paint-app/src/components/Toolbar.tsx index 183b064..b224b51 100644 --- a/paint-app/src/components/Toolbar.tsx +++ b/paint-app/src/components/Toolbar.tsx @@ -1,5 +1,6 @@ import BoltIcon from '@mui/icons-material/Bolt'; import BugReportIcon from '@mui/icons-material/BugReport'; +import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; import GitHubIcon from '@mui/icons-material/GitHub'; import PrintIcon from '@mui/icons-material/Print'; import { @@ -15,6 +16,7 @@ import { Typography, } from '@mui/material'; import { useState } from 'react'; +import { useConnectedData } from '../connectedDataSession'; import { useConnection } from '../connection'; import { usePlotters } from '../plotters'; import { INTERACTIVE_PROJECT_ID, useProject } from '../project'; @@ -35,6 +37,7 @@ export const Toolbar = ({ onPrint }: Props) => { const { project, isDirty, isSaving, autosave } = useProject(); const { connected } = useConnection(); const { activePlotter } = usePlotters(); + const connectedData = useConnectedData(); const [debugOpen, setDebugOpen] = useState(false); const isInteractive = project?.id === INTERACTIVE_PROJECT_ID; @@ -74,6 +77,38 @@ export const Toolbar = ({ onPrint }: Props) => { /> )} + {connectedData.config && ( + + } + label={ + connectedData.status.lastError + ? 'Fetch failed' + : connectedData.status.windowFull + ? 'Window full' + : `${connectedData.status.polls} polls` + } + color={ + connectedData.status.lastError + ? 'error' + : connectedData.status.windowFull + ? 'warning' + : 'info' + } + variant="outlined" + size="small" + sx={{ ml: 1 }} + /> + + )} diff --git a/paint-app/src/connectedData.ts b/paint-app/src/connectedData.ts new file mode 100644 index 0000000..b3685e4 --- /dev/null +++ b/paint-app/src/connectedData.ts @@ -0,0 +1,285 @@ +import type { PageSize } from './pageSizes'; +import type { Point } from './types'; + +// ─── Discovery ─────────────────────────────────────────────────────────── +// A probe fetch is walked once to produce a flat list of plottable fields. +// Only two things can be plotted: a scalar number (sampled repeatedly over +// time) and an array (drawn all at once as a series). + +export type ScalarKind = 'number' | 'string' | 'boolean' | 'null'; + +export type ItemField = { path: string; kind: ScalarKind; sample: string }; + +export type DiscoveredField = { + /** Dot path from the response root. Empty string means the root itself. */ + path: string; + label: string; + sample: string; +} & ( + | { kind: 'scalar'; valueKind: ScalarKind } + | { kind: 'numberArray'; length: number } + | { kind: 'objectArray'; length: number; itemFields: ItemField[] } +); + +/** Guards against pathological payloads turning discovery into a hang. */ +const MAX_DEPTH = 6; +const MAX_FIELDS = 400; +/** How many array entries to union when working out an array's item shape. */ +const ITEM_SHAPE_SAMPLE = 20; + +const scalarKind = (v: unknown): ScalarKind | null => { + if (v === null) return 'null'; + if (typeof v === 'number' && Number.isFinite(v)) return 'number'; + if (typeof v === 'string') return 'string'; + if (typeof v === 'boolean') return 'boolean'; + return null; +}; + +export const sampleText = (v: unknown): string => { + if (v === null) return 'null'; + if (typeof v === 'string') return v.length > 40 ? `"${v.slice(0, 40)}…"` : `"${v}"`; + if (Array.isArray(v)) return `${v.length} items`; + if (typeof v === 'object') return '{…}'; + return String(v); +}; + +const joinPath = (base: string, key: string) => (base ? `${base}.${key}` : key); + +const isPlainObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +/** Union of the scalar keys across the first N entries — entries are often ragged. */ +const itemShape = (items: unknown[]): ItemField[] => { + const byPath = new Map(); + for (const item of items.slice(0, ITEM_SHAPE_SAMPLE)) { + if (!isPlainObject(item)) continue; + for (const [key, value] of Object.entries(item)) { + if (byPath.has(key)) continue; + const kind = scalarKind(value); + // A key that is null in this entry may be a number in the next, so keep + // looking rather than recording it as null. + if (!kind || kind === 'null') continue; + byPath.set(key, { path: key, kind, sample: sampleText(value) }); + } + } + return [...byPath.values()]; +}; + +const describeArray = (path: string, label: string, arr: unknown[]): DiscoveredField | null => { + if (arr.length === 0) return null; + if (arr.every((v) => typeof v === 'number' && Number.isFinite(v))) { + return { + path, + label, + kind: 'numberArray', + length: arr.length, + sample: `[${arr.slice(0, 4).join(', ')}${arr.length > 4 ? ', …' : ''}]`, + }; + } + if (arr.some(isPlainObject)) { + const itemFields = itemShape(arr); + if (itemFields.length === 0) return null; + return { + path, + label, + kind: 'objectArray', + length: arr.length, + itemFields, + sample: `${arr.length} objects · ${itemFields.map((f) => f.path).join(', ')}`, + }; + } + return null; +}; + +/** + * Flatten a parsed JSON response into every field worth plotting. Nested + * objects are walked; arrays terminate the walk and become a single field + * describing the whole series. + */ +export const discoverFields = (root: unknown): DiscoveredField[] => { + const out: DiscoveredField[] = []; + + const walk = (value: unknown, path: string, label: string, depth: number) => { + if (out.length >= MAX_FIELDS || depth > MAX_DEPTH) return; + + if (Array.isArray(value)) { + const field = describeArray(path, label || '(root)', value); + if (field) out.push(field); + return; + } + + if (isPlainObject(value)) { + for (const [key, child] of Object.entries(value)) { + walk(child, joinPath(path, key), joinPath(label, key), depth + 1); + } + return; + } + + const kind = scalarKind(value); + // `null` carries no type information: it can't be configured (there's no + // observed value to seed a range from) and might be a number on the next + // poll, so listing it would only add unselectable noise. + if (!kind || kind === 'null') return; + out.push({ + path, + label: label || '(root)', + kind: 'scalar', + valueKind: kind, + sample: sampleText(value), + }); + }; + + walk(root, '', '', 0); + return out; +}; + +/** Resolve a dot path produced by `discoverFields`. */ +export const getByPath = (root: unknown, path: string): unknown => { + if (!path) return root; + let current: unknown = root; + for (const key of path.split('.')) { + if (!isPlainObject(current)) return undefined; + current = current[key]; + } + return current; +}; + +export const getNumber = (root: unknown, path: string): number | null => { + const v = getByPath(root, path); + return typeof v === 'number' && Number.isFinite(v) ? v : null; +}; + +// ─── Selection & config ────────────────────────────────────────────────── + +export type SelectionMode = 'series' | 'snapshot'; + +export type Selection = { + id: string; + /** Path of the source field, as discovered. */ + path: string; + label: string; + mode: SelectionMode; + color: string; + /** objectArray only: which item key supplies X. Null means the item index. */ + xField: string | null; + /** objectArray only: which item key supplies Y. Null means the item itself. */ + yField: string | null; + /** Snapshots can range off their own data; live series cannot (see below). */ + autoRange: boolean; + yMin: number; + yMax: number; +}; + +export type ConnectedDataConfig = { + url: string; + format: 'json'; + intervalSeconds: number; + pageSize: PageSize; + marginMm: number; + /** How much elapsed time the page width represents, for `series` selections. */ + windowMinutes: number; + selections: Selection[]; +}; + +export const DEFAULT_INTERVAL_SECONDS = 60; +export const DEFAULT_WINDOW_MINUTES = 60; +export const DEFAULT_MARGIN_MM = 10; + +export const SERIES_COLORS = [ + '#1e88e5', + '#e53935', + '#43a047', + '#fb8c00', + '#8e24aa', + '#00897b', + '#d81b60', + '#3949ab', +]; + +// ─── Plot geometry ─────────────────────────────────────────────────────── + +export type PlotArea = { x: number; y: number; width: number; height: number }; + +export const plotArea = (page: PageSize, marginMm: number): PlotArea => { + // A margin so wide it inverts the area would silently produce mirrored + // geometry, so clamp it to a quarter of the shorter side. + const limit = Math.min(page.width, page.height) / 4; + const m = Math.max(0, Math.min(marginMm, limit)); + return { x: m, y: m, width: page.width - 2 * m, height: page.height - 2 * m }; +}; + +const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t); + +/** + * Map a value onto a page-local Y. Page coordinates run +y down, so a larger + * value sits closer to the top — the orientation a reader expects of a chart. + */ +export const valueToY = (value: number, min: number, max: number, area: PlotArea): number => { + const span = max - min; + const t = span === 0 ? 0.5 : clamp01((value - min) / span); + return area.y + area.height * (1 - t); +}; + +/** Fraction of the time window elapsed → page-local X. */ +export const elapsedToX = (elapsedMs: number, windowMinutes: number, area: PlotArea): number => { + const windowMs = Math.max(1, windowMinutes * 60_000); + return area.x + area.width * clamp01(elapsedMs / windowMs); +}; + +export const seriesWindowFull = (elapsedMs: number, windowMinutes: number): boolean => + elapsedMs >= windowMinutes * 60_000; + +/** + * Extract the (x, y) pairs a snapshot selection should draw, in page-local mm. + * X comes from the chosen field or the item index; both axes are normalised + * over the array's own extent, so a snapshot always fills the plot area. + */ +export const snapshotPoints = (root: unknown, selection: Selection, area: PlotArea): Point[] => { + const raw = getByPath(root, selection.path); + if (!Array.isArray(raw)) return []; + + const pairs: { x: number; y: number }[] = []; + raw.forEach((item, index) => { + const yRaw = + selection.yField === null ? item : isPlainObject(item) ? item[selection.yField] : undefined; + if (typeof yRaw !== 'number' || !Number.isFinite(yRaw)) return; + + let xRaw: number = index; + if (selection.xField !== null) { + const candidate = isPlainObject(item) ? item[selection.xField] : undefined; + if (typeof candidate !== 'number' || !Number.isFinite(candidate)) return; + xRaw = candidate; + } + pairs.push({ x: xRaw, y: yRaw }); + }); + + if (pairs.length < 2) return []; + + const xs = pairs.map((p) => p.x); + const xMin = Math.min(...xs); + const xMax = Math.max(...xs); + const xSpan = xMax - xMin; + + const ys = pairs.map((p) => p.y); + const yMin = selection.autoRange ? Math.min(...ys) : selection.yMin; + const yMax = selection.autoRange ? Math.max(...ys) : selection.yMax; + + return pairs.map((p) => ({ + x: area.x + area.width * (xSpan === 0 ? 0.5 : clamp01((p.x - xMin) / xSpan)), + y: valueToY(p.y, yMin, yMax, area), + })); +}; + +/** A sensible starting range for a live series, given its first observed value. */ +export const seedRange = (value: number): { yMin: number; yMax: number } => { + if (value === 0) return { yMin: -1, yMax: 1 }; + const pad = Math.abs(value) * 0.5; + const min = value - pad; + const max = value + pad; + // Round outward to something legible rather than 13.7419… + const step = 10 ** Math.floor(Math.log10(Math.max(1e-9, max - min))) / 2; + return { + yMin: Math.floor(min / step) * step, + yMax: Math.ceil(max / step) * step, + }; +}; diff --git a/paint-app/src/connectedDataSession.tsx b/paint-app/src/connectedDataSession.tsx new file mode 100644 index 0000000..4e2a260 --- /dev/null +++ b/paint-app/src/connectedDataSession.tsx @@ -0,0 +1,183 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + type ConnectedDataConfig, + elapsedToX, + getNumber, + plotArea, + seriesWindowFull, + snapshotPoints, + valueToY, +} from './connectedData'; +import { fetchText } from './desktop'; +import { useProject } from './project'; +import { useStore } from './store'; +import type { Point } from './types'; + +export type ConnectedDataStatus = { + polls: number; + lastPollAt: number | null; + lastError: string | null; + /** Live series have run off the right edge of the page; they stop extending. */ + windowFull: boolean; +}; + +const IDLE_STATUS: ConnectedDataStatus = { + polls: 0, + lastPollAt: null, + lastError: null, + windowFull: false, +}; + +type ConnectedDataCtx = { + config: ConnectedDataConfig | null; + status: ConnectedDataStatus; + start: (config: ConnectedDataConfig) => void; + stop: () => void; + /** Called by the sync loop. Stable identity — the loop depends on it. */ + reportStatus: (status: ConnectedDataStatus) => void; +}; + +const ConnectedDataContext = createContext(null); + +export const ConnectedDataProvider = ({ children }: { children: ReactNode }) => { + const [config, setConfig] = useState(null); + const [status, setStatus] = useState(IDLE_STATUS); + + const start = useCallback((next: ConnectedDataConfig) => { + setStatus(IDLE_STATUS); + setConfig(next); + }, []); + + const stop = useCallback(() => { + setConfig(null); + setStatus(IDLE_STATUS); + }, []); + + const reportStatus = useCallback((next: ConnectedDataStatus) => setStatus(next), []); + + const value = useMemo( + () => ({ config, status, start, stop, reportStatus }), + [config, status, start, stop, reportStatus], + ); + + return {children}; +}; + +export const useConnectedData = () => { + const ctx = useContext(ConnectedDataContext); + if (!ctx) throw new Error('useConnectedData must be used within ConnectedDataProvider'); + return ctx; +}; + +/** + * Drives a Connected Data session: polls the endpoint on its interval and + * turns each response into strokes. + * + * Live series are emitted as one short segment per poll (previous sample → + * new sample) rather than as one ever-growing polyline. That is what makes + * them plottable: the interactive streamer sends each new stroke exactly once, + * so the pen extends the curve instead of retracing it from the origin on + * every tick. Snapshots are the opposite — the whole array is a single stroke, + * re-emitted only when the underlying data actually changes. + */ +export const useConnectedDataSync = () => { + // Only `config` and `reportStatus` are read: both are stable across status + // updates, so a status report can't tear down and restart the poll loop. + const { config, reportStatus, stop } = useConnectedData(); + const { state, addStroke } = useStore(); + const { project } = useProject(); + + // The loop must survive every store change, so mutable reads go through refs. + const stateRef = useRef(state); + stateRef.current = state; + const addStrokeRef = useRef(addStroke); + addStrokeRef.current = addStroke; + + // Closing the document ends the session; otherwise it would keep polling + // into a store that no longer belongs to it. + useEffect(() => { + if (!project && config) stop(); + }, [project, config, stop]); + + useEffect(() => { + if (!config || !project) return; + + let cancelled = false; + const startedAt = Date.now(); + const lastPoint = new Map(); + const lastSnapshot = new Map(); + let polls = 0; + + const poll = async () => { + if (cancelled) return; + try { + const { body } = await fetchText(config.url); + if (cancelled) return; + const json = JSON.parse(body); + + const snapshot = stateRef.current; + const page = snapshot.pages.find((p) => p.id === snapshot.activePageId); + if (!page) return; + const layerId = snapshot.activeLayerId; + const area = plotArea(config.pageSize, config.marginMm); + const toWorld = (p: Point): Point => ({ x: p.x + page.x, y: p.y + page.y }); + + const elapsed = Date.now() - startedAt; + const full = seriesWindowFull(elapsed, config.windowMinutes); + + for (const selection of config.selections) { + if (selection.mode === 'series') { + if (full) continue; + const value = getNumber(json, selection.path); + if (value === null) continue; + const point: Point = { + x: elapsedToX(elapsed, config.windowMinutes, area), + y: valueToY(value, selection.yMin, selection.yMax, area), + }; + const previous = lastPoint.get(selection.id); + lastPoint.set(selection.id, point); + // The first sample is only an anchor — a segment needs two ends. + if (previous) { + addStrokeRef.current(layerId, [toWorld(previous), toWorld(point)], selection.color); + } + continue; + } + + const points = snapshotPoints(json, selection, area); + if (points.length < 2) continue; + const key = JSON.stringify(points); + if (lastSnapshot.get(selection.id) === key) continue; + lastSnapshot.set(selection.id, key); + addStrokeRef.current(layerId, points.map(toWorld), selection.color); + } + + polls += 1; + reportStatus({ polls, lastPollAt: Date.now(), lastError: null, windowFull: full }); + } catch (e) { + if (cancelled) return; + reportStatus({ + polls, + lastPollAt: Date.now(), + lastError: (e as Error).message, + windowFull: false, + }); + } + }; + + poll(); + const timer = setInterval(poll, Math.max(1, config.intervalSeconds) * 1000); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [config, project, reportStatus]); +}; diff --git a/paint-app/src/connection.tsx b/paint-app/src/connection.tsx index c0751cb..1232587 100644 --- a/paint-app/src/connection.tsx +++ b/paint-app/src/connection.tsx @@ -7,15 +7,9 @@ import { useMemo, useState, } from 'react'; +import './desktop'; // registers the `window.desktop` global type import { type ConnectPhase, PlotterConnection } from './serial'; -/** Exposed by electron/preload.cts; absent when running in a plain browser. */ -declare global { - interface Window { - desktop?: { onMainLog: (handler: (line: string) => void) => () => void }; - } -} - const SHOW_LOG_LS_KEY = 'paint-app:showLog'; type ConnectionCtx = { diff --git a/paint-app/src/desktop.ts b/paint-app/src/desktop.ts new file mode 100644 index 0000000..34fc09f --- /dev/null +++ b/paint-app/src/desktop.ts @@ -0,0 +1,64 @@ +/** Exposed by electron/preload.cts; absent when running in a plain browser. */ +declare global { + interface Window { + desktop?: { + onMainLog: (handler: (line: string) => void) => () => void; + fetchUrl?: (url: string) => Promise; + }; + } +} + +export type DesktopFetchResult = { + ok: boolean; + status: number; + contentType?: string; + body?: string; + error?: string; +}; + +export type FetchTextResult = { + body: string; + contentType: string; + /** True when the request went through the main process (no CORS limits). */ + viaDesktop: boolean; +}; + +export const isDesktop = () => typeof window !== 'undefined' && Boolean(window.desktop?.fetchUrl); + +/** + * GET a URL as text, preferring the Electron main-process bridge because + * arbitrary data feeds rarely send CORS headers. In a plain browser this falls + * back to `fetch`, which works only for endpoints that opt in — the thrown + * message says so, since "Failed to fetch" on its own reads like a dead link. + */ +export const fetchText = async (url: string): Promise => { + const bridge = window.desktop?.fetchUrl; + if (bridge) { + const result = await bridge(url); + if (!result.ok) { + throw new Error( + result.error ?? `Request failed with HTTP ${result.status}`.replace(' 0', ' error'), + ); + } + return { + body: result.body ?? '', + contentType: result.contentType ?? '', + viaDesktop: true, + }; + } + + let response: Response; + try { + response = await fetch(url); + } catch (e) { + throw new Error( + `${(e as Error).message}. In the browser build this is usually CORS — the endpoint has to allow cross-origin requests. The desktop app can fetch any URL.`, + ); + } + if (!response.ok) throw new Error(`Request failed with HTTP ${response.status}`); + return { + body: await response.text(), + contentType: response.headers.get('content-type') ?? '', + viaDesktop: false, + }; +}; From ad8cdc09a42f5e117949ea68410e3fc1c1dbde99 Mon Sep 17 00:00:00 2001 From: Travis Bumgarner <11890057+TravisBumgarner@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:57:21 -0600 Subject: [PATCH 04/10] Add Photo processing util MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports gcode2dplotterart/experimental_photo_utils to TypeScript and adds the buckets-to-strokes step it never had — in Python that lived inline in each sketch under Plotter-Explorations/bought-a-3d-printer, so it is lifted out here and shared. The pipeline mirrors the original: decode, resize to fit while preserving aspect ratio, grayscale (average / luminosity / lightness), then bucket into N tonal layers by even pixel count or even histogram slice. A three-step wizard drives it, with a bucketed preview at step 2 and a stroke preview at step 3. Four shading styles, each taken from a sketch in the examples repo and offered as a preset with that sketch's parameters: horizontal scan rows, split on tone change (dogs) diagonal same along diagonals, with gaps (diag_lines) dots random stipple fill per cell (dots) circles concentric rings sized by tone (circles) The result is a saved project with one layer per tone rather than a live session, because a photo plot is a multi-pen print job — the print flow already pauses between layers for a pen swap. Two deliberate departures from the originals. Circle grids are inset by one radius: the sketches centred rings on the boundary and let the plotter clip whatever fell outside, which silently dropped ink. And a dot is the shortest possible segment rather than a bare point, since a stroke here needs two ends; the Python API had add_point. The generative sketches in that repo (boxes, triangles, dessins, qrcodes, unique_boxes) are not photo processing and are left alone. Co-Authored-By: Claude Opus 5 (1M context) --- paint-app/src/components/PhotoWizard.tsx | 606 +++++++++++++++++++++++ paint-app/src/components/ProjectGate.tsx | 69 ++- paint-app/src/photo.ts | 481 ++++++++++++++++++ paint-app/src/photoDecode.ts | 88 ++++ 4 files changed, 1243 insertions(+), 1 deletion(-) create mode 100644 paint-app/src/components/PhotoWizard.tsx create mode 100644 paint-app/src/photo.ts create mode 100644 paint-app/src/photoDecode.ts diff --git a/paint-app/src/components/PhotoWizard.tsx b/paint-app/src/components/PhotoWizard.tsx new file mode 100644 index 0000000..4a619db --- /dev/null +++ b/paint-app/src/components/PhotoWizard.tsx @@ -0,0 +1,606 @@ +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Slider, + Stack, + Step, + StepLabel, + Stepper, + TextField, + Typography, +} from '@mui/material'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { PageSize } from '../pageSizes'; +import { + BUCKET_METHODS, + type BucketedImage, + type BucketMethod, + bucketImage, + DEFAULT_STYLE_PARAMS, + GRAYSCALE_METHODS, + type GrayscaleMethod, + type LayerStrokes, + PHOTO_PRESETS, + type PhotoStyle, + paletteFor, + renderStyle, + type StyleParams, + targetResolution, + toGrayscale, +} from '../photo'; +import { bucketPreviewUrl, decodeFile, fitWithin, resample } from '../photoDecode'; +import { PageSizePicker } from './PageSizePicker'; + +const STEPS = ['Image', 'Process', 'Style']; + +export type PhotoResult = { + name: string; + pageSize: PageSize; + marginMm: number; + /** One entry per bucket, darkest first. Page-local mm. */ + layers: { color: string; strokes: LayerStrokes }[]; +}; + +type Props = { + open: boolean; + onClose: () => void; + onCreate: (result: PhotoResult) => void; +}; + +const STYLE_LABELS: Record = { + horizontal: 'Horizontal scan', + diagonal: 'Diagonal lines', + dots: 'Stipple dots', + circles: 'Concentric circles', +}; + +export const PhotoWizard = ({ open, onClose, onCreate }: Props) => { + const [step, setStep] = useState(0); + + // Step 1 + const [file, setFile] = useState(null); + const [bitmap, setBitmap] = useState(null); + const [sourceUrl, setSourceUrl] = useState(null); + const [loadError, setLoadError] = useState(null); + + // Step 2 + const [presetId, setPresetId] = useState(PHOTO_PRESETS[0].id); + const [grayscale, setGrayscale] = useState('luminosity'); + const [bucket, setBucket] = useState('even-pixels'); + const [layerCount, setLayerCount] = useState(4); + const [palette, setPalette] = useState(PHOTO_PRESETS[0].palette); + + // Step 3 + const [style, setStyle] = useState('diagonal'); + const [params, setParams] = useState(DEFAULT_STYLE_PARAMS); + const [pageSize, setPageSize] = useState({ width: 180, height: 240 }); + const [marginMm, setMarginMm] = useState(10); + + const [rendering, setRendering] = useState(false); + const [rendered, setRendered] = useState(null); + + const fileInputRef = useRef(null); + + const areaWidth = Math.max(1, pageSize.width - 2 * marginMm); + const areaHeight = Math.max(1, pageSize.height - 2 * marginMm); + + const applyPreset = useCallback((id: string) => { + const preset = PHOTO_PRESETS.find((p) => p.id === id); + if (!preset) return; + setPresetId(id); + setStyle(preset.style); + setGrayscale(preset.grayscale); + setBucket(preset.bucket); + setLayerCount(preset.layerCount); + setParams(preset.params); + setPalette(preset.palette); + }, []); + + useEffect(() => { + if (open) applyPreset(PHOTO_PRESETS[0].id); + }, [open, applyPreset]); + + useEffect(() => { + return () => { + if (sourceUrl) URL.revokeObjectURL(sourceUrl); + }; + }, [sourceUrl]); + + const colors = useMemo(() => paletteFor(palette, layerCount), [palette, layerCount]); + + /** + * The bucketed image at the resolution the chosen style wants. Recomputed + * whenever anything upstream changes; it also backs the step-2 preview. + */ + const processed: BucketedImage | null = useMemo(() => { + if (!bitmap) return null; + const target = targetResolution(style, areaWidth, areaHeight, params); + const fitted = fitWithin(bitmap.width, bitmap.height, target.width, target.height); + const image = resample(bitmap, fitted.width, fitted.height); + const gray = toGrayscale(image.rgba, grayscale); + return bucketImage(gray, image.width, image.height, layerCount, bucket); + }, [bitmap, style, areaWidth, areaHeight, params, grayscale, layerCount, bucket]); + + const previewUrl = useMemo( + () => + processed + ? bucketPreviewUrl(processed.data, processed.width, processed.height, colors) + : null, + [processed, colors], + ); + + const onPickFile = async (picked: File) => { + setLoadError(null); + try { + const decoded = await decodeFile(picked); + setFile(picked); + setBitmap(decoded); + if (sourceUrl) URL.revokeObjectURL(sourceUrl); + setSourceUrl(URL.createObjectURL(picked)); + } catch (e) { + setLoadError((e as Error).message); + } + }; + + // Rendering the strokes is the expensive step, so it runs when the user + // reaches the last step rather than on every parameter tweak. + const render = useCallback(() => { + if (!processed) return; + setRendering(true); + // Yield first so the spinner paints before the main thread blocks. + setTimeout(() => { + try { + setRendered(renderStyle(style, processed, params)); + } finally { + setRendering(false); + } + }, 0); + }, [processed, style, params]); + + useEffect(() => { + if (step === 2) render(); + }, [step, render]); + + const strokeCount = rendered?.reduce((sum, l) => sum + l.length, 0) ?? 0; + + const reset = () => { + setStep(0); + setFile(null); + setBitmap(null); + if (sourceUrl) URL.revokeObjectURL(sourceUrl); + setSourceUrl(null); + setLoadError(null); + setRendered(null); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const finish = () => { + if (!rendered) return; + onCreate({ + name: file ? file.name.replace(/\.[^.]+$/, '') : 'Photo', + pageSize, + marginMm, + layers: rendered.map((strokes, i) => ({ color: colors[i], strokes })), + }); + reset(); + }; + + return ( + + Photo processing + + + {STEPS.map((label) => ( + + {label} + + ))} + + + {step === 0 && ( + + + { + const picked = e.target.files?.[0]; + if (picked) onPickFile(picked); + e.target.value = ''; + }} + /> + {loadError && {loadError}} + {bitmap && sourceUrl && ( + <> + + {file?.name} — {bitmap.width}×{bitmap.height}px + + + + )} + + )} + + {step === 1 && ( + + + + Preset + + + + {PHOTO_PRESETS.find((p) => p.id === presetId)?.description} + + + + Grayscale + + + + + Bucketing + + + + {BUCKET_METHODS.find((m) => m.value === bucket)?.hint} + + + + + Layers (pens): {layerCount} + + setLayerCount(v as number)} + /> + + + + {colors.map((color, i) => ( + + { + const next = [...colors]; + next[i] = e.target.value; + setPalette(next); + }} + style={{ width: 28, height: 24, border: 'none', background: 'none' }} + /> + + {i === 0 ? 'darkest' : i === layerCount - 1 ? 'lightest' : `#${i + 1}`} + + + ))} + + + + + + Bucketed preview + + {previewUrl ? ( + + ) : ( + + Choose an image first. + + )} + {processed && ( + + Sampled at {processed.width}×{processed.height} + + )} + + + )} + + {step === 2 && ( + + + + Style + + + + + setMarginMm(Math.max(0, Number.parseFloat(e.target.value) || 0))} + sx={{ width: 140 }} + /> + + {(style === 'horizontal' || style === 'diagonal') && ( + + setParams({ ...params, lineSpacing: v })} + /> + setParams({ ...params, colinearGap: v })} + /> + + )} + {style === 'dots' && ( + setParams({ ...params, boxSide: v })} + /> + )} + {style === 'circles' && ( + + setParams({ ...params, sampleLength: v })} + /> + setParams({ ...params, circleDiameter: v })} + /> + setParams({ ...params, lineWidth: v })} + /> + + )} + + + + {rendered && ( + 60_000 ? 'warning' : 'info'}> + {strokeCount.toLocaleString()} strokes across {rendered.length}{' '} + layers. + {strokeCount > 60_000 && + ' That is a lot — the canvas will be sluggish and the plot will take hours. Increase line spacing or the gap after each run.'} + + {rendered.map((layer, i) => ( + + ))} + + + )} + + + + + Plot preview + + {rendering ? ( + + Rendering… + + ) : ( + + )} + + + )} + + + + + + {step > 0 && } + {step < STEPS.length - 1 ? ( + + ) : ( + + )} + + + ); +}; + +const NumField = ({ + label, + value, + onChange, + step = 1, +}: { + label: string; + value: number; + onChange: (v: number) => void; + step?: number; +}) => ( + onChange(Number.parseFloat(e.target.value) || 0)} + slotProps={{ htmlInput: { step, min: 0 } }} + sx={{ width: 150 }} + /> +); + +/** + * Renders every stroke as SVG. Above a few thousand this gets heavy, so the + * preview thins the set down — the real project keeps all of them. + */ +const PREVIEW_STROKE_LIMIT = 4000; + +const StrokePreview = ({ + layers, + colors, + pageSize, + marginMm, +}: { + layers: LayerStrokes[] | null; + colors: string[]; + pageSize: PageSize; + marginMm: number; +}) => { + if (!layers) { + return ( + + Nothing rendered yet. + + ); + } + + const total = layers.reduce((sum, l) => sum + l.length, 0); + const stride = Math.max(1, Math.ceil(total / PREVIEW_STROKE_LIMIT)); + + return ( + + + Plot preview + + + {layers.map((strokes, layerIndex) => ( + + {strokes + .filter((_s, i) => i % stride === 0) + .map((stroke, i) => ( + `${p.x},${p.y}`).join(' ')} + fill="none" + strokeWidth={0.3} + /> + ))} + + ))} + + + {stride > 1 && ( + + Showing 1 in {stride} strokes for speed — the project gets all {total.toLocaleString()}. + + )} + + ); +}; diff --git a/paint-app/src/components/ProjectGate.tsx b/paint-app/src/components/ProjectGate.tsx index 8e54ca5..59e2dab 100644 --- a/paint-app/src/components/ProjectGate.tsx +++ b/paint-app/src/components/ProjectGate.tsx @@ -1,6 +1,7 @@ import BoltIcon from '@mui/icons-material/Bolt'; import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined'; +import PhotoIcon from '@mui/icons-material/Photo'; import { Box, Button, @@ -22,9 +23,10 @@ import { db, type Project } from '../db'; import { loadLastPageSize, type PageSize, saveLastPageSize } from '../pageSizes'; import { INTERACTIVE_PROJECT_ID, useProject } from './../project'; import { createInitialState } from '../store'; -import { AppStateSchema } from '../types'; +import { type AppState, AppStateSchema } from '../types'; import { ConnectedDataWizard } from './ConnectedDataWizard'; import { PageSizePicker } from './PageSizePicker'; +import { type PhotoResult, PhotoWizard } from './PhotoWizard'; const uid = () => Math.random().toString(36).slice(2, 10); @@ -36,6 +38,7 @@ export const ProjectGate = () => { const [size, setSize] = useState(() => loadLastPageSize()); const [connectedDataOpen, setConnectedDataOpen] = useState(false); + const [photoOpen, setPhotoOpen] = useState(false); const refresh = useCallback(async () => { const all = await db.projects.orderBy('updatedAt').reverse().toArray(); @@ -96,6 +99,50 @@ export const ProjectGate = () => { startConnectedData(config); }; + /** + * A photo plot is a multi-pen print job, not a live session: it becomes a + * saved project with one layer per tone so the print flow can pause for a + * pen swap between them. + */ + const onCreatePhoto = async (result: PhotoResult) => { + setPhotoOpen(false); + const now = Date.now(); + const pageId = uid(); + const state: AppState = { + pages: [ + { id: pageId, x: 0, y: 0, width: result.pageSize.width, height: result.pageSize.height }, + ], + layers: result.layers.map((layer, index) => ({ + id: uid(), + name: `Pen ${index + 1}${index === 0 ? ' (darkest)' : ''}`, + color: layer.color, + thickness: 0.5, + visible: true, + strokes: layer.strokes.map((points) => ({ + id: uid(), + // Strokes arrive page-local; the margin is applied here so the + // renderer's world space stays the single source of truth. + points: points.map((p) => ({ x: p.x + result.marginMm, y: p.y + result.marginMm })), + color: layer.color, + })), + })), + activePageId: pageId, + activeLayerId: '', + }; + state.activeLayerId = state.layers[0].id; + + const project: Project = { + id: uid(), + name: result.name, + state, + createdAt: now, + updatedAt: now, + }; + await db.projects.put(project); + saveLastPageSize(result.pageSize); + setProject({ id: project.id, name: project.name }, state); + }; + const visibleProjects = (projects ?? []).filter((p) => p.id !== INTERACTIVE_PROJECT_ID); const onDelete = async (id: string) => { @@ -218,6 +265,25 @@ export const ProjectGate = () => { Configure + + + + + + Photo processing + + + Turn a photo into a multi-pen plot — grayscale, split into tonal layers, then + shade with lines, dots, or circles. + + + + @@ -227,6 +293,7 @@ export const ProjectGate = () => { onClose={() => setConnectedDataOpen(false)} onStart={onStartConnectedData} /> + setPhotoOpen(false)} onCreate={onCreatePhoto} /> ); }; diff --git a/paint-app/src/photo.ts b/paint-app/src/photo.ts new file mode 100644 index 0000000..90950ba --- /dev/null +++ b/paint-app/src/photo.ts @@ -0,0 +1,481 @@ +import type { Point } from './types'; + +/** + * Port of `gcode2dplotterart/experimental_photo_utils` plus the buckets→strokes + * step the Python side never had (it lived inline in each sketch under + * Plotter-Explorations/bought-a-3d-printer). + * + * The pipeline is: decode → resize to fit → grayscale → bucket → render. + * Everything here is pure and works on plain arrays; decoding and resizing + * need a canvas and live in `photoDecode.ts`. + */ + +// ─── Grayscale ─────────────────────────────────────────────────────────── + +export type GrayscaleMethod = 'average' | 'luminosity' | 'lightness'; + +export const GRAYSCALE_METHODS: { value: GrayscaleMethod; label: string; hint: string }[] = [ + { value: 'average', label: 'Average', hint: '(R + G + B) / 3' }, + { + value: 'luminosity', + label: 'Luminosity', + hint: '0.21R + 0.72G + 0.07B — matches perceived brightness', + }, + { value: 'lightness', label: 'Lightness', hint: '(max + min) / 2' }, +]; + +/** RGBA bytes → one 0..255 float per pixel. */ +export const toGrayscale = (rgba: Uint8ClampedArray, method: GrayscaleMethod): Float32Array => { + const out = new Float32Array(rgba.length / 4); + for (let i = 0, p = 0; i < rgba.length; i += 4, p++) { + const r = rgba[i]; + const g = rgba[i + 1]; + const b = rgba[i + 2]; + if (method === 'average') { + out[p] = (r + g + b) / 3; + } else if (method === 'luminosity') { + out[p] = r * 0.21 + g * 0.72 + b * 0.07; + } else { + out[p] = (Math.max(r, g, b) + Math.min(r, g, b)) / 2; + } + } + return out; +}; + +// ─── Bucketing ─────────────────────────────────────────────────────────── + +export type BucketMethod = 'even-pixels' | 'even-histogram'; + +export const BUCKET_METHODS: { value: BucketMethod; label: string; hint: string }[] = [ + { + value: 'even-pixels', + label: 'Even pixel count', + hint: 'Each layer covers roughly the same number of pixels — even ink per pen', + }, + { + value: 'even-histogram', + label: 'Even histogram', + hint: 'Each layer covers an equal slice of the 0–255 range — truer tones, uneven ink', + }, +]; + +export type BucketedImage = { + width: number; + height: number; + /** Bucket index per pixel, row-major. 0 is darkest. */ + data: Uint8Array; + layerCount: number; +}; + +const histogram256 = (gray: Float32Array): Int32Array => { + const hist = new Int32Array(256); + for (const value of gray) { + const v = value < 0 ? 0 : value > 255 ? 255 : Math.floor(value); + hist[v]++; + } + return hist; +}; + +/** + * `np.digitize(x, bins)` with ascending bins: the number of bins <= x, which + * is the count of thresholds the value has passed. + */ +const digitize = (value: number, bins: number[]): number => { + let low = 0; + let high = bins.length; + while (low < high) { + const mid = (low + high) >> 1; + if (bins[mid] <= value) low = mid + 1; + else high = mid; + } + return low; +}; + +/** Thresholds that split the histogram into equal-population slices. */ +const evenPixelBins = (gray: Float32Array, layerCount: number): number[] => { + const hist = histogram256(gray); + const target = gray.length / layerCount; + const bins: number[] = []; + let count = 0; + for (let value = 0; value < 256; value++) { + if (count >= target && bins.length < layerCount - 1) { + count = 0; + bins.push(value); + } + count += hist[value]; + } + return bins; +}; + +/** Thresholds at equal intervals across the 0..255 range. */ +const evenHistogramBins = (layerCount: number): number[] => { + const bins: number[] = []; + const segment = 256 / layerCount; + for (let i = 1; i < layerCount; i++) bins.push(Math.round(i * segment)); + return bins; +}; + +export const bucketImage = ( + gray: Float32Array, + width: number, + height: number, + layerCount: number, + method: BucketMethod, +): BucketedImage => { + const count = Math.max(2, Math.floor(layerCount)); + const bins = method === 'even-pixels' ? evenPixelBins(gray, count) : evenHistogramBins(count); + const data = new Uint8Array(gray.length); + for (let i = 0; i < gray.length; i++) { + const bucket = digitize(gray[i], bins); + data[i] = bucket > count - 1 ? count - 1 : bucket; + } + return { width, height, data, layerCount: count }; +}; + +export const bucketAt = (image: BucketedImage, x: number, y: number): number => + image.data[y * image.width + x]; + +// ─── Styles ────────────────────────────────────────────────────────────── + +export type PhotoStyle = 'horizontal' | 'diagonal' | 'dots' | 'circles'; + +export type StyleParams = { + /** Rows/diagonals to skip between drawn lines. 1 draws every one. */ + lineSpacing: number; + /** Pixels skipped after each emitted segment, thinning dense areas. */ + colinearGap: number; + /** Dots: side of the cell each source pixel expands into, in mm. */ + boxSide: number; + /** Circles: source pixels averaged per output circle. */ + sampleLength: number; + /** Circles: diameter of the largest circle, in mm. */ + circleDiameter: number; + /** Circles: spacing between concentric rings, in mm. */ + lineWidth: number; +}; + +export const DEFAULT_STYLE_PARAMS: StyleParams = { + lineSpacing: 3, + colinearGap: 1, + boxSide: 2, + sampleLength: 10, + circleDiameter: 2, + lineWidth: 0.4, +}; + +/** + * Strokes for one layer, in page-local mm. Index in the outer array is the + * bucket, so layer 0 is the darkest tone. + */ +export type LayerStrokes = Point[][]; + +const emptyLayers = (count: number): LayerStrokes[] => + Array.from({ length: count }, () => [] as LayerStrokes); + +/** + * Walk a path of pixels, emitting one segment per run of constant bucket. + * Shared by the horizontal and diagonal styles — the only difference between + * them is the path. + */ +const emitRuns = ( + path: { x: number; y: number }[], + image: BucketedImage, + layers: LayerStrokes[], + colinearGap: number, + toMm: (p: { x: number; y: number }) => Point, +) => { + if (path.length < 2) return; + let start = path[0]; + let bucket = bucketAt(image, start.x, start.y); + let index = 0; + + while (index < path.length) { + const point = path[index]; + if (bucketAt(image, point.x, point.y) === bucket) { + index++; + continue; + } + layers[bucket].push([toMm(start), toMm(point)]); + // Skipping ahead after a run is what thins out busy regions; a gap of 1 + // keeps every run, which is the faithful "draw everything" setting. + index += Math.max(1, colinearGap); + if (index >= path.length) return; + start = path[index]; + bucket = bucketAt(image, start.x, start.y); + } + // Close the final run against the end of the path. + const last = path[path.length - 1]; + if (last !== start) layers[bucket].push([toMm(start), toMm(last)]); +}; + +const horizontalStrokes = (image: BucketedImage, params: StyleParams): LayerStrokes[] => { + const layers = emptyLayers(image.layerCount); + const step = Math.max(1, Math.floor(params.lineSpacing)); + for (let y = 0; y < image.height; y += step) { + const path = Array.from({ length: image.width }, (_, x) => ({ x, y })); + emitRuns(path, image, layers, params.colinearGap, (p) => ({ x: p.x, y: p.y })); + } + return layers; +}; + +/** + * Diagonals running up-and-right, seeded down the left edge and then along the + * bottom, so the whole image is covered exactly once. + */ +const diagonalStrokes = (image: BucketedImage, params: StyleParams): LayerStrokes[] => { + const layers = emptyLayers(image.layerCount); + const step = Math.max(1, Math.floor(params.lineSpacing)); + + const buildPath = (startX: number, startY: number) => { + const path: { x: number; y: number }[] = []; + let x = startX; + let y = startY; + while (x >= 0 && x < image.width && y >= 0 && y < image.height) { + path.push({ x, y }); + x++; + y--; + } + return path; + }; + + let lastY = 0; + for (let y = 0; y < image.height; y += step) { + emitRuns(buildPath(0, y), image, layers, params.colinearGap, (p) => ({ x: p.x, y: p.y })); + lastY = y; + } + // Pick up along the bottom edge where the left-edge sweep left off, so the + // spacing stays even across the seam. + const delta = Math.max(0, Math.abs(lastY - image.height) - 1); + for (let x = delta; x < image.width; x += step) { + emitRuns(buildPath(x, image.height - 1), image, layers, params.colinearGap, (p) => ({ + x: p.x, + y: p.y, + })); + } + return layers; +}; + +/** + * Each source pixel becomes a `boxSide` mm cell; the darker the pixel, the + * more sub-cells get a dot. Positions are shuffled so the fill reads as + * stipple rather than a grid. + */ +const dotStrokes = ( + image: BucketedImage, + params: StyleParams, + random: () => number, +): LayerStrokes[] => { + const layers = emptyLayers(image.layerCount); + const side = Math.max(1, Math.floor(params.boxSide)); + const perCell = side * side; + // A stroke needs two points, so a "dot" is the shortest segment that still + // puts ink down — the equivalent of the Python API's add_point. + const DOT_MM = 0.2; + + for (let y = 0; y < image.height; y++) { + for (let x = 0; x < image.width; x++) { + const bucket = bucketAt(image, x, y); + // Bucket 0 is darkest and should be the most filled. + const fill = Math.min(perCell, Math.round(perCell * (1 - bucket / (image.layerCount - 1)))); + if (fill <= 0) continue; + + const cells: Point[] = []; + for (let r = 0; r < side; r++) { + for (let c = 0; c < side; c++) cells.push({ x: x * side + c, y: y * side + r }); + } + for (let i = cells.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)); + [cells[i], cells[j]] = [cells[j], cells[i]]; + } + for (const cell of cells.slice(0, fill)) { + layers[bucket].push([cell, { x: cell.x + DOT_MM, y: cell.y }]); + } + } + } + return layers; +}; + +/** + * Blocks of `sampleLength` pixels are averaged into one cell, drawn as + * concentric rings — darker cells get a bigger outer radius, so tone reads as + * ink density. + */ +const circleStrokes = (image: BucketedImage, params: StyleParams): LayerStrokes[] => { + const layers = emptyLayers(image.layerCount); + const sample = Math.max(1, Math.floor(params.sampleLength)); + const diameter = Math.max(0.1, params.circleDiameter); + const ringGap = Math.max(0.05, params.lineWidth); + const SEGMENTS = 24; + + for (let row = 0; row + sample <= image.height; row += sample) { + for (let col = 0; col + sample <= image.width; col += sample) { + let total = 0; + for (let i = row; i < row + sample; i++) { + for (let j = col; j < col + sample; j++) total += bucketAt(image, j, i); + } + const bucket = Math.round(total / (sample * sample)); + // Darkest bucket → widest circle. + const scale = 1 - bucket / Math.max(1, image.layerCount - 1); + const outer = (diameter / 2) * (0.1 + 0.8 * scale); + if (outer <= 0) continue; + + // Centres are inset by one radius so the leftmost and topmost rings sit + // inside the plot area. The original sketch centred on the boundary and + // relied on the plotter clipping whatever fell outside. + const cx = diameter / 2 + (col / sample) * diameter; + const cy = diameter / 2 + (row / sample) * diameter; + for (let r = outer; r > 0; r -= ringGap) { + const ring: Point[] = []; + for (let s = 0; s <= SEGMENTS; s++) { + const angle = (s / SEGMENTS) * Math.PI * 2; + ring.push({ x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r }); + } + layers[bucket].push(ring); + } + } + } + return layers; +}; + +/** + * Source-image resolution a style needs, given the target area in mm. The + * styles differ in how many source pixels back one millimetre of output. + */ +export const targetResolution = ( + style: PhotoStyle, + areaWidthMm: number, + areaHeightMm: number, + params: StyleParams, +): { width: number; height: number } => { + if (style === 'dots') { + const side = Math.max(1, Math.floor(params.boxSide)); + return { + width: Math.max(1, Math.floor(areaWidthMm / side)), + height: Math.max(1, Math.floor(areaHeightMm / side)), + }; + } + if (style === 'circles') { + const sample = Math.max(1, Math.floor(params.sampleLength)); + const diameter = Math.max(0.1, params.circleDiameter); + return { + width: Math.max(1, Math.floor((areaWidthMm / diameter) * sample)), + height: Math.max(1, Math.floor((areaHeightMm / diameter) * sample)), + }; + } + // One source pixel per millimetre. + return { + width: Math.max(1, Math.floor(areaWidthMm)), + height: Math.max(1, Math.floor(areaHeightMm)), + }; +}; + +export const renderStyle = ( + style: PhotoStyle, + image: BucketedImage, + params: StyleParams, + random: () => number = Math.random, +): LayerStrokes[] => { + switch (style) { + case 'horizontal': + return horizontalStrokes(image, params); + case 'diagonal': + return diagonalStrokes(image, params); + case 'dots': + return dotStrokes(image, params, random); + case 'circles': + return circleStrokes(image, params); + } +}; + +// ─── Presets ───────────────────────────────────────────────────────────── + +export type PhotoPreset = { + id: string; + name: string; + description: string; + /** Which sketch in Plotter-Explorations/bought-a-3d-printer this came from. */ + source: string; + style: PhotoStyle; + grayscale: GrayscaleMethod; + bucket: BucketMethod; + layerCount: number; + params: StyleParams; + palette: string[]; +}; + +/** Palettes lifted from the original sketches. */ +const CMYK = ['#000000', '#00b7eb', '#ff00ff', '#ffff00']; +const CMYK_GREY = ['#a9a9a9', '#00b7eb', '#ff00ff', '#ffff00']; +const DOGS = ['#8e3392', '#e76500', '#e0c200']; +const RED = ['#dd3031']; + +export const PHOTO_PRESETS: PhotoPreset[] = [ + { + id: 'diagonal-cmyk', + name: 'Diagonal lines (CMYK)', + description: + 'Diagonal sweeps broken into runs of equal tone. Four pens, each covering about the same amount of paper.', + source: 'diag_lines_attempt_2/process_photo_even_pixel_buckets.py', + style: 'diagonal', + grayscale: 'luminosity', + bucket: 'even-pixels', + layerCount: 4, + params: { ...DEFAULT_STYLE_PARAMS, lineSpacing: 3, colinearGap: 1 }, + palette: CMYK_GREY, + }, + { + id: 'diagonal-original', + name: 'Diagonal lines (sparse)', + description: 'The original, thinner pass — wider gaps after each run leave more paper showing.', + source: 'diag_lines_original/diag_lines.py', + style: 'diagonal', + grayscale: 'luminosity', + bucket: 'even-histogram', + layerCount: 4, + params: { ...DEFAULT_STYLE_PARAMS, lineSpacing: 3, colinearGap: 3 }, + palette: CMYK, + }, + { + id: 'horizontal-dogs', + name: 'Horizontal scan', + description: + 'Every row scanned left to right, split wherever the tone changes. The simplest and fastest to plot.', + source: 'dogs/main.py', + style: 'horizontal', + grayscale: 'average', + bucket: 'even-histogram', + layerCount: 3, + params: { ...DEFAULT_STYLE_PARAMS, lineSpacing: 1, colinearGap: 1 }, + palette: DOGS, + }, + { + id: 'dots-stipple', + name: 'Stipple dots', + description: + 'Each pixel becomes a small cell filled with a random scatter of dots — more dots where the image is darker.', + source: 'dots/main.py', + style: 'dots', + grayscale: 'average', + bucket: 'even-histogram', + layerCount: 5, + params: { ...DEFAULT_STYLE_PARAMS, boxSide: 2 }, + palette: ['#000000'], + }, + { + id: 'circles-red', + name: 'Concentric circles', + description: + 'Blocks averaged into a grid of filled circles, sized by tone. One pen; slow but striking.', + source: 'circles/main.py', + style: 'circles', + grayscale: 'average', + bucket: 'even-histogram', + layerCount: 5, + params: { ...DEFAULT_STYLE_PARAMS, sampleLength: 10, circleDiameter: 2, lineWidth: 0.4 }, + palette: RED, + }, +]; + +/** Repeat the preset palette out to `count` pens when it is shorter. */ +export const paletteFor = (palette: string[], count: number): string[] => + Array.from({ length: count }, (_, i) => palette[i % palette.length] ?? '#000000'); diff --git a/paint-app/src/photoDecode.ts b/paint-app/src/photoDecode.ts new file mode 100644 index 0000000..e32ac79 --- /dev/null +++ b/paint-app/src/photoDecode.ts @@ -0,0 +1,88 @@ +/** + * Canvas-backed half of the photo pipeline: decoding a file and resampling it. + * Kept apart from `photo.ts` so the arithmetic there stays pure and testable. + */ + +export type DecodedImage = { + rgba: Uint8ClampedArray; + width: number; + height: number; +}; + +export const decodeFile = async (file: File): Promise => { + try { + return await createImageBitmap(file); + } catch { + throw new Error( + `Could not decode "${file.name}". Supported formats are whatever the browser can open — JPEG, PNG, WebP, GIF.`, + ); + } +}; + +/** Largest size fitting inside the bounds without changing the aspect ratio. */ +export const fitWithin = ( + width: number, + height: number, + maxWidth: number, + maxHeight: number, +): { width: number; height: number } => { + if (width <= 0 || height <= 0) throw new Error('Image dimensions cannot be zero'); + if (maxWidth <= 0 || maxHeight <= 0) throw new Error('Maximum dimensions must be positive'); + const scale = Math.min(maxWidth / width, maxHeight / height); + return { + width: Math.max(1, Math.floor(width * scale)), + height: Math.max(1, Math.floor(height * scale)), + }; +}; + +/** + * Resample to exactly the given size. Callers pass dimensions already derived + * from `fitWithin`, so the aspect ratio is preserved by construction. + */ +export const resample = (source: ImageBitmap, width: number, height: number): DecodedImage => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw new Error('Could not get a 2D canvas context'); + ctx.drawImage(source, 0, 0, width, height); + return { rgba: ctx.getImageData(0, 0, width, height).data, width, height }; +}; + +/** Data URL of a bucketed image, for the wizard's preview. */ +export const bucketPreviewUrl = ( + data: Uint8Array, + width: number, + height: number, + palette: string[], +): string => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return ''; + const image = ctx.createImageData(width, height); + const rgb = palette.map(hexToRgb); + for (let i = 0; i < data.length; i++) { + const [r, g, b] = rgb[data[i]] ?? [0, 0, 0]; + image.data[i * 4] = r; + image.data[i * 4 + 1] = g; + image.data[i * 4 + 2] = b; + image.data[i * 4 + 3] = 255; + } + ctx.putImageData(image, 0, 0); + return canvas.toDataURL(); +}; + +const hexToRgb = (hex: string): [number, number, number] => { + const clean = hex.replace('#', ''); + const full = + clean.length === 3 + ? clean + .split('') + .map((c) => c + c) + .join('') + : clean; + const int = Number.parseInt(full, 16); + return [(int >> 16) & 255, (int >> 8) & 255, int & 255]; +}; From 59ba2630ada6f6add13242316e4f33444b3aebc9 Mon Sep 17 00:00:00 2001 From: Travis Bumgarner <11890057+TravisBumgarner@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:13:50 -0600 Subject: [PATCH 05/10] Make Photo processing a full-page studio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal wizard forced a linear path through settings that are actually interdependent — tone and shading get judged against each other, so stepping forward and back through a dialog was the wrong shape. It is now a full-page workspace: controls on the left, a live preview of whatever is being edited on the right. The steps become two tabs, "Prepare Image" (tone) and "Style" (shading), switchable in either direction. Until an image is loaded the workspace is replaced entirely by a picker, which now also takes a drag-and-drop. Strokes re-render on a debounce instead of on a button, and are only built while the Style tab is open since that is the expensive step. Any upstream change drops the previous render outright — otherwise editing tone on the Prepare tab would leave stale strokes behind and "Create project" would commit geometry from the settings before the edit. Co-Authored-By: Claude Opus 5 (1M context) --- paint-app/src/components/PhotoStudio.tsx | 706 +++++++++++++++++++++++ paint-app/src/components/PhotoWizard.tsx | 606 ------------------- paint-app/src/components/ProjectGate.tsx | 11 +- 3 files changed, 714 insertions(+), 609 deletions(-) create mode 100644 paint-app/src/components/PhotoStudio.tsx delete mode 100644 paint-app/src/components/PhotoWizard.tsx diff --git a/paint-app/src/components/PhotoStudio.tsx b/paint-app/src/components/PhotoStudio.tsx new file mode 100644 index 0000000..97b3066 --- /dev/null +++ b/paint-app/src/components/PhotoStudio.tsx @@ -0,0 +1,706 @@ +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import PhotoIcon from '@mui/icons-material/Photo'; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Divider, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Slider, + Stack, + Tab, + Tabs, + TextField, + Typography, +} from '@mui/material'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { PageSize } from '../pageSizes'; +import { + BUCKET_METHODS, + type BucketedImage, + type BucketMethod, + bucketImage, + GRAYSCALE_METHODS, + type GrayscaleMethod, + type LayerStrokes, + PHOTO_PRESETS, + type PhotoStyle, + paletteFor, + renderStyle, + type StyleParams, + targetResolution, + toGrayscale, +} from '../photo'; +import { bucketPreviewUrl, decodeFile, fitWithin, resample } from '../photoDecode'; +import { PageSizePicker } from './PageSizePicker'; + +export type PhotoResult = { + name: string; + pageSize: PageSize; + marginMm: number; + /** One entry per bucket, darkest first. Page-local mm. */ + layers: { color: string; strokes: LayerStrokes }[]; +}; + +type Props = { + onExit: () => void; + onCreate: (result: PhotoResult) => void; +}; + +type TabKey = 'prepare' | 'style'; + +const STYLE_LABELS: Record = { + horizontal: 'Horizontal scan', + diagonal: 'Diagonal lines', + dots: 'Stipple dots', + circles: 'Concentric circles', +}; + +const SIDEBAR_WIDTH = 380; +/** Long enough that dragging a slider doesn't re-render on every frame. */ +const RENDER_DEBOUNCE_MS = 350; +/** Past this the plot is impractical and the canvas will crawl. */ +const STROKE_WARN_THRESHOLD = 60_000; + +/** + * Full-page photo-to-plot workspace: controls on the left, a live preview of + * whichever stage is being edited on the right. The two tabs are independent + * rather than sequential — tone and shading are adjusted against each other, + * so moving between them has to be free. + */ +export const PhotoStudio = ({ onExit, onCreate }: Props) => { + const [tab, setTab] = useState('prepare'); + + const [file, setFile] = useState(null); + const [bitmap, setBitmap] = useState(null); + const [sourceUrl, setSourceUrl] = useState(null); + const [loadError, setLoadError] = useState(null); + const [dragging, setDragging] = useState(false); + + const [presetId, setPresetId] = useState(PHOTO_PRESETS[0].id); + const [grayscale, setGrayscale] = useState(PHOTO_PRESETS[0].grayscale); + const [bucket, setBucket] = useState(PHOTO_PRESETS[0].bucket); + const [layerCount, setLayerCount] = useState(PHOTO_PRESETS[0].layerCount); + const [palette, setPalette] = useState(PHOTO_PRESETS[0].palette); + + const [style, setStyle] = useState(PHOTO_PRESETS[0].style); + const [params, setParams] = useState(PHOTO_PRESETS[0].params); + const [pageSize, setPageSize] = useState({ width: 210, height: 297 }); + const [marginMm, setMarginMm] = useState(10); + + const [rendering, setRendering] = useState(false); + const [rendered, setRendered] = useState(null); + + const fileInputRef = useRef(null); + + const areaWidth = Math.max(1, pageSize.width - 2 * marginMm); + const areaHeight = Math.max(1, pageSize.height - 2 * marginMm); + + const applyPreset = useCallback((id: string) => { + const preset = PHOTO_PRESETS.find((p) => p.id === id); + if (!preset) return; + setPresetId(id); + setStyle(preset.style); + setGrayscale(preset.grayscale); + setBucket(preset.bucket); + setLayerCount(preset.layerCount); + setParams(preset.params); + setPalette(preset.palette); + }, []); + + useEffect(() => { + return () => { + if (sourceUrl) URL.revokeObjectURL(sourceUrl); + }; + }, [sourceUrl]); + + const colors = useMemo(() => paletteFor(palette, layerCount), [palette, layerCount]); + + /** The bucketed image at whatever resolution the chosen style needs. */ + const processed: BucketedImage | null = useMemo(() => { + if (!bitmap) return null; + const target = targetResolution(style, areaWidth, areaHeight, params); + const fitted = fitWithin(bitmap.width, bitmap.height, target.width, target.height); + const image = resample(bitmap, fitted.width, fitted.height); + const gray = toGrayscale(image.rgba, grayscale); + return bucketImage(gray, image.width, image.height, layerCount, bucket); + }, [bitmap, style, areaWidth, areaHeight, params, grayscale, layerCount, bucket]); + + const previewUrl = useMemo( + () => + processed + ? bucketPreviewUrl(processed.data, processed.width, processed.height, colors) + : null, + [processed, colors], + ); + + // Any change upstream invalidates the strokes immediately. Without this, + // editing tone on the Prepare tab would leave the previous render in place + // and "Create project" would happily commit strokes from the old settings. + // biome-ignore lint/correctness/useExhaustiveDependencies: invalidate on input identity, not on reading them + useEffect(() => { + setRendered(null); + }, [processed, style, params]); + + // Strokes are the expensive step, so they're only built while the Style tab + // is open, and only once the knobs have settled. + useEffect(() => { + if (!processed || tab !== 'style') return; + setRendering(true); + const timer = setTimeout(() => { + setRendered(renderStyle(style, processed, params)); + setRendering(false); + }, RENDER_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [processed, style, params, tab]); + + const loadImage = async (picked: File) => { + setLoadError(null); + try { + const decoded = await decodeFile(picked); + setFile(picked); + setBitmap(decoded); + setRendered(null); + setSourceUrl((previous) => { + if (previous) URL.revokeObjectURL(previous); + return URL.createObjectURL(picked); + }); + } catch (e) { + setLoadError((e as Error).message); + } + }; + + const onDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + const picked = e.dataTransfer.files?.[0]; + if (picked) loadImage(picked); + }; + + const strokeCount = rendered?.reduce((sum, l) => sum + l.length, 0) ?? 0; + + const finish = () => { + if (!rendered) return; + onCreate({ + name: file ? file.name.replace(/\.[^.]+$/, '') : 'Photo', + pageSize, + marginMm, + layers: rendered.map((strokes, i) => ({ color: colors[i], strokes })), + }); + }; + + const hiddenInput = ( + { + const picked = e.target.files?.[0]; + if (picked) loadImage(picked); + e.target.value = ''; + }} + /> + ); + + // Nothing here is meaningful without a source image, so the whole workspace + // is replaced by the picker until there is one. + if (!bitmap) { + return ( + + + { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4 }} + > + + + + Choose a photo to plot + + + Drop an image here, or pick one from your computer. Everything else unlocks once an + image is loaded. + + + {loadError && ( + + {loadError} + + )} + + {hiddenInput} + + + ); + } + + return ( + + + + {/* ── Controls ── */} + + + + + + {file?.name} + + + {bitmap.width}×{bitmap.height}px + + + + + {hiddenInput} + + setTab(v as TabKey)} variant="fullWidth"> + + + + + + + {tab === 'prepare' ? ( + + + Preset + + + + {PHOTO_PRESETS.find((p) => p.id === presetId)?.description} + + + + + + Grayscale + + + + {GRAYSCALE_METHODS.find((m) => m.value === grayscale)?.hint} + + + + Bucketing + + + + {BUCKET_METHODS.find((m) => m.value === bucket)?.hint} + + + + + Layers (pens): {layerCount} + + setLayerCount(v as number)} + /> + + + + + Pen colors + + + {colors.map((color, i) => ( + + { + const next = [...colors]; + next[i] = e.target.value; + setPalette(next); + }} + style={{ width: 34, height: 26, border: 'none', background: 'none' }} + /> + + {i === 0 ? 'dark' : i === layerCount - 1 ? 'light' : i + 1} + + + ))} + + + + ) : ( + + + Style + + + + + + + setMarginMm(Math.max(0, v))} + /> + + + + {(style === 'horizontal' || style === 'diagonal') && ( + <> + setParams({ ...params, lineSpacing: v })} + /> + setParams({ ...params, colinearGap: v })} + /> + + )} + {style === 'dots' && ( + setParams({ ...params, boxSide: v })} + /> + )} + {style === 'circles' && ( + <> + setParams({ ...params, sampleLength: v })} + /> + setParams({ ...params, circleDiameter: v })} + /> + setParams({ ...params, lineWidth: v })} + /> + + )} + + )} + + + {/* ── Result summary + commit ── */} + + + {rendered && !rendering && ( + + + {strokeCount.toLocaleString()} strokes across {rendered.length}{' '} + layers + + + {rendered.map((layer, i) => ( + + ))} + + {strokeCount > STROKE_WARN_THRESHOLD && ( + + That's a lot of strokes — the canvas will be sluggish and the plot will take + hours. Raise the line spacing or the gap after each run. + + )} + + )} + + {!rendered && ( + + Open the Style tab to generate strokes. + + )} + + + + {/* ── Preview ── */} + + {tab === 'prepare' ? ( + previewUrl && ( + + + {processed && ( + + Sampled at {processed.width}×{processed.height} for the {STYLE_LABELS[style]}{' '} + style + + )} + + ) + ) : rendering ? ( + + + + Generating strokes… + + + ) : ( + + )} + + + + ); +}; + +const StudioHeader = ({ onExit }: { onExit: () => void }) => ( + + + + Photo processing + + +); + +const NumField = ({ + label, + value, + onChange, + step = 1, +}: { + label: string; + value: number; + onChange: (v: number) => void; + step?: number; +}) => ( + onChange(Number.parseFloat(e.target.value) || 0)} + slotProps={{ htmlInput: { step, min: 0 } }} + /> +); + +/** + * Every stroke as SVG. Past a few thousand this gets heavy to lay out, so the + * preview thins the set — the created project always keeps all of them. + */ +const PREVIEW_STROKE_LIMIT = 4000; + +const StrokePreview = ({ + layers, + colors, + pageSize, + marginMm, +}: { + layers: LayerStrokes[] | null; + colors: string[]; + pageSize: PageSize; + marginMm: number; +}) => { + if (!layers) { + return ( + + Adjust the knobs to generate a preview. + + ); + } + + const total = layers.reduce((sum, l) => sum + l.length, 0); + const stride = Math.max(1, Math.ceil(total / PREVIEW_STROKE_LIMIT)); + + return ( + + + Plot preview + + {layers.map((strokes, layerIndex) => ( + + {strokes + .filter((_s, i) => i % stride === 0) + .map((stroke, i) => ( + `${p.x},${p.y}`).join(' ')} + fill="none" + strokeWidth={0.3} + /> + ))} + + ))} + + + + {pageSize.width}×{pageSize.height}mm + {stride > 1 && + ` · showing 1 in ${stride} strokes for speed, project gets all ${total.toLocaleString()}`} + + + ); +}; diff --git a/paint-app/src/components/PhotoWizard.tsx b/paint-app/src/components/PhotoWizard.tsx deleted file mode 100644 index 4a619db..0000000 --- a/paint-app/src/components/PhotoWizard.tsx +++ /dev/null @@ -1,606 +0,0 @@ -import { - Alert, - Box, - Button, - Chip, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormControl, - InputLabel, - MenuItem, - Paper, - Select, - Slider, - Stack, - Step, - StepLabel, - Stepper, - TextField, - Typography, -} from '@mui/material'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { PageSize } from '../pageSizes'; -import { - BUCKET_METHODS, - type BucketedImage, - type BucketMethod, - bucketImage, - DEFAULT_STYLE_PARAMS, - GRAYSCALE_METHODS, - type GrayscaleMethod, - type LayerStrokes, - PHOTO_PRESETS, - type PhotoStyle, - paletteFor, - renderStyle, - type StyleParams, - targetResolution, - toGrayscale, -} from '../photo'; -import { bucketPreviewUrl, decodeFile, fitWithin, resample } from '../photoDecode'; -import { PageSizePicker } from './PageSizePicker'; - -const STEPS = ['Image', 'Process', 'Style']; - -export type PhotoResult = { - name: string; - pageSize: PageSize; - marginMm: number; - /** One entry per bucket, darkest first. Page-local mm. */ - layers: { color: string; strokes: LayerStrokes }[]; -}; - -type Props = { - open: boolean; - onClose: () => void; - onCreate: (result: PhotoResult) => void; -}; - -const STYLE_LABELS: Record = { - horizontal: 'Horizontal scan', - diagonal: 'Diagonal lines', - dots: 'Stipple dots', - circles: 'Concentric circles', -}; - -export const PhotoWizard = ({ open, onClose, onCreate }: Props) => { - const [step, setStep] = useState(0); - - // Step 1 - const [file, setFile] = useState(null); - const [bitmap, setBitmap] = useState(null); - const [sourceUrl, setSourceUrl] = useState(null); - const [loadError, setLoadError] = useState(null); - - // Step 2 - const [presetId, setPresetId] = useState(PHOTO_PRESETS[0].id); - const [grayscale, setGrayscale] = useState('luminosity'); - const [bucket, setBucket] = useState('even-pixels'); - const [layerCount, setLayerCount] = useState(4); - const [palette, setPalette] = useState(PHOTO_PRESETS[0].palette); - - // Step 3 - const [style, setStyle] = useState('diagonal'); - const [params, setParams] = useState(DEFAULT_STYLE_PARAMS); - const [pageSize, setPageSize] = useState({ width: 180, height: 240 }); - const [marginMm, setMarginMm] = useState(10); - - const [rendering, setRendering] = useState(false); - const [rendered, setRendered] = useState(null); - - const fileInputRef = useRef(null); - - const areaWidth = Math.max(1, pageSize.width - 2 * marginMm); - const areaHeight = Math.max(1, pageSize.height - 2 * marginMm); - - const applyPreset = useCallback((id: string) => { - const preset = PHOTO_PRESETS.find((p) => p.id === id); - if (!preset) return; - setPresetId(id); - setStyle(preset.style); - setGrayscale(preset.grayscale); - setBucket(preset.bucket); - setLayerCount(preset.layerCount); - setParams(preset.params); - setPalette(preset.palette); - }, []); - - useEffect(() => { - if (open) applyPreset(PHOTO_PRESETS[0].id); - }, [open, applyPreset]); - - useEffect(() => { - return () => { - if (sourceUrl) URL.revokeObjectURL(sourceUrl); - }; - }, [sourceUrl]); - - const colors = useMemo(() => paletteFor(palette, layerCount), [palette, layerCount]); - - /** - * The bucketed image at the resolution the chosen style wants. Recomputed - * whenever anything upstream changes; it also backs the step-2 preview. - */ - const processed: BucketedImage | null = useMemo(() => { - if (!bitmap) return null; - const target = targetResolution(style, areaWidth, areaHeight, params); - const fitted = fitWithin(bitmap.width, bitmap.height, target.width, target.height); - const image = resample(bitmap, fitted.width, fitted.height); - const gray = toGrayscale(image.rgba, grayscale); - return bucketImage(gray, image.width, image.height, layerCount, bucket); - }, [bitmap, style, areaWidth, areaHeight, params, grayscale, layerCount, bucket]); - - const previewUrl = useMemo( - () => - processed - ? bucketPreviewUrl(processed.data, processed.width, processed.height, colors) - : null, - [processed, colors], - ); - - const onPickFile = async (picked: File) => { - setLoadError(null); - try { - const decoded = await decodeFile(picked); - setFile(picked); - setBitmap(decoded); - if (sourceUrl) URL.revokeObjectURL(sourceUrl); - setSourceUrl(URL.createObjectURL(picked)); - } catch (e) { - setLoadError((e as Error).message); - } - }; - - // Rendering the strokes is the expensive step, so it runs when the user - // reaches the last step rather than on every parameter tweak. - const render = useCallback(() => { - if (!processed) return; - setRendering(true); - // Yield first so the spinner paints before the main thread blocks. - setTimeout(() => { - try { - setRendered(renderStyle(style, processed, params)); - } finally { - setRendering(false); - } - }, 0); - }, [processed, style, params]); - - useEffect(() => { - if (step === 2) render(); - }, [step, render]); - - const strokeCount = rendered?.reduce((sum, l) => sum + l.length, 0) ?? 0; - - const reset = () => { - setStep(0); - setFile(null); - setBitmap(null); - if (sourceUrl) URL.revokeObjectURL(sourceUrl); - setSourceUrl(null); - setLoadError(null); - setRendered(null); - }; - - const handleClose = () => { - reset(); - onClose(); - }; - - const finish = () => { - if (!rendered) return; - onCreate({ - name: file ? file.name.replace(/\.[^.]+$/, '') : 'Photo', - pageSize, - marginMm, - layers: rendered.map((strokes, i) => ({ color: colors[i], strokes })), - }); - reset(); - }; - - return ( - - Photo processing - - - {STEPS.map((label) => ( - - {label} - - ))} - - - {step === 0 && ( - - - { - const picked = e.target.files?.[0]; - if (picked) onPickFile(picked); - e.target.value = ''; - }} - /> - {loadError && {loadError}} - {bitmap && sourceUrl && ( - <> - - {file?.name} — {bitmap.width}×{bitmap.height}px - - - - )} - - )} - - {step === 1 && ( - - - - Preset - - - - {PHOTO_PRESETS.find((p) => p.id === presetId)?.description} - - - - Grayscale - - - - - Bucketing - - - - {BUCKET_METHODS.find((m) => m.value === bucket)?.hint} - - - - - Layers (pens): {layerCount} - - setLayerCount(v as number)} - /> - - - - {colors.map((color, i) => ( - - { - const next = [...colors]; - next[i] = e.target.value; - setPalette(next); - }} - style={{ width: 28, height: 24, border: 'none', background: 'none' }} - /> - - {i === 0 ? 'darkest' : i === layerCount - 1 ? 'lightest' : `#${i + 1}`} - - - ))} - - - - - - Bucketed preview - - {previewUrl ? ( - - ) : ( - - Choose an image first. - - )} - {processed && ( - - Sampled at {processed.width}×{processed.height} - - )} - - - )} - - {step === 2 && ( - - - - Style - - - - - setMarginMm(Math.max(0, Number.parseFloat(e.target.value) || 0))} - sx={{ width: 140 }} - /> - - {(style === 'horizontal' || style === 'diagonal') && ( - - setParams({ ...params, lineSpacing: v })} - /> - setParams({ ...params, colinearGap: v })} - /> - - )} - {style === 'dots' && ( - setParams({ ...params, boxSide: v })} - /> - )} - {style === 'circles' && ( - - setParams({ ...params, sampleLength: v })} - /> - setParams({ ...params, circleDiameter: v })} - /> - setParams({ ...params, lineWidth: v })} - /> - - )} - - - - {rendered && ( - 60_000 ? 'warning' : 'info'}> - {strokeCount.toLocaleString()} strokes across {rendered.length}{' '} - layers. - {strokeCount > 60_000 && - ' That is a lot — the canvas will be sluggish and the plot will take hours. Increase line spacing or the gap after each run.'} - - {rendered.map((layer, i) => ( - - ))} - - - )} - - - - - Plot preview - - {rendering ? ( - - Rendering… - - ) : ( - - )} - - - )} - - - - - - {step > 0 && } - {step < STEPS.length - 1 ? ( - - ) : ( - - )} - - - ); -}; - -const NumField = ({ - label, - value, - onChange, - step = 1, -}: { - label: string; - value: number; - onChange: (v: number) => void; - step?: number; -}) => ( - onChange(Number.parseFloat(e.target.value) || 0)} - slotProps={{ htmlInput: { step, min: 0 } }} - sx={{ width: 150 }} - /> -); - -/** - * Renders every stroke as SVG. Above a few thousand this gets heavy, so the - * preview thins the set down — the real project keeps all of them. - */ -const PREVIEW_STROKE_LIMIT = 4000; - -const StrokePreview = ({ - layers, - colors, - pageSize, - marginMm, -}: { - layers: LayerStrokes[] | null; - colors: string[]; - pageSize: PageSize; - marginMm: number; -}) => { - if (!layers) { - return ( - - Nothing rendered yet. - - ); - } - - const total = layers.reduce((sum, l) => sum + l.length, 0); - const stride = Math.max(1, Math.ceil(total / PREVIEW_STROKE_LIMIT)); - - return ( - - - Plot preview - - - {layers.map((strokes, layerIndex) => ( - - {strokes - .filter((_s, i) => i % stride === 0) - .map((stroke, i) => ( - `${p.x},${p.y}`).join(' ')} - fill="none" - strokeWidth={0.3} - /> - ))} - - ))} - - - {stride > 1 && ( - - Showing 1 in {stride} strokes for speed — the project gets all {total.toLocaleString()}. - - )} - - ); -}; diff --git a/paint-app/src/components/ProjectGate.tsx b/paint-app/src/components/ProjectGate.tsx index 59e2dab..118b6ef 100644 --- a/paint-app/src/components/ProjectGate.tsx +++ b/paint-app/src/components/ProjectGate.tsx @@ -26,7 +26,7 @@ import { createInitialState } from '../store'; import { type AppState, AppStateSchema } from '../types'; import { ConnectedDataWizard } from './ConnectedDataWizard'; import { PageSizePicker } from './PageSizePicker'; -import { type PhotoResult, PhotoWizard } from './PhotoWizard'; +import { type PhotoResult, PhotoStudio } from './PhotoStudio'; const uid = () => Math.random().toString(36).slice(2, 10); @@ -38,6 +38,8 @@ export const ProjectGate = () => { const [size, setSize] = useState(() => loadLastPageSize()); const [connectedDataOpen, setConnectedDataOpen] = useState(false); + // The photo util takes over the whole view rather than opening a dialog — + // it is a workspace, not a form. const [photoOpen, setPhotoOpen] = useState(false); const refresh = useCallback(async () => { @@ -151,6 +153,10 @@ export const ProjectGate = () => { refresh(); }; + if (photoOpen) { + return setPhotoOpen(false)} onCreate={onCreatePhoto} />; + } + return ( @@ -281,7 +287,7 @@ export const ProjectGate = () => { @@ -293,7 +299,6 @@ export const ProjectGate = () => { onClose={() => setConnectedDataOpen(false)} onStart={onStartConnectedData} /> - setPhotoOpen(false)} onCreate={onCreatePhoto} /> ); }; From c7433c589663d27ee014a20405fc5d2912c618d9 Mon Sep 17 00:00:00 2001 From: Travis Bumgarner <11890057+TravisBumgarner@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:17:21 -0600 Subject: [PATCH 06/10] Set the source image row apart in the photo studio sidebar Every control below depends on the loaded image, so the row now reads as a header rather than as the first setting: tinted background, a divider under it, a border on the thumbnail, a bolder filename, and Change as an outlined button instead of a bare link. Co-Authored-By: Claude Opus 5 (1M context) --- paint-app/src/components/PhotoStudio.tsx | 33 +++++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/paint-app/src/components/PhotoStudio.tsx b/paint-app/src/components/PhotoStudio.tsx index 97b3066..5a66fe5 100644 --- a/paint-app/src/components/PhotoStudio.tsx +++ b/paint-app/src/components/PhotoStudio.tsx @@ -276,22 +276,47 @@ export const PhotoStudio = ({ onExit, onCreate }: Props) => { minHeight: 0, }} > - + {/* The source image is the one thing every control below depends on, + so it is set apart rather than reading as the first setting. */} + - + {file?.name} {bitmap.width}×{bitmap.height}px - From ff91daddb96767a1a6dffa061c8e64bcbc84f5b6 Mon Sep 17 00:00:00 2001 From: Travis Bumgarner <11890057+TravisBumgarner@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:26:23 -0600 Subject: [PATCH 07/10] Make Prepare Image about the image alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preset dropdown on Prepare set the shading style along with tone, colour count, and pen colours — a style concern reaching across into image preparation, which made the tab hard to reason about. Presets are now style and its parameters only, and they live on the Style tab where they belong. Prepare becomes a real adjustment pipeline instead: black / gray / white points, contrast, grayscale as a toggle rather than an assumption, and reduction to N inks. With grayscale off, colours are reduced by k-means clustering — the step the Python README listed as planned and never implemented — with centroids ordered darkest-first so the styles' "ink 0 is the heaviest" assumption holds for colour images too. Seeding is deterministic, so a re-render never reshuffles the palette. Pen colours move to per-ink overrides layered over what the image itself suggests (a grey ramp, or the k-means centroids). Changing the ink count can no longer strand a stale palette, and the sketch palettes are offered as one-click chips. One thing worth stating plainly, because it looks like a bug: under Even pixel count the gray point does nothing. That split ranks pixels, and a tone curve preserves rank, so only clipping moves a pixel between inks. The UI says so where the sliders are, and a test asserts the invariance so it can't be "fixed" into something that quietly means something else. Co-Authored-By: Claude Opus 5 (1M context) --- paint-app/src/components/PhotoStudio.tsx | 348 +++++++++++++++++------ paint-app/src/photo.ts | 307 +++++++++++++++++--- 2 files changed, 523 insertions(+), 132 deletions(-) diff --git a/paint-app/src/components/PhotoStudio.tsx b/paint-app/src/components/PhotoStudio.tsx index 5a66fe5..e97d9b6 100644 --- a/paint-app/src/components/PhotoStudio.tsx +++ b/paint-app/src/components/PhotoStudio.tsx @@ -8,12 +8,14 @@ import { CircularProgress, Divider, FormControl, + FormControlLabel, InputLabel, MenuItem, Paper, Select, Slider, Stack, + Switch, Tab, Tabs, TextField, @@ -23,19 +25,22 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { PageSize } from '../pageSizes'; import { BUCKET_METHODS, - type BucketedImage, type BucketMethod, - bucketImage, + DEFAULT_ADJUST, + DEFAULT_PREPARE, GRAYSCALE_METHODS, type GrayscaleMethod, type LayerStrokes, + PALETTE_PRESETS, PHOTO_PRESETS, type PhotoStyle, + type PreparedImage, + type PrepareParams, paletteFor, + prepareImage, renderStyle, type StyleParams, targetResolution, - toGrayscale, } from '../photo'; import { bucketPreviewUrl, decodeFile, fitWithin, resample } from '../photoDecode'; import { PageSizePicker } from './PageSizePicker'; @@ -83,12 +88,14 @@ export const PhotoStudio = ({ onExit, onCreate }: Props) => { const [loadError, setLoadError] = useState(null); const [dragging, setDragging] = useState(false); - const [presetId, setPresetId] = useState(PHOTO_PRESETS[0].id); - const [grayscale, setGrayscale] = useState(PHOTO_PRESETS[0].grayscale); - const [bucket, setBucket] = useState(PHOTO_PRESETS[0].bucket); - const [layerCount, setLayerCount] = useState(PHOTO_PRESETS[0].layerCount); - const [palette, setPalette] = useState(PHOTO_PRESETS[0].palette); + // Preparation: everything about the image itself. + const [prepare, setPrepare] = useState(DEFAULT_PREPARE); + // Per-ink colour overrides. Anything unset falls back to what the image + // suggests, so changing the ink count never strands a stale palette. + const [inkOverrides, setInkOverrides] = useState>({}); + // Shading: everything about how it lands on paper. + const [presetId, setPresetId] = useState(PHOTO_PRESETS[0].id); const [style, setStyle] = useState(PHOTO_PRESETS[0].style); const [params, setParams] = useState(PHOTO_PRESETS[0].params); const [pageSize, setPageSize] = useState({ width: 210, height: 297 }); @@ -107,30 +114,33 @@ export const PhotoStudio = ({ onExit, onCreate }: Props) => { if (!preset) return; setPresetId(id); setStyle(preset.style); - setGrayscale(preset.grayscale); - setBucket(preset.bucket); - setLayerCount(preset.layerCount); setParams(preset.params); - setPalette(preset.palette); }, []); + const patchPrepare = useCallback( + (changes: Partial) => setPrepare((previous) => ({ ...previous, ...changes })), + [], + ); + useEffect(() => { return () => { if (sourceUrl) URL.revokeObjectURL(sourceUrl); }; }, [sourceUrl]); - const colors = useMemo(() => paletteFor(palette, layerCount), [palette, layerCount]); - - /** The bucketed image at whatever resolution the chosen style needs. */ - const processed: BucketedImage | null = useMemo(() => { + /** The reduced image at whatever resolution the chosen style needs. */ + const processed: PreparedImage | null = useMemo(() => { if (!bitmap) return null; const target = targetResolution(style, areaWidth, areaHeight, params); const fitted = fitWithin(bitmap.width, bitmap.height, target.width, target.height); const image = resample(bitmap, fitted.width, fitted.height); - const gray = toGrayscale(image.rgba, grayscale); - return bucketImage(gray, image.width, image.height, layerCount, bucket); - }, [bitmap, style, areaWidth, areaHeight, params, grayscale, layerCount, bucket]); + return prepareImage(image.rgba, image.width, image.height, prepare); + }, [bitmap, style, areaWidth, areaHeight, params, prepare]); + + const colors = useMemo(() => { + const base = processed?.suggestedPalette ?? paletteFor(['#000000'], prepare.colorCount); + return base.map((color, i) => inkOverrides[i] ?? color); + }, [processed, inkOverrides, prepare.colorCount]); const previewUrl = useMemo( () => @@ -330,109 +340,201 @@ export const PhotoStudio = ({ onExit, onCreate }: Props) => { {tab === 'prepare' ? ( - - - Preset - - - - {PHOTO_PRESETS.find((p) => p.id === presetId)?.description} - + +
patchPrepare(DEFAULT_ADJUST)}> + + patchPrepare({ blackPoint: Math.min(v, prepare.whitePoint - 1) }) + } + /> + v.toFixed(2)} + onChange={(v) => patchPrepare({ gamma: v })} + /> + + patchPrepare({ whitePoint: Math.max(v, prepare.blackPoint + 1) }) + } + /> + patchPrepare({ contrast: v })} + /> + {prepare.grayscale && prepare.bucketMethod === 'even-pixels' && ( + + Even pixel count splits by rank, so a tone curve on its own can't move a pixel + between inks — the gray point will do nothing here. Only clipping (black/white + points, or hard contrast) changes the split. Switch to Even histogram to make + these fully effective. + + )} +
- - Grayscale - - - - {GRAYSCALE_METHODS.find((m) => m.value === grayscale)?.hint} - +
+ patchPrepare({ grayscale: e.target.checked })} + /> + } + label={Convert to grayscale} + /> - - Bucketing - - - - {BUCKET_METHODS.find((m) => m.value === bucket)?.hint} - + {prepare.grayscale ? ( + <> + + Method + + + + {GRAYSCALE_METHODS.find((m) => m.value === prepare.grayscaleMethod)?.hint} + + + ) : ( + + Colors are reduced with k-means clustering, which picks the inks that best + represent the image. + + )} +
- - - Layers (pens): {layerCount} - + + +
setLayerCount(v as number)} + value={prepare.colorCount} + onChange={(_e, v) => patchPrepare({ colorCount: v as number })} /> - + {prepare.grayscale && ( + <> + + Split tones by + + + + {BUCKET_METHODS.find((m) => m.value === prepare.bucketMethod)?.hint} + + + )} +
- - - Pen colors - + + +
0 ? () => setInkOverrides({}) : undefined + } + > {colors.map((color, i) => ( { - const next = [...colors]; - next[i] = e.target.value; - setPalette(next); - }} + onChange={(e) => + setInkOverrides((previous) => ({ ...previous, [i]: e.target.value })) + } style={{ width: 34, height: 26, border: 'none', background: 'none' }} /> - {i === 0 ? 'dark' : i === layerCount - 1 ? 'light' : i + 1} + {i === 0 ? 'dark' : i === colors.length - 1 ? 'light' : i + 1} ))} - + + {PALETTE_PRESETS.map((preset) => ( + + setInkOverrides( + Object.fromEntries( + paletteFor(preset.colors, colors.length).map((c, i) => [i, c]), + ), + ) + } + /> + ))} + +
) : ( + + Preset + + + + {PHOTO_PRESETS.find((p) => p.id === presetId)?.description} + + Style - patchPrepare({ grayscaleMethod: e.target.value as GrayscaleMethod }) - } - > - {GRAYSCALE_METHODS.map((m) => ( - - {m.label} - - ))} - - - - {GRAYSCALE_METHODS.find((m) => m.value === prepare.grayscaleMethod)?.hint} - - - ) : ( - - Colors are reduced with k-means clustering, which picks the inks that best - represent the image. - - )} - - - - -
- patchPrepare({ colorCount: v as number })} - /> - {prepare.grayscale && ( - <> - - Split tones by - - - - {BUCKET_METHODS.find((m) => m.value === prepare.bucketMethod)?.hint} - - - )} -
+ Change + +
+ {hiddenInput} + + setTab(v as TabKey)} variant="fullWidth"> + + + + + + + {tab === 'prepare' ? ( + +
patchPrepare(DEFAULT_ADJUST)}> + + patchPrepare({ blackPoint: Math.min(v, prepare.whitePoint - 1) }) + } + /> + v.toFixed(2)} + onChange={(v) => patchPrepare({ gamma: v })} + /> + + patchPrepare({ whitePoint: Math.max(v, prepare.blackPoint + 1) }) + } + /> + patchPrepare({ contrast: v })} + /> + {prepare.grayscale && prepare.bucketMethod === 'even-pixels' && ( + + Even pixel count splits by rank, so a tone curve on its own can't move a pixel + between inks — the gray point will do nothing here. Only clipping (black/white + points, or hard contrast) changes the split. Switch to Even histogram to make + these fully effective. + + )} +
- + -
0 ? () => setInkOverrides({}) : undefined +
+ patchPrepare({ grayscale: e.target.checked })} + /> } - > - - {colors.map((color, i) => ( - - - setInkOverrides((previous) => ({ ...previous, [i]: e.target.value })) - } - style={{ width: 34, height: 26, border: 'none', background: 'none' }} - /> - - {i === 0 ? 'dark' : i === colors.length - 1 ? 'light' : i + 1} - - - ))} - - - {PALETTE_PRESETS.map((preset) => ( - - setInkOverrides( - Object.fromEntries( - paletteFor(preset.colors, colors.length).map((c, i) => [i, c]), - ), - ) - } - /> - ))} - -
- - ) : ( - - - Preset - - - - {PHOTO_PRESETS.find((p) => p.id === presetId)?.description} - - - - Style - - - - - - - setMarginMm(Math.max(0, v))} + label={Convert to grayscale} /> - - - {(style === 'horizontal' || style === 'diagonal') && ( + {prepare.grayscale ? ( <> - setParams({ ...params, lineSpacing: v })} - /> - setParams({ ...params, colinearGap: v })} - /> + + Method + + + + {GRAYSCALE_METHODS.find((m) => m.value === prepare.grayscaleMethod)?.hint} + + ) : ( + + Colors are reduced with k-means clustering, which picks the inks that best + represent the image. + )} - {style === 'dots' && ( - setParams({ ...params, boxSide: v })} - /> - )} - {style === 'circles' && ( +
+ + + +
+ patchPrepare({ colorCount: v as number })} + /> + {prepare.grayscale && ( <> - setParams({ ...params, sampleLength: v })} - /> - setParams({ ...params, circleDiameter: v })} - /> - setParams({ ...params, lineWidth: v })} - /> + + Split tones by + + + + {BUCKET_METHODS.find((m) => m.value === prepare.bucketMethod)?.hint} + )} - - )} - +
- {/* ── Result summary + commit ── */} - - - {rendered && !rendering && ( - - - {strokeCount.toLocaleString()} strokes across {rendered.length}{' '} - layers - - - {rendered.map((layer, i) => ( - + +
0 ? () => setInkOverrides({}) : undefined + } + > + + {colors.map((color, i) => ( + + + setInkOverrides((previous) => ({ ...previous, [i]: e.target.value })) + } + style={{ width: 34, height: 26, border: 'none', background: 'none' }} + /> + + {i === 0 ? 'dark' : i === colors.length - 1 ? 'light' : i + 1} + + + ))} + + + {PALETTE_PRESETS.map((preset) => ( + + setInkOverrides( + Object.fromEntries( + paletteFor(preset.colors, colors.length).map((c, i) => [i, c]), + ), + ) + } /> ))} - {strokeCount > STROKE_WARN_THRESHOLD && ( - - That's a lot of strokes — the canvas will be sluggish and the plot will take - hours. Raise the line spacing or the gap after each run. - - )} - - )} - - {!rendered && ( - - Open the Style tab to generate strokes. +
+
+ ) : ( + + + Preset + + + + {PHOTO_PRESETS.find((p) => p.id === presetId)?.description} - )} -
-
- {/* ── Preview ── */} - - {tab === 'prepare' ? ( - previewUrl && ( - - + Style + + + + + + + setMarginMm(Math.max(0, v))} + /> + + + + {(style === 'horizontal' || style === 'diagonal') && ( + <> + setParams({ ...params, lineSpacing: v })} + /> + setParams({ ...params, colinearGap: v })} + /> + + )} + {style === 'dots' && ( + setParams({ ...params, boxSide: v })} /> - {processed && ( - - Sampled at {processed.width}×{processed.height} for the {STYLE_LABELS[style]}{' '} - style - - )} - - ) - ) : rendering ? ( - - - - Generating strokes… - + )} + {style === 'circles' && ( + <> + setParams({ ...params, sampleLength: v })} + /> + setParams({ ...params, circleDiameter: v })} + /> + setParams({ ...params, lineWidth: v })} + /> + + )} - ) : ( - )} + + {/* ── Result summary + commit ── */} + + + {rendered && !rendering && ( + + + {strokeCount.toLocaleString()} strokes across {rendered.length}{' '} + layers + + + {rendered.map((layer, i) => ( + + ))} + + {strokeCount > STROKE_WARN_THRESHOLD && ( + + That's a lot of strokes — the canvas will be sluggish and the plot will take + hours. Raise the line spacing or the gap after each run. + + )} + + )} + + {!rendered && ( + + Open the Style tab to generate strokes. + + )} + +
+ + {/* ── Preview ── */} + + {tab === 'prepare' ? ( + previewUrl && ( + + + {processed && ( + + Sampled at {processed.width}×{processed.height} for the {STYLE_LABELS[style]}{' '} + style + + )} + + ) + ) : rendering ? ( + + + + Generating strokes… + + + ) : ( + + )}
); }; -const StudioHeader = ({ onExit }: { onExit: () => void }) => ( - - - - Photo processing - - -); - /** A titled group of controls, with an optional reset affordance. */ const Section = ({ title, diff --git a/paint-app/src/components/ProjectGate.tsx b/paint-app/src/components/ProjectGate.tsx index 118b6ef..adeb99b 100644 --- a/paint-app/src/components/ProjectGate.tsx +++ b/paint-app/src/components/ProjectGate.tsx @@ -23,14 +23,17 @@ import { db, type Project } from '../db'; import { loadLastPageSize, type PageSize, saveLastPageSize } from '../pageSizes'; import { INTERACTIVE_PROJECT_ID, useProject } from './../project'; import { createInitialState } from '../store'; -import { type AppState, AppStateSchema } from '../types'; +import { AppStateSchema } from '../types'; import { ConnectedDataWizard } from './ConnectedDataWizard'; import { PageSizePicker } from './PageSizePicker'; -import { type PhotoResult, PhotoStudio } from './PhotoStudio'; const uid = () => Math.random().toString(36).slice(2, 10); -export const ProjectGate = () => { +type Props = { + onOpenPhoto: () => void; +}; + +export const ProjectGate = ({ onOpenPhoto }: Props) => { const { setProject } = useProject(); const { start: startConnectedData } = useConnectedData(); const [projects, setProjects] = useState(null); @@ -38,9 +41,6 @@ export const ProjectGate = () => { const [size, setSize] = useState(() => loadLastPageSize()); const [connectedDataOpen, setConnectedDataOpen] = useState(false); - // The photo util takes over the whole view rather than opening a dialog — - // it is a workspace, not a form. - const [photoOpen, setPhotoOpen] = useState(false); const refresh = useCallback(async () => { const all = await db.projects.orderBy('updatedAt').reverse().toArray(); @@ -101,50 +101,6 @@ export const ProjectGate = () => { startConnectedData(config); }; - /** - * A photo plot is a multi-pen print job, not a live session: it becomes a - * saved project with one layer per tone so the print flow can pause for a - * pen swap between them. - */ - const onCreatePhoto = async (result: PhotoResult) => { - setPhotoOpen(false); - const now = Date.now(); - const pageId = uid(); - const state: AppState = { - pages: [ - { id: pageId, x: 0, y: 0, width: result.pageSize.width, height: result.pageSize.height }, - ], - layers: result.layers.map((layer, index) => ({ - id: uid(), - name: `Pen ${index + 1}${index === 0 ? ' (darkest)' : ''}`, - color: layer.color, - thickness: 0.5, - visible: true, - strokes: layer.strokes.map((points) => ({ - id: uid(), - // Strokes arrive page-local; the margin is applied here so the - // renderer's world space stays the single source of truth. - points: points.map((p) => ({ x: p.x + result.marginMm, y: p.y + result.marginMm })), - color: layer.color, - })), - })), - activePageId: pageId, - activeLayerId: '', - }; - state.activeLayerId = state.layers[0].id; - - const project: Project = { - id: uid(), - name: result.name, - state, - createdAt: now, - updatedAt: now, - }; - await db.projects.put(project); - saveLastPageSize(result.pageSize); - setProject({ id: project.id, name: project.name }, state); - }; - const visibleProjects = (projects ?? []).filter((p) => p.id !== INTERACTIVE_PROJECT_ID); const onDelete = async (id: string) => { @@ -153,10 +109,6 @@ export const ProjectGate = () => { refresh(); }; - if (photoOpen) { - return setPhotoOpen(false)} onCreate={onCreatePhoto} />; - } - return ( @@ -286,7 +238,7 @@ export const ProjectGate = () => { shade with lines, dots, or circles. - diff --git a/paint-app/src/components/SettingsMenu.tsx b/paint-app/src/components/SettingsMenu.tsx index 58b5e4a..4cc5452 100644 --- a/paint-app/src/components/SettingsMenu.tsx +++ b/paint-app/src/components/SettingsMenu.tsx @@ -396,7 +396,7 @@ export const SettingsMenu = () => { return ( <> - setAnchor(e.currentTarget)} edge="start"> + setAnchor(e.currentTarget)}> diff --git a/paint-app/src/components/Toolbar.tsx b/paint-app/src/components/Toolbar.tsx index b224b51..51cf6ac 100644 --- a/paint-app/src/components/Toolbar.tsx +++ b/paint-app/src/components/Toolbar.tsx @@ -1,3 +1,4 @@ +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import BoltIcon from '@mui/icons-material/Bolt'; import BugReportIcon from '@mui/icons-material/BugReport'; import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; @@ -26,6 +27,9 @@ import { SettingsMenu } from './SettingsMenu'; type Props = { onPrint: () => void; + /** Set when a full-page util owns the view; it takes over the bar's title. */ + utilTitle: string | null; + onExitUtil: () => void; }; /** @@ -33,7 +37,7 @@ type Props = { * plotter selection and the connection live at app scope: you can connect, * switch machines, or emergency-stop from anywhere, including the home screen. */ -export const Toolbar = ({ onPrint }: Props) => { +export const Toolbar = ({ onPrint, utilTitle, onExitUtil }: Props) => { const { project, isDirty, isSaving, autosave } = useProject(); const { connected } = useConnection(); const { activePlotter } = usePlotters(); @@ -46,11 +50,21 @@ export const Toolbar = ({ onPrint }: Props) => { return ( - - - {project?.name ?? 'paint-app'} - - {project && !isInteractive && ( + {utilTitle ? ( + <> + + + {utilTitle} + + + ) : ( + + {project?.name ?? 'paint-app'} + + )} + {!utilTitle && project && !isInteractive && ( {isSaving && } @@ -140,6 +154,9 @@ export const Toolbar = ({ onPrint }: Props) => { + + + Math.random().toString(36).slice(2, 10); + +/** + * Persist a photo render as a project. A photo plot is a multi-pen print job + * rather than a live session, so each ink becomes its own layer and the print + * flow can pause for a pen swap between them. + */ +export const createPhotoProject = async ( + result: PhotoResult, +): Promise<{ project: Project; state: AppState }> => { + const now = Date.now(); + const pageId = uid(); + + const layers = result.layers.map((layer, index) => ({ + id: uid(), + name: `Pen ${index + 1}${index === 0 ? ' (darkest)' : ''}`, + color: layer.color, + thickness: 0.5, + visible: true, + strokes: layer.strokes.map((points) => ({ + id: uid(), + // Strokes arrive page-local; the margin is applied here so the + // renderer's world space stays the single source of truth. + points: points.map((p) => ({ x: p.x + result.marginMm, y: p.y + result.marginMm })), + color: layer.color, + })), + })); + + const state: AppState = { + pages: [ + { id: pageId, x: 0, y: 0, width: result.pageSize.width, height: result.pageSize.height }, + ], + layers, + activePageId: pageId, + activeLayerId: layers[0].id, + }; + + const project: Project = { + id: uid(), + name: result.name, + state, + createdAt: now, + updatedAt: now, + }; + + await db.projects.put(project); + saveLastPageSize(result.pageSize); + return { project, state }; +}; From 30d8a20a4c1cc0fc52703f9c7e018df2f617a08f Mon Sep 17 00:00:00 2001 From: Travis Bumgarner <11890057+TravisBumgarner@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:42:47 -0600 Subject: [PATCH 09/10] Add a resize knob, move ink colors to the Style tab Resize was implicit: the style picked a sampling resolution from the page area and that was that. It is now a Detail slider on Prepare, which is the knob the Python pipeline's aggressive `resize_image` really was. Below 100% the source is sampled coarsely and re-expanded with smoothing off, so the style still gets an image at exactly the resolution its geometry expects (one source pixel per millimetre) while tonal regions merge into hard-edged blocks. The plot comes out bolder, with fewer and longer strokes. Ink colors move to Style. How many inks the image is reduced to is a property of the image; which pens draw them is a property of the plot. Co-Authored-By: Claude Opus 5 (1M context) --- paint-app/src/components/PhotoStudio.tsx | 99 +++++++++++++++--------- paint-app/src/photoDecode.ts | 39 ++++++++++ 2 files changed, 100 insertions(+), 38 deletions(-) diff --git a/paint-app/src/components/PhotoStudio.tsx b/paint-app/src/components/PhotoStudio.tsx index bc83f4a..f92e56b 100644 --- a/paint-app/src/components/PhotoStudio.tsx +++ b/paint-app/src/components/PhotoStudio.tsx @@ -41,7 +41,7 @@ import { type StyleParams, targetResolution, } from '../photo'; -import { bucketPreviewUrl, decodeFile, fitWithin, resample } from '../photoDecode'; +import { bucketPreviewUrl, decodeFile, fitWithin, resampleWithDetail } from '../photoDecode'; import { PageSizePicker } from './PageSizePicker'; export type PhotoResult = { @@ -88,6 +88,9 @@ export const PhotoStudio = ({ onCreate }: Props) => { // Preparation: everything about the image itself. const [prepare, setPrepare] = useState(DEFAULT_PREPARE); + // How finely the source is sampled, as a fraction of what the style would + // use on its own. Below 1 the image is coarsened and re-expanded. + const [detail, setDetail] = useState(1); // Per-ink colour overrides. Anything unset falls back to what the image // suggests, so changing the ink count never strands a stale palette. const [inkOverrides, setInkOverrides] = useState>({}); @@ -131,9 +134,9 @@ export const PhotoStudio = ({ onCreate }: Props) => { if (!bitmap) return null; const target = targetResolution(style, areaWidth, areaHeight, params); const fitted = fitWithin(bitmap.width, bitmap.height, target.width, target.height); - const image = resample(bitmap, fitted.width, fitted.height); + const image = resampleWithDetail(bitmap, fitted.width, fitted.height, detail); return prepareImage(image.rgba, image.width, image.height, prepare); - }, [bitmap, style, areaWidth, areaHeight, params, prepare]); + }, [bitmap, style, areaWidth, areaHeight, params, prepare, detail]); const colors = useMemo(() => { const base = processed?.suggestedPalette ?? paletteFor(['#000000'], prepare.colorCount); @@ -336,6 +339,26 @@ export const PhotoStudio = ({ onCreate }: Props) => { {tab === 'prepare' ? ( +
setDetail(1) : undefined}> + `${Math.round(v * 100)}%`} + onChange={setDetail} + /> + + {detail >= 1 + ? 'Sampling at full resolution for the chosen style.' + : `Sampled at ${Math.round(detail * 100)}% and re-expanded, so tonal regions merge into larger blocks — fewer, longer strokes and a bolder plot.`} + {processed && ` Currently ${processed.width}×${processed.height}.`} + +
+ + +
patchPrepare(DEFAULT_ADJUST)}> { )}
+
+ ) : ( + + + Preset + + + + {PHOTO_PRESETS.find((p) => p.id === presetId)?.description} + + + + Style + + @@ -509,41 +567,6 @@ export const PhotoStudio = ({ onCreate }: Props) => { ))} -
- ) : ( - - - Preset - - - - {PHOTO_PRESETS.find((p) => p.id === presetId)?.description} - - - - Style - - diff --git a/paint-app/src/photoDecode.ts b/paint-app/src/photoDecode.ts index e32ac79..1f80e45 100644 --- a/paint-app/src/photoDecode.ts +++ b/paint-app/src/photoDecode.ts @@ -49,6 +49,45 @@ export const resample = (source: ImageBitmap, width: number, height: number): De return { rgba: ctx.getImageData(0, 0, width, height).data, width, height }; }; +/** + * Resample at a reduced detail level. The style still receives an image at + * exactly the resolution it expects (its geometry assumes one source pixel per + * millimetre), but the content is sampled coarsely and re-expanded without + * smoothing. The result is chunkier tonal regions: fewer, longer strokes and a + * bolder plot, which is what the Python pipeline's aggressive `resize_image` + * was really buying. + */ +export const resampleWithDetail = ( + source: ImageBitmap, + width: number, + height: number, + detail: number, +): DecodedImage => { + if (detail >= 1) return resample(source, width, height); + + const coarseWidth = Math.max(1, Math.round(width * detail)); + const coarseHeight = Math.max(1, Math.round(height * detail)); + + const coarse = document.createElement('canvas'); + coarse.width = coarseWidth; + coarse.height = coarseHeight; + const coarseCtx = coarse.getContext('2d'); + if (!coarseCtx) throw new Error('Could not get a 2D canvas context'); + coarseCtx.drawImage(source, 0, 0, coarseWidth, coarseHeight); + + const full = document.createElement('canvas'); + full.width = width; + full.height = height; + const ctx = full.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw new Error('Could not get a 2D canvas context'); + // Nearest-neighbour on the way back up, so the blocks stay hard-edged + // instead of being blurred back into a gradient. + ctx.imageSmoothingEnabled = false; + ctx.drawImage(coarse, 0, 0, width, height); + + return { rgba: ctx.getImageData(0, 0, width, height).data, width, height }; +}; + /** Data URL of a bucketed image, for the wizard's preview. */ export const bucketPreviewUrl = ( data: Uint8Array, From ebb802793a68d88ea59388bd30c8b0f4b563db7f Mon Sep 17 00:00:00 2001 From: Travis Bumgarner <11890057+TravisBumgarner@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:19:45 -0600 Subject: [PATCH 10/10] Show levels as a histogram with handles beneath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three unrelated sliders gave no sense of where an image's tones actually sit, so setting a black point was guesswork. Levels is now the familiar histogram with three handles under it, sharing the same 0..255 domain so a handle sits directly beneath the tones it clips. Regions outside the black and white points are dimmed, since that is what clipping does to them. The middle handle is gamma, expressed as a position between the outer two — the Photoshop convention, and the only way it reads meaningfully against a distribution. Converting between the two is gamma = ln(t) / ln(0.5), which is what makes the handle's input value land on middle grey; a test asserts exactly that, along with the round trip, because getting the direction backwards would be invisible in a type check and obvious only on screen. Bar heights use a square root scale: photographic histograms have a few huge spikes that flatten everything else into an invisible line under a linear one. Resampling also splits out from the reduction, so turning a levels knob no longer redoes the resample — only the per-pixel pass that actually depends on it. Co-Authored-By: Claude Opus 5 (1M context) --- paint-app/src/components/PhotoStudio.tsx | 221 +++++++++++++++++++---- paint-app/src/photo.ts | 28 +++ 2 files changed, 218 insertions(+), 31 deletions(-) diff --git a/paint-app/src/components/PhotoStudio.tsx b/paint-app/src/components/PhotoStudio.tsx index f92e56b..76a3732 100644 --- a/paint-app/src/components/PhotoStudio.tsx +++ b/paint-app/src/components/PhotoStudio.tsx @@ -29,7 +29,11 @@ import { DEFAULT_PREPARE, GRAYSCALE_METHODS, type GrayscaleMethod, + gammaToMidpoint, type LayerStrokes, + type LevelsParams, + luminanceHistogram, + midpointToGamma, PALETTE_PRESETS, PHOTO_PRESETS, type PhotoStyle, @@ -129,14 +133,34 @@ export const PhotoStudio = ({ onCreate }: Props) => { }; }, [sourceUrl]); - /** The reduced image at whatever resolution the chosen style needs. */ - const processed: PreparedImage | null = useMemo(() => { + /** + * The source resampled to whatever resolution the chosen style needs. Kept + * separate from the reduction below so that turning a levels knob doesn't + * redo the resample — only the cheap per-pixel pass. + */ + const sampled = useMemo(() => { if (!bitmap) return null; const target = targetResolution(style, areaWidth, areaHeight, params); const fitted = fitWithin(bitmap.width, bitmap.height, target.width, target.height); - const image = resampleWithDetail(bitmap, fitted.width, fitted.height, detail); - return prepareImage(image.rgba, image.width, image.height, prepare); - }, [bitmap, style, areaWidth, areaHeight, params, prepare, detail]); + return resampleWithDetail(bitmap, fitted.width, fitted.height, detail); + }, [bitmap, style, areaWidth, areaHeight, params, detail]); + + const histogram = useMemo( + () => + sampled + ? luminanceHistogram( + sampled.rgba, + prepare.grayscale ? prepare.grayscaleMethod : 'luminosity', + ) + : null, + [sampled, prepare.grayscale, prepare.grayscaleMethod], + ); + + /** The image reduced to ink indices. */ + const processed: PreparedImage | null = useMemo( + () => (sampled ? prepareImage(sampled.rgba, sampled.width, sampled.height, prepare) : null), + [sampled, prepare], + ); const colors = useMemo(() => { const base = processed?.suggestedPalette ?? paletteFor(['#000000'], prepare.colorCount); @@ -360,32 +384,12 @@ export const PhotoStudio = ({ onCreate }: Props) => {
patchPrepare(DEFAULT_ADJUST)}> - - patchPrepare({ blackPoint: Math.min(v, prepare.whitePoint - 1) }) - } - /> - v.toFixed(2)} - onChange={(v) => patchPrepare({ gamma: v })} - /> - - patchPrepare({ whitePoint: Math.max(v, prepare.blackPoint + 1) }) - } + { ); }; +const HISTOGRAM_HEIGHT = 72; + +/** + * Levels as a histogram with the three handles beneath it, Photoshop-style. + * The handles share the histogram's 0..255 domain so a thumb sits directly + * under the tones it clips; the middle one is the gamma control, expressed as + * a position between the outer two because that is how it reads against the + * distribution. + */ +const LevelsControl = ({ + histogram, + blackPoint, + whitePoint, + gamma, + onChange, +}: { + histogram: Int32Array | null; + blackPoint: number; + whitePoint: number; + gamma: number; + onChange: (changes: Partial) => void; +}) => { + const midpoint = gammaToMidpoint(gamma, blackPoint, whitePoint); + + const bars = useMemo(() => { + if (!histogram) return null; + let peak = 0; + for (const v of histogram) if (v > peak) peak = v; + if (peak === 0) return null; + // Square root rather than linear: photographic histograms have a few huge + // spikes that would flatten everything else into an invisible line. + return Array.from(histogram, (v) => Math.sqrt(v / peak) * HISTOGRAM_HEIGHT); + }, [histogram]); + + const handleChange = (value: number | number[], activeThumb: number) => { + if (!Array.isArray(value)) return; + const [black, mid, white] = value; + if (activeThumb === 1) { + onChange({ gamma: midpointToGamma(mid, blackPoint, whitePoint) }); + return; + } + // Dragging an outer handle keeps gamma where it is; the midpoint handle + // just follows, since its position is derived from gamma. + const nextBlack = Math.min(black, white - 2); + const nextWhite = Math.max(white, nextBlack + 2); + onChange({ blackPoint: nextBlack, whitePoint: nextWhite }); + }; + + return ( + + + {bars ? ( + + Tonal distribution + {/* Everything outside the black/white points is crushed flat. */} + + + {bars.map((height, i) => ( + whitePoint ? 0.3 : 0.75} + /> + ))} + + + ) : ( + + + No histogram yet + + + )} + + + handleChange(value, activeThumb)} + sx={{ + mt: 0, + py: 1, + // The track would read as a second axis competing with the + // histogram above; only the handles carry meaning here. + '& .MuiSlider-rail, & .MuiSlider-track': { opacity: 0 }, + }} + /> + + + + Black {Math.round(blackPoint)} + + + Gray {gamma.toFixed(2)} + + + White {Math.round(whitePoint)} + + + + ); +}; + /** A titled group of controls, with an optional reset affordance. */ const Section = ({ title, diff --git a/paint-app/src/photo.ts b/paint-app/src/photo.ts index ecfb6d4..c97edd9 100644 --- a/paint-app/src/photo.ts +++ b/paint-app/src/photo.ts @@ -130,6 +130,34 @@ export type BucketedImage = { layerCount: number; }; +/** + * Tonal distribution of the source, for the levels display. Uses whichever + * grayscale method is in play so the shape matches what actually gets split. + */ +export const luminanceHistogram = ( + rgba: Uint8ClampedArray, + method: GrayscaleMethod = 'luminosity', +): Int32Array => histogram256(toGrayscale(rgba, method)); + +/** + * Where the midtone handle sits between the black and white points, given a + * gamma. Inverse of `midpointToGamma`. + */ +export const gammaToMidpoint = (gamma: number, black: number, white: number): number => + black + (white - black) * 0.5 ** Math.max(0.01, gamma); + +/** + * Gamma that maps `mid` onto middle grey — the Photoshop levels convention. + * Solving 0.5 = t^(1/gamma) for gamma gives ln(t) / ln(0.5). + */ +export const midpointToGamma = (mid: number, black: number, white: number): number => { + const span = white - black; + if (span <= 0) return 1; + const t = (mid - black) / span; + if (t <= 0.001 || t >= 0.999) return t <= 0.001 ? 3 : 0.1; + return Math.max(0.1, Math.min(3, Math.log(t) / Math.log(0.5))); +}; + const histogram256 = (gray: Float32Array): Int32Array => { const hist = new Int32Array(256); for (const value of gray) {