diff --git a/.github/funding.yml b/.github/FUNDING.yml similarity index 100% rename from .github/funding.yml rename to .github/FUNDING.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7374167 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +# Weekly, with patches and minors grouped into one pull request. Majors come +# one at a time, and are taken on evidence: the real app driven, not only the +# tests passing. The workflows' actions are kept current the same way. + +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + target-branch: "2.0.0" + schedule: + interval: weekly + groups: + minor-and-patch: + update-types: [minor, patch] + + - package-ecosystem: github-actions + directory: "/" + target-branch: "2.0.0" + schedule: + interval: weekly + groups: + actions: + update-types: [minor, patch] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ecd3a1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +# The tests, on Windows and Ubuntu, for every push to a release branch and +# every pull request. They run under Node, with no window: the shared UI's own +# real-Electron and visual tests are @diamonddigitaldev/electron-kit's. + +on: + push: + branches: [master, '2.0.0'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + # npm 12, as on the development machine: it only runs the install scripts + # package.json allows (ffmpeg-static downloads its binary in one). + - name: Install npm 12 + run: npm install -g npm@12 + + # Windows runners install global packages outside Node's own folder, and + # setup-node puts Node's folder, with its bundled npm, first on PATH. + - name: Put npm 12 first on PATH + if: runner.os == 'Windows' + shell: pwsh + run: npm prefix -g >> $env:GITHUB_PATH + + - name: Install dependencies + run: | + npm --version + npm ci + + # ffmpeg-static downloads FFmpeg in an install script, which npm only runs + # when package.json's allowScripts approves it. Without the binary, the + # tests that need it skip, and electron-builder leaves it out of the build + # without a word, so this fails instead. + - name: Check FFmpeg and FFprobe are there + run: node -e "for (const [name, file] of [['FFmpeg', require('ffmpeg-static')], ['FFprobe', require('ffprobe-static').path]]) { require('fs').accessSync(file); console.log(name + ':', file); }" + + - name: Run the tests + run: npm test diff --git a/.gitignore b/.gitignore index 737bc52..ef00807 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ dist/ -package-lock.json \ No newline at end of file +# Local test media and its conversion output. Generated by hand while testing; +# never part of the repo. +test-media/ diff --git a/README.md b/README.md index 882668b..aa62f45 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ![license](https://img.shields.io/badge/license-Apache%202.0-blue?style=flat-square) ![version](https://img.shields.io/badge/version-1.0.0-brightgreen?style=flat-square) - ![platform](https://img.shields.io/badge/platform-Windows-lightgrey?style=flat-square) + ![platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-lightgrey?style=flat-square) [![discord](https://img.shields.io/discord/667479986214666272?logo=discord&logoColor=white&style=flat-square)](https://diamonddigital.dev/discord) [![buy me a coffee](https://img.shields.io/badge/-Buy%20Me%20a%20Coffee-ffdd00?logo=Buy%20Me%20A%20Coffee&logoColor=000000&style=flat-square)](https://www.buymeacoffee.com/willtda) @@ -30,9 +30,28 @@ ## Installation -1. Download the latest release from the [releases page](https://github.com/diamonddigitaldev/Diamond-File-Converter/releases). -2. Run the installer and follow the on-screen instructions. -3. Launch Diamond File Converter and start converting! +Download the files for your system from the [releases page](https://github.com/diamonddigitaldev/Diamond-File-Converter/releases). In the names below, `` is the release's version, such as `2.0.0`. + +### Windows + +Download **Diamond-File-Converter-Setup-``.exe** and run it. It asks who it's for: only you (the default, with no administrator prompt) or everyone on the computer. If Diamond File Converter is already installed for everyone, as every version before 2.0.0-beta.6 was, that copy is updated instead. It then asks which file types to open with it, all ticked, and whether to add **Convert with Diamond File Converter** to the menu files and folders show when you right-click them. + +> [!NOTE] +> You may get a Windows SmartScreen popup when trying to run the installer. This is normal as the installer is not signed. + +### Linux + +- **Debian, Ubuntu and derivatives:** download **Diamond-File-Converter-``.deb**, then run `sudo apt install ./Diamond-File-Converter-.deb`. Diamond File Converter is then in your applications menu under Sound & Video. +- **Fedora, openSUSE and others using RPM:** download **Diamond-File-Converter-``.rpm** and install it with your package manager, such as `sudo dnf install ./Diamond-File-Converter-.rpm`. +- **Anywhere else:** download **Diamond-File-Converter-``.AppImage**, make it executable with `chmod +x`, and run it. + +Linux builds start with 2.0. With the `.deb` or `.rpm`, the app is in your file manager's "Open with" for the files it converts. Opening files from a terminal (`diamond-file-converter file.mp4`) works too. + +### On every system + +FFmpeg and FFprobe are bundled, so there is nothing else to install and nothing to add to your PATH. The `latest.yml`, `latest-linux.yml` and `.blockmap` files on each release are for the app's updater: you don't need to download them. + +To go back to an earlier version, uninstall the app and install that version from the releases page. ## Usage diff --git a/docs/MANUAL-TESTING.md b/docs/MANUAL-TESTING.md new file mode 100644 index 0000000..6e4b0b3 --- /dev/null +++ b/docs/MANUAL-TESTING.md @@ -0,0 +1,2950 @@ +# Manual Testing Checklist + +Steps a human needs to perform, covering what automated tests cannot judge: +whether something *looks* right, *feels* right, and behaves correctly against +the real filesystem, real dialogs and real Windows integration. + +A new section is appended for each version or fix. Do not rewrite earlier +sections — the history is useful. + +> ### Read the status line on each section +> +> **Every section in this file has been run in full and passed, as of +> 2026-09-02 against 2.0.0-alpha.4.** The alpha.1 and alpha.2 sections were +> held back while the UX direction settled; they were worked through for this +> release, alongside the three alpha.4 sections. The withdrawn pipelines +> section has been deleted rather than carried as dead weight — the feature it +> covered was removed in `09eaca3` and no longer exists to test. +> +> **2026-10-16, for 2.0.0-beta.7: Will cleared every box still open as +> passed**, across all sections, and they are ticked. The status lines below +> are kept as they were written, so where one says a box was left open or +> failed, it was cleared by that sign-off, not by a separate run. +> +> A written checklist is not a passed one. Every section carries its own status +> line; do not describe any build as tested on the strength of this file +> existing. + +**Before starting:** + +``` +npm install +npm test # expect 207 passing, 0 failing +npm start +``` + +> If Electron exits immediately with `Cannot read properties of undefined +> (reading 'getPath')`, the shell has `ELECTRON_RUN_AS_NODE=1` set, which makes +> Electron run as plain Node. `unset ELECTRON_RUN_AS_NODE` and retry. This is +> an environment quirk, not a bug in the app. + +**Test media you will need:** a folder containing a mix of video, audio and +image files, at least one nested subfolder, at least one unsupported file +(a `.txt` will do), one very large video, and one deliberately corrupt media +file (rename a text file to `.mp4`). + +--- + +## 2.0.0-alpha.1 — card grid, secure bridge, core architecture + +> **Status: PASSED 2026-09-02.** Held while the UX direction settled, then run +> in full by hand against 2.0.0-alpha.4 and passed. This is the broad sweep — +> the card grid, ingest, selection, the format dropdown, conversion, failure +> handling, toasts, appearance in both themes, Windows integration and the +> security posture. + + +First release of the rebuilt interface. The whole UI changed, so this pass is +broad rather than targeted. + +### Window and first run + +- [x] App opens at roughly 1100×780 on a clean profile. +- [x] Window cannot be resized below 880×600. *(Raised from 720×560 in + alpha.4 — below roughly 800px wide the selection bar clipped its own + buttons.)* +- [x] Resize and move the window, close and reopen — position and size are + remembered. +- [x] Menu bar shows **Menu** and **Credits**. +- [x] `Menu → Open Files` opens a file dialog filtered to supported types. +- [x] `Menu → Open Folder` opens a folder picker. +- [x] `Ctrl+O` opens the file dialog; `Ctrl+Shift+O` opens the folder picker. +- [x] `Credits` opens a modal window; it cannot be minimised; `Escape` closes + it; the version number shown matches `package.json`. +- [x] Credits links (Diamond Digital, TheFuturisticIdiot, Donate, GitHub) all + open in the external browser, not inside the app. + +### Empty state and ingest + +- [x] Empty state shows the dashed drop zone, centred. +- [x] Hovering the drop zone tints it; the tint does **not** persist after the + pointer leaves. +- [x] Clicking the drop zone opens the file dialog. Clicking the **Select + Files** button inside it opens the dialog exactly once, not twice. +- [x] Drag a file over the window — the target area highlights. Drag out again + without dropping — the highlight clears. It must not flicker while moving + across cards. +- [x] Drop a single file: a card appears immediately. +- [x] Drop a folder containing nested subfolders: files from subfolders are + included, not just the top level. +- [x] Drop a folder containing an unsupported file: a toast reports how many + were skipped. **Nothing may be discarded silently.** +- [x] Drop the same file twice: it is not duplicated. +- [x] Drop a file onto the very edge of the window, outside the grid: the app + must **not** navigate away or replace itself with the file. + +### Cards and metadata + +- [x] Each card shows a kind icon (video / audio / image), the filename, and a + metadata line. +- [x] Metadata fills in shortly after the card appears — resolution, duration, + codec and size. A card must never wait on this to render. +- [x] Hover a card: the background lightens slightly and the border tints. +- [x] A long filename truncates with an ellipsis and does not push the remove + button out of the card. +- [x] Hovering a truncated filename shows the full path as a tooltip. +- [x] The × button removes just that card. +- [x] Drop an MP3 that has embedded cover art: it is treated as audio, with no + resolution shown. + +### Text selection *(regression: filenames used to highlight on click-drag)* + +- [x] Click and drag across a card's filename — **no text highlights.** +- [x] Same for the metadata line, the status text, the app title, the bulk bar + and the action bar. +- [x] Text inside a real input field can still be selected and edited. + +### Selecting cards + +- [x] Click a card: it is selected and the bulk bar switches to "N selected". +- [x] Ctrl+click a second card: both selected. +- [x] Shift+click a third: the range between them is selected. +- [x] `Ctrl+A` selects every card; `Escape` clears the selection. +- [x] `Delete` removes the selected cards. +- [x] Click the checkbox in a card's top-left corner: it toggles only that card + and leaves the rest of the selection alone. +- [x] With some but not all cards selected, the select-all checkbox in the bulk + bar shows a dash (indeterminate), not a tick. +- [x] Ticking select-all selects everything; unticking clears it. +- [x] Click the empty grid background: the selection clears. + +### Choosing formats + +- [x] With nothing selected, the bulk bar reads **"All N files"** and + **"Convert all to"**. +- [x] **The two-click flow:** drop a folder of images, choose one format from + the bulk bar, press Convert. Nothing else should be required. *(This is + how the previous version worked and must stay this quick.)* +- [x] With cards selected, the bar reads "N selected" / "Convert selected to", + and **Remove** and **Deselect** appear. +- [x] A per-card dropdown changes only that card. +- [x] Select a video and an audio file together: the bulk dropdown offers only + formats both can produce, and never a video-only container like MKV. +- [x] Select a video and an image together: no shared format exists, so the + dropdown is empty and the bar explains why. +- [x] Choose a format, then pick **"Choose format…"** again: the card returns to + the amber "Choose a format" state and Convert disables again. + *(Regression: this previously did nothing.)* +- [x] Convert stays disabled until at least one card has a format. +- [x] The footer detail line reads **"1 file still needs a format"** for one and + **"2 files still need a format"** for two. *(Regression: used to read + "1 still need a format".)* + +### Converting + +- [x] Queue several files, choose formats, press Convert. +- [x] More than one file converts at once — cards show progress simultaneously. +- [x] Each card shows its own progress bar advancing. +- [x] A file with no readable duration (a still image) shows a moving + indeterminate bar, **never a bar frozen at 0%**. +- [x] The overall bar at the bottom is **accent green, not blue**, and matches + the card bars. +- [x] While converting, the Convert button is replaced by **Cancel** — the two + never appear side by side. +- [x] Per-card **Cancel** stops just that file; the others carry on. +- [x] Footer **Cancel** stops everything. +- [x] Cancel a job mid-run, then check the output folder: **no partial file is + left behind.** +- [x] On completion a success toast appears and the footer summary reads the + outcome (e.g. "3 files converted"), **not** "3 files queued". + *(Regression: it used to revert to "queued".)* +- [x] Completed cards offer **Show in folder**; clicking it reveals the file in + Explorer. +- [x] Output lands next to the source file, with the correct extension. + +### Conversions worth checking individually + +- [x] Video → video (MP4 → MKV): plays correctly. +- [x] Video → audio (MP4 → MP3): audio only, correct duration. +- [x] Video → GIF: **colours look correct**, not a muddy 256-colour mess. +- [x] Video → PNG: output is a **folder** of numbered frames, and the card says + how many frames — "1 frame" for one, "12 frames" for twelve. +- [x] Animated GIF → PNG: also produces a folder of frames. +- [x] Image → image (PNG → JPG): correct, and transparency is flattened. +- [x] Audio → audio (FLAC → MP3): correct duration and audible quality. + +### Failure handling + +- [x] Convert the deliberately corrupt file. It must fail **without a blocking + dialog**. +- [x] The failing card turns red and shows the real ffmpeg reason, not a + generic message. +- [x] Other files in the same batch keep going. +- [x] A **Retry** button appears on the failed card and works after changing the + format. +- [x] Convert to a format whose output already exists: a prompt offers Cancel / + Overwrite / Save as New, and each behaves as labelled. +- [x] Convert to a read-only folder: fails with a clear message rather than + hanging. + +### Toasts + +- [x] Toasts appear centred below the header and **do not cover the app title + or the Add Files / Add Folder buttons.** +- [x] The icon lines up with the first line of text — check specifically on a + long wrapped message, where the icon must stay on the first line. +- [x] The × dismisses a toast; toasts auto-dismiss after a few seconds. +- [x] Several toasts stack rather than overlapping. +- [x] A failure toast stays until dismissed. + +### Appearance + +- [x] Switch Windows to light mode: the app follows without restarting. +- [x] Switch back to dark: it follows again. +- [x] **In light mode**, the Convert button is accent green, **not Bootstrap + blue**. *(Regression: it was blue in light mode.)* +- [x] Clear All, Cancel and Convert are all the same height where they sit + together. *(Regression: Clear All was shorter.)* +- [x] Resize the window narrow and wide: cards reflow, and the page never + scrolls sideways. +- [x] With many files queued, the grid scrolls and the header, bulk bar and + action bar stay put. + +### Windows integration *(packaged build only)* + +``` +npm run build +``` + +- [x] Installer runs and the app launches from the Start menu. +- [x] `resources/ffmpeg/` in the install directory contains **both** + `ffmpeg.exe` and `ffprobe.exe`. +- [x] Right-click a media file in Explorer: **"Convert with Diamond File + Converter"** appears and opens the app with that file queued. +- [x] Right-click a *folder*: the same entry appears and queues its contents. +- [x] Select several files in Explorer and use the entry: they arrive as **one + batch**, not one window per file. +- [x] With the app already running, use the Explorer entry again: files are + added to the existing window rather than opening a second one. +- [x] Double-click an associated media file: it opens in the app. +- [x] Uninstall, then right-click a media file: the context-menu entry is + **gone**. A leftover entry is a bug. + +### Security posture + +- [x] Open DevTools (in a dev run) and check the console on both the main and + credits windows: + - [x] `window.electronAPI` is defined + - [x] `window.require` is `undefined` + - [x] `window.process` is `undefined` +- [x] No errors in the console during a normal session. + +--- + +## 2.0.0-alpha.1 — QA fixes + +> **Status: PASSED 2026-09-02.** Run against the packaged 2.0.0-alpha.4 build +> — not `npm start` — and passed. The Escape defect only ever appeared once +> packaged, so the packaged build is the only run that counts here. + + +Two defects found running the checklist above against the packaged build, plus +the updater behaviour for pre-releases. **Test these against a packaged build, +not `npm start`** — the Escape defect only appeared once packaged. + +### Credits window closes on Escape + +- [x] Open Credits from the menu (or press `C`). +- [x] Press **Escape** — the window closes. +- [x] Open it again, click a link or a button first so focus has moved inside + the page, then press Escape — it still closes. +- [x] The × button still closes it too. +- [x] The window still cannot be minimised. + +### Existing output prompts instead of silently renaming + +- [x] Convert a file to a format whose output already exists in the + destination. A **File Already Exists** prompt appears offering + **Cancel / Overwrite / Save as New**. + *(It previously wrote "name (1).ext" with no prompt at all.)* +- [x] **Cancel** — the job is abandoned and the existing file is untouched. +- [x] **Overwrite** — the existing file is replaced. +- [x] **Save as New** — a numbered file appears alongside the original, and + the original is untouched. +- [x] Queue several files that all collide. Tick **"Apply to all remaining + files"** and choose one option — the prompt appears **once**, and the + rest follow that choice without asking again. +- [x] Start a second batch afterwards: the prompt appears again. The + apply-to-all choice must not carry over between runs. + +### Updates never offer a pre-release + +The app currently ships as `2.0.0-alpha.1` while the latest stable is `1.0.0`. + +- [x] `Menu → Check for Updates` while running the alpha reports **"You're up + to date!"**, not an offer to install `1.0.0`. Going backwards to an older + stable is a downgrade and must be refused. +- [x] It does **not** offer any other alpha or beta, even a newer one. +- [x] No update prompt appears on its own a few seconds after launch. +- [x] On a stable build, a newer stable release *is* still offered normally — + this must not have broken ordinary updates. +- [x] "View Changelog" from an update dialog opens the correct release page. + +--- + +## 2.0.0-alpha.2 — Advanced options / Bulk edit + +> **Status: PASSED 2026-09-02.** Held while the UX direction settled, then run +> in full by hand against 2.0.0-alpha.4 and passed, including the check that +> the plain path is still two clicks. + + +Per-file encoding settings. The engine already supported all of this; this pass +exposes it. **The plain path must stay two clicks** — check that first. + +### The simple path is not disturbed + +- [x] Drop a folder of images, pick one format in the bulk bar, press Convert. + Still two clicks. The dialog must not be required for this. +- [x] The per-card format dropdown still works on its own. + +### Opening the dialog + +- [x] There is **no button in the top-right corner** for this — it lives in the + green selection bar. +- [x] With nothing selected the bar reads **"Select all"** and the button reads + **"Edit all"**; it opens for every file. +- [x] With cards selected the button reads **"Bulk edit"** and opens for just + those, titled **"Bulk edit"**. +- [x] The tune icon on a card opens it for that one file only, titled + **"Advanced options"**, and does not change the selection. +- [x] Bulk editing files that already have *different* settings starts blank and + says they are configured differently, rather than showing one file's + values as if they applied to all. +- [x] The word "job" appears nowhere in the interface. +- [x] Escape and the × both close it without applying anything. + +### Controls follow the target format + +- [x] Choose an **audio** target (MP3): the Video section disappears entirely. +- [x] Choose an **image** target (PNG): the Audio section disappears and an + Image quality slider appears. Trim disappears for a still image. +- [x] Choose **GIF**: Trim stays available, because a GIF can be animated. +- [x] Choose **WebM**: the codec list offers VP9/VP8/AV1 and **not** H.264. +- [x] Choose **MP4**: it offers H.264/H.265/AV1 and **not** VP9. +- [x] Choose **MP3**: the audio codec list offers only MP3. +- [x] Set Stream to **Copy without re-encoding**: codec, quality, resize and + frame rate all disappear, since none of them apply to a remux. +- [x] Set Stream to **Remove video**: same, and the preview gains `-vn`. +- [x] Pick a codec with no constant-quality mode (MPEG-4 in AVI): Quality + switches to bitrate and locks. Switch back to H.264 — it must return to + **Constant quality**, not stay stuck on bitrate. +- [x] Select a video and an image together: no shared format exists, the target + list is empty and the dialog says so. + +### Command preview + +- [x] The preview updates as you change controls, and starts with `ffmpeg`. +- [x] It reflects what you set — a CRF of 20 appears as `-crf 20`, a resize as + `scale=...`, a trim as `-ss`. +- [x] The preview text can be selected and copied. *(It is the one deliberate + exception to nothing-is-selectable.)* +- [x] An invalid combination shows a red message and disables **Apply**. + +### Trim slider + +- [x] Trim is a **two-point slider**, not a pair of text boxes. +- [x] Drag the left handle right and the right handle left; the green range + between them shrinks and the readout shows the kept duration. +- [x] The handles **cannot cross**. +- [x] **Hold Shift while dragging** — the handle moves much more slowly for the + same pointer movement, so a precise point is reachable. +- [x] Click a handle and use the arrow keys; Shift+arrow gives a smaller step. + Home and End jump to the extremes. +- [x] Leave the handles alone and Apply: **no trim is recorded**, and the card + shows no trim in its summary. +- [x] Open advanced options on a file with no readable duration: the slider is + replaced by a note saying it cannot be trimmed. +- [x] Bulk edit a selection of clips of different lengths: the slider spans the + **longest**, and a trim past a shorter clip's end simply runs to its end. + +### Button styling + +- [x] The dialog's **Cancel is outlined, not filled** — only Apply is filled. + Two filled buttons side by side is a design violation. + +### Applying + +- [x] Apply, then check the card: a green summary line appears under the + metadata describing what was set, e.g. "H.264 · CRF 20 · 1280×720". +- [x] Open the dialog again on that card — your settings are still there. +- [x] Apply with nothing changed: the card gains **no** summary line. Untouched + settings must not be baked in. +- [x] Apply to a multi-file selection: every selected card gets the summary. +- [x] Convert, and confirm the output honours the settings — check the + resolution, duration and audio channels of the result, not just that a + file appeared. + +### Nothing is clipped + +Fixed widths on a `` as "the user is typing" +and bailed out. A `` too), then press + Escape, Delete and Ctrl+A — all three still work. +- [x] Click into a **text field** (the resize width, or the name template) and + press Delete — it edits the text and does **not** remove any cards. +- [x] Open **Advanced options**, select some text in a field, press Escape — + the dialog closes and the cards behind it are untouched. +- [x] With the dialog open and cards selected behind it, press **Delete** — + nothing is removed. + +### Convert no longer re-runs a finished batch + +- [x] Queue several files including one that will fail. Convert. +- [x] Fix the failed card's format and press **Convert** again. Only that card + runs. Every already-**Done** card stays Done, does not flip back to + Ready, and produces **no** "File Already Exists" prompt. +- [x] The **Retry** button on a single card still re-runs just that card. +- [x] Once every card is Done, the **Convert button is disabled** rather than + enabled and doing nothing. +- [x] Add a new file to a finished queue: Convert enables again and runs only + the new file. + +### "Apply to all remaining files" actually applies + +Jobs run through a concurrency pool, and several used to reach the prompt at +once — each opening its own dialog before the first answer was recorded, so +the tick had no effect on the dialogs already queued behind it. + +- [x] Queue **at least six** files that all collide with existing output. + Convert. +- [x] Only **one** prompt is on screen at a time. +- [x] Tick **"Apply to all remaining files"** and choose **Overwrite** — no + further prompts appear, and every file is overwritten. +- [x] Repeat with **Save as New**, and again with **Cancel**. Each choice + carries to the rest of the batch. +- [x] Start a second batch afterwards — the prompt appears again. The choice + must not carry between runs. + +### Advanced options: the codec you pick is the codec you get + +One defect caused both of the dialog bugs reported. The codec selects were +rebuilt from the value the dialog opened with, so a pick was overwritten +immediately — dropping it on Apply, and leaving Quality apparently stuck +because the effective codec fell back to the container default. + +- [x] Target **AVI**, set Codec to **MPEG-4**. Quality switches to **Target + bitrate** with its explanatory note. Reopen the Codec dropdown — it + reads **MPEG-4**, not "Format default". +- [x] Now set Codec to **H.264**. Quality returns to **Constant quality** and + the CRF field comes back. +- [x] Switch between MPEG-4 and H.264 several times — it tracks every time. +- [x] Set a CRF, Apply, and reopen — the codec and the CRF are both still + there, and the card's summary names the codec you chose. +- [x] Convert, then ffprobe the output: it really is the codec you picked. +- [x] Same check on the **audio** codec select, and on the **encoder preset** + (it is rebuilt the same way). + +### An unreadable file says so + +- [x] Queue a deliberately corrupt file (rename a `.txt` to `.mp4`). +- [x] Its metadata line settles on **"MP4 · unreadable"** within a second or + two. It must not sit on "Reading…" indefinitely. +- [x] A healthy file still shows its real resolution, duration, codec and size. + +### DevTools are reachable again + +Replacing Electron's default menu removed its F12 accelerator along with it. + +- [x] **Menu → Toggle Developer Tools** opens DevTools. +- [x] **F12** does the same. +- [x] With DevTools open, use the app normally — drop files, choose formats, + open the dialog, convert. **The console shows no errors.** *(This was + unverifiable in the first pass and is still unverified.)* + +### Credits window + +The Escape handler was verified working in isolation; what was missing was a +guard against opening the window more than once. + +- [x] Open Credits, press **Escape** — it closes. +- [x] Open Credits **five times in a row** from the menu. Only one window ever + exists; the others just focus it. +- [x] After those five opens, press **Escape once** — the window closes and + there is **no second window behind it**. +- [x] Open Credits, click **View Source Code on GitHub** (the browser takes + focus), click back on the Credits window, then press Escape — it closes. +- [x] The × button still closes it, and it still cannot be minimised. + +### Still unconfirmed — needs the reporter's exact sequence + +Two reported defects could not be reproduced. Both mechanisms were tested +directly under Electron and behaved correctly, so these steps are about +establishing whether there is a bug at all, not confirming a fix. + +- [x] **Live theme change.** With the app running, switch Windows + Settings → Personalization → Colors → "Choose your mode" from Dark to + Light. The app follows **without a restart**. Try it with the app + focused, and with it in the background; try it with the main window and + with Credits open. A push from the main process has been added as a + second route, so if this now works, note *which* route did it. +- [x] **Escape on Credits.** If it ever fails to close again, note whether the + menu had been used more than once, and whether focus had been in another + application first. + +--- + +## 2.0.0-alpha.4 — pipelines removed, window minimum raised + +> **Status: PASSED 2026-09-02.** First run by hand on 2026-08-31 against the +> live dev build (`npm start`) with a real mixed-media folder: the window +> minimum, the selection bar's "nothing selected" vs "N selected" states, +> folder ingest (nested subfolders, unsupported-file reporting, dedup, +> unreadable-file detection), and every "nothing offers a pipeline" check all +> matched. The one gap that pass could not cover — literal OS drag-and-drop +> from Explorer onto the window, which automation cannot drive out of a +> restricted Explorer window — was closed by hand on 2026-09-02. + +Two changes, both subtractive. + +**Pipelines are gone.** The feature was out of proportion to what this app is +for: a node editor is a large, fiddly thing to learn in order to do something +the advanced options dialog already does per file, and in use it proved +unintuitive. Everything has been removed — the editor, the Manual settings / +Pipeline chooser in the advanced options dialog, the graph model and compiler, +the IPC channels and the stored pipelines. Joining several files into one is +still wanted and is tracked separately; it was never going to be built on this. + +**The window will no longer shrink to a size that breaks it.** The old minimum +of 720×560 let the selection bar run past the right edge and clip its own +buttons. + +So most of this section is checking that removing something did not take +anything else with it. + +### The window minimum + +- [x] The window will not resize below **880×600** in either direction. +- [x] At exactly the minimum, with several files queued and all selected: + the selection bar fits, **no button is cut off at the right edge**, and + the Deselect button is fully visible. +- [x] At the minimum the footer still shows the summary, the progress bar and + all three buttons without overlap. +- [x] At the minimum at least one full row of cards is visible in the grid. +- [x] Collapse the rail at the minimum size — nothing reflows badly. +- [x] An existing install that had a smaller window remembered will open at or + above the new minimum rather than at the old saved size. + +### Nothing offers a pipeline any more + +- [x] The rail shows **Files** only. There is no Pipelines item. +- [x] Open **Advanced options** on a card. There are **no mode tiles** at the + top — the dialog starts straight at Output, and the sections below are + the ones the chosen format calls for. +- [x] There is no "Use pipeline" dropdown and no link to any editor anywhere. +- [x] No card ever reads "Choose a pipeline" or "Pipeline missing". +- [x] Every card with a format reads **Ready**. +- [x] The settings summary under a card shows the manual settings applied to + it, and is blank when none have been. + +### An existing install that had pipelines saved + +Worth doing on a profile that ran alpha.4's first build, if you still have one: + +- [x] The app opens normally with no error. +- [x] Cards that were previously set to use a pipeline now behave as ordinary + cards: they take their manual settings and convert. +- [x] Nothing in the interface refers to the pipelines that were saved. + +### The rail still works with one destination + +- [x] Clicking **Files** when already on Files does nothing awkward. +- [x] Collapse and expand still work, and the choice survives a restart. +- [x] Keyboard: Tab reaches the rail items, each takes a focus ring, Enter + activates. + +### Drag and drop actually works *(it did not)* + +Dropping files on the window has been broken since the 2.0 rewrite and nobody +caught it, because the drop steps live in the alpha.1 section and that section +has never been run. Every drop threw before it reached the queue. Use the +buttons and this looks fine; only dropping is affected. + +- [x] Drag **one file** from Explorer onto the window — a card appears. +- [x] Drag **several files at once** — a card appears for each. +- [x] Drag a **folder** — its contents are scanned in, subfolders included. +- [x] Drag a **mixture** of files and folders together. +- [x] Drag a folder containing unsupported files — the skipped count is + reported in a toast, not silently dropped. +- [x] Drop the **same file twice** — it is not duplicated. +- [x] Drop onto the **card grid** once cards exist, and onto the **drop zone** + when empty. Both work. +- [x] Drop onto the **navigation rail** — it still queues the file rather than + doing nothing. +- [x] Drop something with no file behind it — an image dragged out of a browser, + or selected text. Nothing is queued and **nothing throws**; the app must + not navigate away or go blank. +- [x] With DevTools open, drop a file and confirm **no console error**. + +### The selection bar only offers what applies + +- [x] With cards queued and **nothing selected**: the bar shows the + **Select all** checkbox, **"Convert all to"** and its format dropdown. + **Bulk edit, Remove and Deselect are all absent.** +- [x] The two-click flow still works from that state: pick one format, press + Convert. *(This is the flow the whole grid was nearly a regression for — + it must not gain a step.)* +- [x] Select one card: **Bulk edit**, **Remove** and **Deselect** appear, and + the labels change to "1 selected" / "Convert selected to". +- [x] Deselect everything again: those three disappear and the labels revert. +- [x] Tick **Select all**, then untick it — the buttons come and go with the + selection. +- [x] The bar itself is gone entirely when there are no cards at all. + +### Everything else still behaves + +This is the regression sweep — all of it worked in alpha.3 and must still: + +- [x] Drop files and folders; unsupported files are reported, not discarded. +- [x] Selection: click, ctrl+click, shift+click, Ctrl+A, Escape, Delete. +- [x] The two-click flow: drop a folder, pick one format in the bulk bar, + Convert. +- [x] Per-card format dropdowns, and clearing one back to "Choose format…". +- [x] Advanced options: output routing, name template, conflict policy, video + and audio settings, image quality, the trim slider. +- [x] The codec you choose is the codec you get, and it survives Apply and + reopening. *(The alpha.3 fix — it lived next to the removed code.)* +- [x] The live command preview updates as you change settings. +- [x] Convert several files at once; per-card progress and per-card cancel. +- [x] A failing file marks its own card and the queue carries on. +- [x] "Apply to all remaining files" on an output conflict. +- [x] Convert does not re-run already-finished cards. +- [x] Toasts, Credits, F12 Developer Tools, and both themes. + +--- + +## 2.0.0-alpha.4 — cross-kind conversions + +> **Status: PASSED 2026-08-31.** Both issues from the first pass are fixed in +> `340c76e` and confirmed by hand against the built dist (Electron 44, +> `dist/win-unpacked`), not just by code review this time: +> +> - Reopened Advanced options on a fresh video-to-PNG conversion — the span +> readout read **"covering 0:10" on the very first paint**, no drag needed. +> - Queued an animated WebP — the card read **"WEBP · unreadable" the moment +> it was added**, before any format was chosen. Note for whoever reads this +> next: the ingest card's own tooltip is still just that short label, not +> the fuller "animated WebP" sentence `probe.js` computes — that detail +> currently only surfaces if you go on to pick a format and press Convert, +> which now correctly fails with **"skipping unsupported chunk: ANIM"** +> (both in the toast and on the card) instead of the old bare "Conversion +> failed!". Worth deciding whether the probe's fuller reason should reach +> the ingest card directly, or whether surfacing it at attempt-time is +> considered enough. +> +> `npm test` is 151/152 on this Linux sandbox (152 including the +> pre-existing Windows-only path test, same single artifact as before — +> nothing new failing), with 7 new tests added specifically for these three +> fixes, all passing. A normal conversion (clip.mp4 → 300 PNG frames) still +> ran clean on the rebuilt Electron 44 binary. +> +>
+> First pass (2026-08-31, superseded by the fix above) +> +> Run by hand against the live dev build with a real video, audio file, and a +> spread of still/animated GIF/WebP/PNG sources. Frame counting and the frame +> preview, the volume guard dialog (confirmed both under and over the +> 1,000-image line), retargeting a video across kinds after setting explicit +> codec/quality by hand, and the per-format dialog sections (Picture/Video/Audio +> showing and hiding correctly) all matched. Two real problems: +> +> 1. **The Frames span readout reads "keeping HH:MM" instead of "covering +> HH:MM"** the first time the dialog opens in range mode — it self-corrects +> to "covering" the moment either handle is dragged. +> 2. **An animated WebP into PNG fails outright**, not just the "still WebP +> into PNG" case this section expects to work. The bundled ffmpeg's WebP +> decoder skips the `ANIM`/`ANMF` chunks that carry the animation and reports +> "image data not found" — a real limitation of this particular +> ffmpeg-static build's WebP support, not a bug in this app's job +> composition. Separately, the card only ever shows a bare "Conversion +> failed!" for this failure, with none of the real ffmpeg reason surfaced. +> +> A still GIF/WebP into PNG produced a single file (not a folder), an animated +> GIF into MP4 kept its animation, and image metadata read correctly for both. +> Audio extraction's stream-copy rule (offered only when the source audio +> already matches) wasn't re-driven by hand here — it's covered by the +> automated suite rather than this pass. +>
+ +Converting between *kinds* — a video into audio, a video into stills, a GIF into +a video — has been offered in the format dropdown since the card grid landed. +What it did once you chose it was another matter. The queue described this item +as "surface the cross-kind conversions"; they were already surfaced, and quietly +wrong. + +What changed, and so what to poke at: + +- **Choosing which frames.** A conversion that produces stills now has a + **Frames** section instead of Trim: a span with a preview of the frame at each + end, a choice between every frame in that span and a single one, and a running + count of the images it will write. +- **An animated GIF into MP4 keeps its animation.** It used to be treated as one + still and looped for five seconds. +- **A still WebP into PNG makes one file.** It used to make a *folder* holding + one frame. +- **Retargeting across kinds no longer fails** on settings chosen for the old + target. +- A picture target no longer asks for a video codec, bitrate or encoder preset. + +### Frames: choosing which ones + +- [x] Queue a video of a minute or so and set its format to **PNG**. +- [x] The card shows roughly how many images that is, in amber, under the format + row. Click that line — **Advanced options opens.** +- [x] The section is headed **Frames**, not Trim, and offers **Every frame in + the range** / **A single frame**. +- [x] A preview of the actual frame appears at each end of the span, with its + timestamp beneath. **The right-hand one is not blank** *(the end handle + sits exactly at the duration, where there is no frame to decode — it is + nudged just inside)*. +- [x] Drag either handle: the previews follow, the timestamps update, and the + image count changes with them. +- [x] Drag quickly back and forth, then stop. The preview that settles matches + where you actually stopped, not somewhere you passed through. +- [x] The readout says **covering** a span, not "keeping" one. +- [x] Press **A single frame**: one handle, one preview, the readout clears, and + the count reads **1 image**. +- [x] Apply, and the card now reads **1 image**. +- [x] Reopen the dialog — it is still on **A single frame**. +- [x] Convert. Exactly **one** PNG lands, next to the source, and it is **not a + black frame** *(an unspecified single frame is taken from the middle)*. +- [x] Set a narrow range and **Every frame in the range**, convert, and count the + files. It matches roughly what the card promised, and only that stretch of + the video is in there. +- [x] The files are named `frame_000001.png` upwards and **sort in capture + order** in Explorer. + +### The volume guard + +- [x] With a target of PNG over a long video, press **Convert**. A dialog says + about how many images this writes. +- [x] **Cancel** — nothing converts, the card stays Ready. +- [x] Confirm — it runs. +- [x] Narrow the range so it is under a thousand images and press Convert — **no + dialog**, it just runs. +- [x] Convert a single frame, or anything that is not a frame export — no dialog. + +### Conversions that used to be wrong + +- [x] **An animated GIF to MP4.** The result *plays the animation* and is about + as long as the GIF. It must not be a single frozen frame held for five + seconds. +- [x] **A still WebP or GIF to PNG.** One file appears. **Not a folder.** +- [x] An **animated WebP** to anything is refused before you start: the card + reads **unreadable**, and the reason names animated WebP. *(This is a + limitation of the bundled FFmpeg 6.1.1, whose WebP decoder skips the + `ANIM`/`ANMF` chunks — not something the app can convert around. An + earlier version of this checklist wrongly said it should work.)* +- [x] **A video to MP3.** Plays, right length, no video stream. +- [x] **A video to GIF.** Still animated, still good colours. +- [x] **A still image to MP4.** A short clip holding that picture. + +### Retargeting across kinds + +- [x] Queue a video, open Advanced options, set a codec and a quality + deliberately, Apply. +- [x] Now change that card's format to **PNG** and convert. It works. *(It used + to fail with "PNG does not support the video codec libx264" — a setting + you never asked to apply to a picture.)* +- [x] Changing between two formats of the **same** kind — MP4 to MKV — **keeps** + your settings. + +### The dialog suits the conversion + +- [x] Target **PNG**: the section is headed **Picture** and offers only Resize + and Frame rate. **No Stream, Codec, Quality or Encoder preset.** +- [x] Target **MP4**: headed **Video**, all of them present. +- [x] Target **MP3** from a video: no video section at all, no Resize. +- [x] An **image source into MP4**: no Audio section — a picture has none. +- [x] **Stream copy** is offered for MP4 to MKV. +- [x] Extracting audio from a video to **MP3**: "Copy without re-encoding" is + offered **only** if that video's audio is already MP3. From a normal + AAC-carrying MP4 it must be absent. *(It used to always be offered and + then fail inside ffmpeg.)* +- [x] Extracting to **M4A** from an AAC video does offer it, and copying works. + +### Metadata reads correctly for images + +The probe's still-vs-moving test was inverted, so this is worth a look: + +- [x] An **animated GIF** card shows its dimensions. +- [x] A **still PNG or WebP** card shows its type without claiming a frame rate. +- [x] A video card is unchanged — resolution, duration, codec, size. +- [x] A corrupt file still settles on "unreadable". + +### Nothing else regressed + +- [x] Drag and drop, folder ingest, selection, the two-click flow. +- [x] Trim still works normally on a video-to-video conversion, and still says + **keeping**. +- [x] The command preview updates as you change anything. +- [x] Convert several files of mixed kinds at once. +- [x] Both themes, and at the 880×600 minimum — **the frame previews and the + Frames block must fit** without the dialog scrolling sideways. + +--- + +## 2.0.0-alpha.4 — Electron 44 + +> **Status: PASSED 2026-09-02.** Run against the clean 2026-09-02 installer. +> Explorer integration, the admin prompt and the all-users install passed, and +> the runtime items that need a running window — window bounds across a +> restart, both themes with a live theme switch, drag and drop from Explorer, +> and converting end to end from the installed build — were worked through and +> passed too. The size expectation below was **wrong and has been corrected** +> — see the packaging note. `resources/ffmpeg/` in the fresh build carries +> both `ffmpeg.exe` and `ffprobe.exe`. + +> ### Packaging: what sits in the project folder ships +> +> The 2026-08-31 alpha.4 build was **polluted**. `electron-builder` has no +> `files` allowlist in `package.json`, so it packages everything in the project +> directory that isn't one of its own defaults — and a hand-made `test-media/` +> folder sitting there went into `app.asar`: **623 of 2036 entries, ~111MB** of +> test clips and extracted PNG frames, shipped inside the installer. `.gitignore` +> does not help; electron-builder does not read it. +> +> Rebuilt clean on 2026-09-02 with the scratch folder moved out of the project +> directory, the real figures are: +> +> | build | installer | `app.asar` | +> |---|---|---| +> | alpha.3 (Electron 41, clean) | 241.2 MiB | — | +> | alpha.4 2026-08-31 (polluted) | 307.5 MiB | 131.6 MB | +> | alpha.4 2026-09-02 (clean) | **252.0 MiB** | **16.5 MB** | +> +> So Electron 41 → 44 costs **+10.8 MiB (+4.5%)**, not the ~66MB the earlier +> figure implied. Worth adding a `files` exclusion to the build config so a +> stray folder can never do this again. + +The runtime moved three major versions: Electron 41 → 44, bringing Chromium 152 +and Node 24. **No application code changed for it.** It was taken only after +driving the real app against the things a major bump actually threatens, all of +which passed — but automation cannot judge how it feels, and a runtime change +can affect anything. + +- [x] The app starts, and **Menu → About / Credits** shows the right version. +- [x] Window position and size are still remembered across a restart. +- [x] Drag and drop from Explorer still queues files. *(This crosses the context + bridge through `webUtils`, which is exactly the sort of API a major bump + moves.)* *(Will, 2026-09-02 — the first time this has been exercised by + hand rather than through Add Folder.)* +- [x] Frame previews still appear in the Frames section. +- [x] Both themes, and a live Windows theme switch while running. +- [x] Explorer integration: right-click a media file → the app opens with it + queued. Double-click an associated file. *(Will, 2026-09-02.)* +- [x] The installer runs, asks for admin, and installs for all users. + *(Will, 2026-09-02.)* +- [x] **The installer is about 252 MiB, up from 241 MiB on alpha.3** — a +10.8 + MiB, +4.5% cost for Electron 44. *(Corrected 2026-09-02: this line + previously read "about 308MB, up from 242MB", which was measured against + a build carrying ~111MB of stray test media. See the packaging note + above.)* +- [x] Convert something end to end from the **installed** build, not just the + dev one. *(Will, 2026-09-02 — reinstalled from the clean 2026-09-02 + installer first; the copy installed before that was the polluted 08-31 + build.)* + +--- + +## 2.0.0-alpha.4 — fixes from the cross-kind test pass + +> **Status: PASSED 2026-09-02.** Written 2026-08-31 alongside the fixes, then +> run by hand and passed: the span readout is right on first open, a failed +> conversion names its cause, and an animated WebP is refused at probe time. + +Two issues came back from testing the frames work. Both are fixed; a third, +found while fixing them, is fixed too. + +### The span readout names what it is doing + +- [x] Queue a video, set it to **PNG**, open **Advanced options**. Without + touching anything, the readout under the slider reads **"covering 0:10"**. + *(It used to say "keeping" until you dragged a handle — the slider painted + before the section knew it was choosing frames.)* +- [x] Drag a handle: still "covering". +- [x] Switch to **A single frame**: the readout clears — one frame is a position, + not a span. +- [x] Now change the format to **MP4** in the same dialog. The section is headed + **Trim** again and reads **"keeping …"**. *(Found while fixing the above: + the stale single-frame choice blanked it, and would also have silently + thrown away the trim's end.)* +- [x] Set a trim, Apply, convert — the output really is trimmed. +- [x] Switch back to PNG in the same dialog: it remembers you had chosen + **A single frame**. + +### A failed conversion says why + +- [x] Convert something that will fail — an animated WebP is the easy one, or + rename a `.txt` to `.mp4`. +- [x] The card shows **a real reason**, not "Conversion failed!". For animated + WebP it names the unsupported chunk. +- [x] The reason has no `[component @ 0x7f…]` prefix on it. +- [x] Hover or select the message — it is readable and can be copied. +- [x] A batch where one file fails still converts the rest, and only the failed + card carries the message. + +### Animated WebP is refused up front + +- [x] Drop an **animated WebP**. The card's metadata line reads + **"WEBP · unreadable"** as soon as the probe returns — before any format + is chosen. +- [x] Open Advanced options on it: nothing crashes, and there is no duration to + trim. +- [x] A **still** WebP is unaffected: it shows its type and converts normally. +- [x] An animated **GIF** is unaffected: dimensions shown, converts, keeps its + animation. +- [x] An **MP3 with embedded cover art** is unaffected — still audio, no + resolution badge, converts normally. *(The check looks for a picture + stream with no dimensions; cover art has them.)* + +--- + +## 2.0.0-alpha.5 — the conflict prompt and the pool + +> **Status: run by hand 2026-09-05.** The Apply regression described below is +> fixed and re-verified via the "Advanced options actually stick" section. Of +> the remaining boxes: the staggered-batch and queue-keeps-moving cases both +> pass; the concurrency-cap case passes on the app's own progress state but +> could not be checked against Task Manager, which this session cannot open; +> and one new, real, reproducible failure was found — pressing the footer +> Cancel/Cancel All button while a File Already Exists prompt is open does +> nothing, even after dragging the dialog aside as suggested below. See "Cancel +> All while a prompt is open" for the reproduction. + +**None of the checklist below could be exercised, because the prompt itself +never appears.** Reproduced live, repeatedly: + +- Queue any file whose output name already exists. Leave **"If it already + exists"** at its default (**Ask me**), or explicitly set it to **Ask me**, + **Overwrite**, or **Skip the file** via Advanced options and click **Apply** + (the "Settings applied to 1 file" toast confirms it took). Press **Convert**. +- **No dialog ever appears, under any of the four settings.** The file is + silently written as `name (1).ext`, `name (2).ext`, etc. — "Save as a new + file" behaviour — regardless of what was actually configured. Overwrite does + not overwrite (the original's mtime is untouched); Skip does not skip (a new + numbered file is written anyway); Ask me does not ask. +- Root cause, confirmed via the DevTools console: `Advanced options → Apply` + does not reliably write into the job's `settings` object. + `JSON.stringify(jobs.find(...).settings)` reads back `{}` immediately after + Apply, even though the toast said the settings were applied and the dialog + showed the chosen value. This is the **same mechanism** behind the mirrored- + output failure below — Apply losing the settings it just collected — so it + is one root cause surfacing as two symptoms, not two separate bugs. It is + intermittent for some destinations (mirror) but was 100% reproducible for + onConflict in every attempt this session (5/5: Ask me, Overwrite, and Skip + each tested twice, 6 attempts total). +- Practical effect: a user who picks Overwrite or Skip to avoid clutter gets + silently ignored and ends up with numbered duplicates anyway, with no + indication anything went wrong. This is worse than before the alpha.5 fix + branch, because there is now no error and no visible sign of failure at all. + +Every item below is left unchecked because it depends on the prompt firing. +They should be revisited once Apply reliably persists `job.settings.output`. + +Three defects on the "If it already exists → **Ask me**" path, all with one +cause: a job waiting on the prompt was in neither the running set nor the queue, +so the pool could not see it. It looked idle, it looked empty, and it looked +like nothing had been cancelled. + +**The one that matters is the first**, because it is the same symptom a tester +reported against alpha.1 and which `4f1d06b` was supposed to have fixed — +"Apply to all remaining files" being ignored. That fix was real; this is a +second, independent route to the same behaviour, and it survived because the +existing checklist case below happens to use the one ordering that works. + +`npm test` is 155 passing. The three defects now have behavioural tests in +`test/runner.test.js` that drive a real `JobRunner`; they were confirmed to fail +against the old code before the fix went in. Two tests that read `main.js` as +text were deleted rather than kept — one of them matched the very line causing +the first defect and read it as proof the code was correct. + +### "Apply to all remaining" survives a staggered batch *(it did not)* + +The existing case at **"Apply to all remaining files" actually applies** uses +six similar files, which all finish probing before you can answer the first +dialog. That is the ordering that passes. This one is deliberately the other +ordering. + +- [x] Put **one very large video** and **five small files** in a folder, and + convert them all once so every output exists. Used the existing + `large.mp4` (25s, 1080p) plus `small1–5.mp4` (2s each) from the conflict + test media, first converted to WebM to create the collision targets. +- [x] Set concurrency to **2 or more** (Menu → it follows the CPU count by + default; any machine with 4+ cores is fine). No concurrency control + exists in the Menu in this build — checked via DevTools console, + `navigator.hardwareConcurrency` reads **32** on this machine, comfortably + satisfying the "any 4+-core machine" allowance, so the default pool size + is well above 1. +- [x] Queue all six again and press Convert. On the **first** prompt, tick + **"Apply to all remaining files"** and choose **Overwrite**. +- [x] **Exactly one dialog appears.** Verified: the prompt fired once for + `large.webm`, and all five small files plus the large one converted + afterwards with no further dialogs. +- [x] All six convert, and the toast at the end reports six. +- [x] Repeat choosing **Save as New** — same thing, one dialog, and every output + lands as `name (1).ext`. Verified: re-queued the same six files, one + dialog on `large.webm`, ticked Apply to all + Save as New, and all six + landed as the next free numbered file (`large (2).webm`, + `small1 (4).webm`, etc., since earlier passes had already used some + numbers) rather than overwriting anything. + +### Cancel All while a prompt is open *(it does not stop anything)* + +- [x] **Re-tested 2026-09-08, fixed.** The original failure was that the + footer's Cancel/Cancel All button did nothing while any conflict prompt + was open — clicking it, even after dragging the dialog clear, had no + effect, and the only way to stop a run was to answer every prompt + individually. That is now fixed: the button no longer lives in the + footer at all. **Cancel All · Skip This File · Overwrite · Save as + New** are all inside the "File Already Exists" dialog itself, so + Windows' window-modal input-blocking (the actual cause, per the fix + notes) no longer applies to it. Clicking Cancel All now visibly does + something on every press: it cancels the file currently being asked + about and the dialog either closes or moves to the next prompt. + **However**, a single press does not abort the whole run in one action + the way the fix description implies — seven collisions queued up front + (independent of conversion concurrency) each need their own press. Full + detail and repro is in the new `## 2.0.0-alpha.5 — the QA failures, + fixed` section under "The conflict prompt can abort the run" — left + unticked there, not here, since the box in that section is the one + that specifically claims a single Cancel All press aborts everything. + This box only covers the original complaint (the button being + unreachable/inert), which is resolved. +- [x] Not applicable — depends on the above. *(Original box text was already + lost before this session — see the 09-03 session note elsewhere in this + file. The underlying blocker this depended on — the footer button being + unreachable — is now gone, but without knowing what this item + originally tested, it can't be verified here rather than guessed at.)* +- [x] Not applicable — depends on the above. *(Same as above — original text + unknown, not guessed at.)* + +### The queue keeps moving after a prompt + +The fix makes a waiting job hold a slot. If it failed to give the slot back, the +queue would stall — so this is the case that proves it does. + +- [x] Queue **three** colliding files with concurrency set to **1**. Set via + hand-editing `concurrency` to `1` in `config.json` and relaunching, since + no in-app control exists. +- [x] Answer the first prompt with **Cancel** (the button, not Cancel All). With + concurrency 1 the footer read **"0 running"** throughout — confirming a + job waiting on the prompt does not let a second job start. +- [x] The **second** prompt appears. Answer it **Cancel** too. +- [x] The **third** prompt appears. Answer **Overwrite** — it converts. +- [x] The app returns to idle with two cancelled and one done. Nothing is left + spinning, and the Convert button is no longer lit. Verified: footer read + "1 file converted, 2 cancelled" and Convert was clickable again. +- [x] Repeat, answering **Cancel** on all three instead of Overwriting the last: + same, three cards settle as Cancelled and the app returns to idle with + no stall. *(The dialog itself offers Overwrite / Save as New / Cancel — + there is no separate "Skip" button on the live prompt; Skip is only a + standing policy set in Advanced options, which bypasses the dialog + entirely rather than being an answer to it. This repeat instead checks + the pool doesn't stall even when every prompt in the batch is declined, + not just when the last one is accepted — read literally, "Skip" isn't a + choice the dialog offers, so this is the closest faithful re-run.)* + +### Concurrency still means something + +- [x] Queue **eight** colliding files with concurrency **2**. Set via the same + `config.json` edit (`concurrency: 2`) and relaunch; used `concurrency8`'s + `f1–f8.mp4`, first converted once to WebM to create the collisions. +- [x] Tick "Apply to all remaining" on the first prompt and choose Overwrite. + One dialog fired (for `f3.webm`, the first in scan order that session), + and all eight converted afterwards. +- [x] Could not verify as specified — **Task Manager is on this environment's + denylist for this session** and could not be opened to count + `ffmpeg.exe` processes. As indirect, weaker evidence: the app's own + per-card progress state never showed more than **two** cards reading + "Converting" at once across two separate runs (a first pass converting + all eight cleanly, and the collision re-run above), which is consistent + with the pool honouring `concurrency: 2`, but this is the app's own + self-report, not an independent process count, and does not substitute + for the check as written. + +### Nothing else about conflicts changed + +- [x] Re-verified 2026-09-05 via the "Advanced options actually stick" section: + with a card's conflict setting explicitly set to **Overwrite**, Apply, + convert — the existing file was replaced, no numbered duplicate + appeared. With **Skip the file** explicitly set, Apply, convert — no new + file was written and the card settled as skipped. Both now behave as + labelled; neither degrades to "Save as a new file" any more. +- [x] With no collision at all, no dialog ever appears. Held throughout this + whole pass — the large majority of conversions run this session had no + pre-existing output and none of them raised a prompt. +- [x] Not independently tested this pass — per-card Cancel on a running + conversion wasn't specifically exercised against alpha.5. (It was + confirmed working in the alpha.1 section, which this is a regression + check against.) + +--- + +## 2.0.0-alpha.5 — what a folder ingest skipped, and why + +> **Status: run by hand 2026-09-05, 15 of 19 passed.** Left unticked: one box +> in "One message, not two" where the real behaviour doesn't literally match +> the wording, the unreadable-file toast (needs an OS-level permission-denied +> file this environment couldn't produce), the drag-and-drop leg of the +> "all four input methods match" box (File Explorer access this session is +> click-only, so drag-and-drop couldn't be driven), and the both-themes check +> (not attempted this pass — see the item for why). + +Unsupported files were dropped with only a count — "skipped 2 unsupported +files" — which tells you something went missing, but not what, and not whether +it was the app's doing or yours. The scanner now records a reason per file and +the toast can list them. + +Two reporting bugs went with it. A scan that both truncated *and* skipped +reported only the truncation, because the branches were an else-if chain. And +adding files that were all already in the list said **nothing at all**, which +reads exactly like a drop that never registered. + +There were also two different "skipped" toasts — one from the scanner, one from +the queue's own format check — which could both fire for a single folder and +word the same thing differently. There is now one. + +**Test media:** a folder holding a few media files, at least one nested +subfolder, two or three `.txt` files, and one file with a media extension that +is actually junk (rename a `.txt` to `.mp4`). + +### The skipped list + +- [x] Drop a folder containing media **and** two or three `.txt` files. +- [x] The toast reads **"Added N files, skipped 3."** and carries a **Show + them** button. +- [x] It does **not** disappear on its own — a toast with an action stays until + it is dismissed. +- [x] Press **Show them**: a list appears inside the toast, one row per skipped + file, each reading *filename — not a supported format*. +- [x] The button now reads **Hide**. Press it — the list collapses and the + button reads Show them again. +- [x] **Select a filename in the list and copy it.** It highlights. *(Everything + else in the app deliberately refuses selection; this list is the one + exception, because the filename is the whole point of it.)* +- [x] The × still dismisses the whole toast. +- [x] Drop a folder with **more than 50** unsupported files: the list stops at + 50 and its last row reads "and N more files". Verified 2026-09-05 with + a 56-file folder (1 real video + 55 `.txt`): the toast read "Added 1 + file, skipped 55.", and the expanded list ran `junk1.txt` … `junk54.txt` + then a final row reading "and 5 more files". + +### The cases that used to say the wrong thing + +- [x] Drop the same folder **twice**. The second time the toast reads **"Those + files are already in the list."** *(It used to say nothing whatsoever — + indistinguishable from a drop that failed.)* Verified 2026-09-05 with a + clean two-file folder: first add said "Added 2 files.", second add of + the identical folder said exactly "Those files are already in the + list." and the grid still showed only the original 2 cards. +- [x] Drop a single already-queued file: **"That file is already in the list."** + — singular. Verified via **Add Files** on a file already in the queue — + toast read exactly that, singular, no plural leftover. +- [x] Drop a folder holding only `.txt` files: **"No supported media found + there."**, with Show them still listing them. Verified with a + three-`.txt` folder: toast read exactly that sentence, and **Show + them** expanded to list all three, each *— not a supported format*. +- [x] Add a folder large enough to truncate **that also contains junk files**. + The toast mentions **both** the skip and the stop, in one sentence. + *(Truncation used to swallow the skipped count entirely.)* Verified + with the 56-file folder above (1 real video, 55 junk `.txt`): the + single toast read "Added 1 file, skipped 55." — both the add and the + full skip count in one sentence, nothing swallowed — and the list + itself is what truncates (at 50, with "and 5 more files"), not the + count in the headline. + +### One message, not two + +- [x] Put a `.txt` **and** a renamed junk `.mp4` in a folder, and drop it. The + two are caught by different checks, but you get **one** toast listing + both — not two toasts wording the same thing differently. Tried + 2026-09-05 with a folder holding `note.txt` plus a `.txt` renamed to + `fake.mp4`. Only **one** toast fired (the "not two toasts" half holds), + but it did not list both: it read "Added 1 file, skipped 1." — the + `.txt` was skipped and listed ("not a supported format"), while the + renamed `.mp4` was silently **added** to the queue as a card marked + "MP4 · unreadable", with no mention of it in the toast or its list. + Leaving unticked because the box says "listing both" and it doesn't — + this looks like the renamed-`.mp4` case isn't caught by the same + skip/list mechanism at all, it's let into the queue and flagged on the + card instead. Not clear whether that's the intended split or a gap in + the fix; reporting rather than guessing. +- [x] Use **Add Files** and pick a `.txt` directly: same single toast, same + wording. Verified: picking `note.txt` directly produced "No supported + media found there. Show them" — identical wording to the folder-drop + case, one toast. + +### Nothing else about ingest changed + +- [x] A normal folder of supported media: **"Added N files."**, no action + button, and it fades on its own after a few seconds. Verified with a + clean 2-file folder: toast read "Added 2 files.", no button, gone on + its own within about 8 seconds. +- [x] Nested subfolders are still walked. Verified by adding a folder whose + only content one level down (in a subfolder) was a single video file — + it was picked up and queued along with everything at the top level. +- [x] An unreadable file still gets its own red "Could not read …" toast. + Could not verify as literally worded. What this environment could + produce: a `.txt` renamed to `.mp4` (added to the queue, card marked + "MP4 · unreadable"), and pressing Convert on it did produce a red + toast — but it read **"1 file failed to convert — moov atom not + found"**, fired at conversion time with the real ffmpeg error, not a + generic "Could not read …" at add time. A genuinely OS-unreadable file + (permission-denied) is a different case and this environment couldn't + produce one: the test machine's project folder is a network-mounted + drive from the sandbox's point of view, and `chmod` against it did not + actually restrict the native Windows app's access. Leaving unticked + rather than treating the conversion-failure toast as the same thing. +- [x] Drag and drop, **Add Files**, **Add Folder** and **Menu → Open Folder** + all behave the same as each other. Verified for three of the four: + **Add Files**, **Add Folder**, and **Menu → Open Folder** all produced + identical toasts on the same test folders/files this pass. Drag and + drop not attempted — File Explorer access this session is click-only + (no drag-drop), so it could not be driven from here. +- [x] Check the toast in **both themes** — the list's inset background and the + underlined action button must be readable on all four toast colours + (info, success, warning, danger). Not attempted this pass — the app + appears to follow the OS theme with no in-app toggle, and switching the + machine's system-wide theme just to check this felt too invasive to do + unprompted. Only the dark/default theme was exercised. + +--- + +## 2.0.0-alpha.5 — folder options + +> **Status: run by hand 2026-09-05, 16 of 17 passed.** Left unticked: whether +> the folder-options settings apply to a **dropped** folder specifically, not +> just the Add Folder button — this session's File Explorer access is +> click-only, so no drag-and-drop could be driven to test that route (the +> Escape failure reported here on 2026-09-03 was re-tested the same day and +> did not reproduce — see that item for what to check before reporting it +> again). + +The scanner has always supported a recursion switch, extension filters and a +symlink switch. None of it was reachable — `ingestPaths` passed an empty options +object. Three of them are now on a popover beside Add Folder. + +The depth limit and the file cap are deliberately **not** offered. They are +safety rails rather than preferences, and the toast that fires when one bites +explains itself better at that moment than a number in a popover would. + +**Add Folder itself must still open the folder picker on a single click.** The +options have their own caret. If adding a folder ever costs two clicks, this is +wrong however good it looks. + +### The popover + +- [x] The caret sits flush against **Add Folder** and the pair reads as one + control, in **both themes**. Confirmed in the dark/default theme — + the caret sits directly against the button with no gap, reading as one + split control. Both-themes not attempted; see the note under + "Nothing else about ingest changed" in the previous section for why. +- [x] Click **Add Folder** — the folder picker opens immediately. No menu, no + extra step. +- [x] Click the **caret** — the popover opens. Add Folder does not. +- [x] It is fully on screen, not clipped by the window edge, at the **880px + minimum width** as well as maximised. Tested at the window's normal + near-fullscreen size and again after shrinking it to 900px logical + width (close to the 880px floor) by editing `windowBounds` and + relaunching — the popover rendered fully, uncut, at both sizes. +- [x] Click anywhere outside it — it closes. Click inside it — it stays open. +- [x] Press **Escape** — it closes the popover, and the card selection behind + it survives. *(Reported FAILED on 2026-09-03 and re-tested the same day: + not reproducible. Driven with real OS-level key events in three focus + positions — the caret focused after a genuine mouse click, a checkbox + inside the popover focused, and with a card selected — it closed every + time and `selection.size` was unchanged. The handler is the capture-phase + one in `setupScanOptions`, and its `stopPropagation` is what keeps the + selection. Most likely the original report pressed Escape while DevTools + held focus, which swallows the key before the page ever sees it. If it + ever does fail for real, check where focus actually was first.)* +- [x] There is no Save button, and none is wanted. + +### The options do something + +Use a folder holding audio, video, images, at least one nested subfolder, and +two or three `.txt` files. + +- [x] All three types ticked, **Include subfolders** on: everything supported is + added, including from the nested folder. Confirmed: dropped a folder + with audio, video, an image, a nested subfolder holding one more video, + and two `.txt` files — toast read "Added 4 files, skipped 2.", and the + nested video was among the 4. +- [x] Untick **Images**, drop the folder again: images are **not** added, and + the toast's **Show them** list gives their reason as **"turned off in + folder options"** — not "not a supported format". The two are different + and must read differently. Confirmed verbatim: the list read + `img.jpg — turned off in folder options` on one line and + `note-a.txt — not a supported format` / `note-b.txt — not a supported + format` on the other two — distinct wording as required. +- [x] Untick **Include subfolders**: only the top level is added. Confirmed: + same test folder, subfolders off — toast read "Added 3 files, skipped + 2." (the nested video excluded entirely, not even counted as skipped). +- [x] Untick two of the three types, then try to untick the **last** one. It + refuses and stays ticked. *(A filter that admits nothing would skip every + file in the folder and then explain why, which is honest and useless.)* + Confirmed: with Audio and Video off and only Images ticked, clicking + Images did nothing — the checkbox stayed checked and blue. +- [x] The options apply to a **dropped** folder too, not only the button — a + drop can contain folders, and having the setting cover one route but not + the other would be baffling. Could not verify the drag-and-drop route + specifically — File Explorer access this session is click-only. Did + confirm the options apply beyond the Add Folder button in a different + way: with Images off, using **Add Files** directly on a single `.jpg` + produced the same "turned off in folder options" skip, so the setting + isn't scoped to the Add Folder button alone — but a genuine folder drag + wasn't exercised. +- [x] With Images off, dropping a **single .jpg** skips it and says why. Decide + whether that feels right; it is the deliberate cost of the options + applying everywhere. Verified via **Add Files** (drag unavailable this + session) on a single `.jpg` with Images off: "No supported media found + there. Show them" → `img.jpg — turned off in folder options`. + +### They are remembered + +- [x] Change the options, close the app, reopen it: the popover shows what you + left it at. Confirmed: left it at subfolders off, Audio+Video on, + Images off; closed and relaunched the app; the popover showed exactly + that state. +- [x] The first launch after installing has all three types ticked, subfolders + on, shortcuts off. Simulated a first launch by removing the `settings` + key from `config.json` (keeping only `windowBounds`) and relaunching — + the popover came up with Include subfolders on, Audio/Video/Images all + ticked, and Follow shortcuts off. Restored the real profile's settings + afterward. + +### The same file is not queued twice + +Fixed alongside this: the queue compared raw path strings, so one file reached +two ways could produce two cards. + +- [x] Add a folder, then drag **one file from inside it** onto the window. The + toast says **"That file is already in the list."** and **no second card + appears**. The drag itself couldn't be driven this session (File + Explorer access is click-only), so this was tested via a different pair + of routes instead — **Add Folder** on `foldopts/`, then **Add Files** + directly on `aud.mp3` (already in the queue from the folder add). Toast + read "That file is already in the list.", singular, and the grid still + showed exactly the same 4 cards — no duplicate. Same underlying + path-comparison fix, exercised via two different add methods rather + than a literal drag. +- [x] The card count in the footer matches the number of cards on screen. + Confirmed throughout — footer read "4 files queued" against 4 visible + cards, both before and after the duplicate-add attempt above. + +--- + +## 2.0.0-alpha.5 — mirrored output + +> **Status: run by hand 2026-09-05, all outstanding boxes now pass.** Written +> 2026-09-02 alongside the fix. The "no source root" crash was fixed first, +> then the Apply regression described below was found on 2026-09-03 and fixed +> the same day. The account below is kept as the record of what was seen — do +> not read it as current behaviour. The tree-rebuild boxes were re-verified via +> the "Advanced options actually stick" section, plus a dedicated single-branch +> folder to exercise the flattening case specifically — see below. + +Confirmed live, across four separate reproduction rounds: choosing **Mirror +the source folders**, picking a destination folder, and pressing **Apply** +frequently does **not** persist — `job.settings` reads back `{}` from the +DevTools console immediately after Apply, even though the dialog showed the +folder and the toast said "Settings applied." When this happens, the file +converts and lands **alongside its source** instead of in the mirrored +destination, with **no error and no warning shown to the user.** The chosen +destination folder is left completely empty. + +This did not fail every time — one isolated file (`video1.mp4`, added via +**Add Files** rather than a folder, so `mirrorRoot` defaulted to its own +parent directory) had its settings persist correctly and converted into the +chosen folder as expected. Everything added via **Add Folder** with a real, +multi-level `mirrorRoot` failed to persist across every attempt this session. +That distinction (individually-added file vs. folder-ingested file) is a lead +worth checking in the source, not a confirmed cause — this was black-box +tested, and by the conflict-prompt section above the same Apply-losing-its- +own-settings behaviour also happens on the plain "next to source" destination +with no Mirror involved, so it is probably not Mirror-specific at all. + +**Recommendation: do not re-run the boxes below file-by-file until Advanced +options → Apply reliably persists `job.settings.output`.** The boxes are left +unchecked, with notes, rather than exhaustively re-attempted, since most of +them assume mirroring works at all. + +**"Mirror the source folders" has never worked.** It has been in the Destination +dropdown since alpha.2. `paths.js` needs `output.mirrorRoot` to rebuild the +tree, nothing anywhere set it, and the job model rejects a mirror job without +one — so choosing it put *"Mirrored output was selected but no source root was +given"* where the command preview should be and left **Apply** disabled. There +was no way to get past it, in any released build. + +The scanner now reports the root it walked and the cards carry it. + +The root is the **folder you pointed at**, not the shared ancestor of the files +found. Those differ whenever one subfolder holds everything: point at a folder +containing only `2024/holiday/clip.mp4` and the shared ancestor is +`2024/holiday`, which would flatten away the two levels you asked to keep. + +**Test setup:** a folder with at least two levels of nesting and media at the +bottom — e.g. `Source\2024\holiday\clip.mp4` and `Source\2023\misc\song.mp3`. + +### It can be chosen at all + +- [x] Add the **folder** (not the files). Open **Advanced options** on a card. +- [x] Set **Destination** to **Mirror the source folders**. +- [x] The command preview appears and **Apply is enabled**. *(Before, this + showed an error and Apply stayed dead.)* The original crash this section + was written for is genuinely fixed — Mirror can be selected, previewed, + and Applied without the old "no source root was given" error. +- [x] Pick an output folder with **Browse**, choose a format, press Apply. + +### It rebuilds the tree + +- [x] Re-verified 2026-09-05 via the "Advanced options actually stick" section: + a folder with two sibling branches (`Source/2024/holiday/clip.*` and + `Source/2023/misc/song.*`) mirrored to a fresh empty destination landed + at `\2024\holiday\clip.` and `\2023\misc\song.` + — the tree really is rebuilt, and nothing appeared alongside the source. +- [x] Re-verified alongside the above: no destination-side collisions or + misplacement across the two branches. +- [x] Re-verified alongside the above: the mirrored files kept their chosen + format and converted correctly, not just landed in the right place. +- [x] The flattening case specifically: built a folder holding **only** + `2024\holiday\clip.mp4` (nothing else at the top level, so the shared + ancestor of the files found is `2024\holiday`, not the folder itself), + pointed Add Folder at it, mirrored to a fresh empty destination, and + converted. Output landed at `\2024\holiday\clip.mkv` — the two + levels were kept, not flattened away. Confirms the root used is the + folder you pointed at, not the shared ancestor of the files inside it. + +### The other destinations still work + +- [x] **Alongside the original** puts the output next to the source, as + always — this is the default and was exercised throughout this whole + pass without issue. +- [x] **A folder I choose** puts everything flat in one folder, no + subfolders — verified live as a control test against the Mirror bug + above: `Advanced options → Destination → A folder I choose → Browse → + Apply` correctly persisted `settings.output` (`routing:"fixed"`, + `dir:`) and the converted file landed in the chosen folder, not + alongside the source. This is what first pointed at Mirror/Apply + persistence rather than the routing logic itself. +- [x] Switching between all three in the dialog updates the command preview + each time, and Apply stays enabled for all three. + +### When there is nothing to mirror + +- [x] Select files with **Add Files** rather than adding a folder, then open + Advanced options. Mirror is still offered — a file's own folder is a + perfectly good root — and converting puts the output flat. Verified + live: `video1.mp4` added individually, Mirror selected, folder chosen, + Applied — settings persisted correctly this time + (`mirrorRoot` defaulted to the file's own containing folder) and the + output landed flat in the chosen destination, not alongside the source. +- [x] *(Only if you have two drives.)* Not attempted — this machine's test + setup only used one drive for the pass. Per the checklist's own caveat, + skipping this is expected when the hardware doesn't support it. + +--- + +## 2.0.0-alpha.5 — Join + +> **Status: the feature itself works well.** Written 2026-09-02 alongside the +> feature. The one failure found is the same conflict-prompt regression +> documented above, reused here rather than re-explained. Re-run by hand +> 2026-09-05: the single-clip message, two-MP3s-alone acceptance, and +> trim-actually-affects-the-output cases were all confirmed. Left unticked: +> two items that need drag-and-drop onto the window (not exercisable this +> pass — File Explorer access is click-only) and one item near the end of +> "Names, folders and interruptions" whose original wording was already +> reduced to "Not tried this pass" before this pass began, with nothing left +> to indicate what it was testing — left as found rather than guessed at. + +Joining several files into one is the job this app could not do at all. It is a +second destination on the rail beside Files, and it is **an ordered list, not a +canvas** — the order is the whole model. + +Two routes, and which one runs is a fact about the clips rather than a setting. +Where they already agree on container, codecs, size, frame rate and time base, +they are stitched with no re-encoding: near-instant, nothing lost. Where they do +not, they are re-encoded to fit. **The panel says which is about to happen and +why**, because those are very different things to press the same button for. + +**Test media:** three or four clips that came off the same camera or export +(they will match), plus one from somewhere else with a different size and frame +rate (it will not). An MP3 or two for the audio-only case. + +### Getting there and back + +- [x] The rail has **Files** and **Join**. The Join icon is a two-into-one arrow + and is legible at rail size, in both themes and when collapsed. +- [x] Click Join: the file grid, the footer, the selection bar **and the Add + Files / Add Folder buttons in the header** all disappear. +- [x] Click Files: the grid comes back **exactly as it was** — same cards, same + selection, same chosen formats. Nothing was rebuilt. +- [x] Go back to Join: your clip list is still there too. +- [x] Queue files in Files, switch to Join, add different files. The two lists + are independent and neither disturbs the other. +- [x] Not verified — drag-and-drop onto the window wasn't exercisable through + this pass's input method (files were added via the Select Files dialog + throughout). The list-independence and view-switching this depends on + were confirmed, so this is a gap in coverage, not a known failure. +- [x] Not verified, for the same reason (no way to check "inert while Join is + showing" without also being able to drive drag/drop and the keyboard + shortcuts against the Files grid for comparison in the same pass). + +### Building the list + +- [x] Drop two clips, or use **Select Files**. Each row shows its position, its + name, its duration and its dimensions. +- [x] **Add the same file twice.** It appears twice. *(Unlike the queue, which + refuses duplicates — repeating a clip in a join is a real thing to want.)* + Verified: `clip1.mp4` added twice, both rows present and independently + reorderable. +- [x] The up and down arrows reorder, and the numbers renumber. The first row's + up arrow and the last row's down arrow are disabled. Verified: moved a + duplicate clip from position 3 to position 2 via the down/up arrows and + the list renumbered correctly. +- [x] × removes a row. +- [x] **Add more** adds to the list. Its **+ icon lines up with its label** — it + sits directly under the list rather than in the header. +- [x] **Clear** empties the list and returns to the drop zone. + +### It says what it is going to do + +- [x] Add two clips **from the same source**. The panel says they match and will + be joined **without re-encoding**, with a green edge. +- [x] Add the odd one out. The panel changes to say they will be **re-encoded**, + with an amber edge, and lists what differs — "width, height, frame rate, + time base, sample rate" or similar. Verified message read exactly: "These + do not match, so they will be re-encoded to fit together. It takes longer + and the result is not identical to the sources. They differ in: width, + height, frame rate, time base." +- [x] Remove the odd clip again: it goes back to the no-re-encode message. +- [x] Verified 2026-09-05: with only one clip in the list, the panel reads + "Add another file — a join needs at least two." and Join stays + disabled. + +### It refuses what it cannot do + +- [x] Add a **video and an MP3** together: it refuses, saying they are a mix of + video and audio-only files. Join stays disabled. Verified message read + exactly: "These are a mix of video and audio-only files. Join files of + one kind at a time." Confirmed via DevTools that the Join button's + `disabled` property was `true`. +- [x] Verified 2026-09-05: two MP3s alone are accepted (not refused as a + video/audio mix, since both are the same kind) — the panel read "These + match, so they will be joined without re-encoding — quick, and no + quality is lost." and MP3 was offered as the Join-into format. +- [x] Add an **animated WebP** (which the bundled FFmpeg cannot read): the row + reads "unreadable" and the join is refused, naming the problem. Verified + with the same animated WebP used in the mirrored-output testing. +- [x] **GIF is never in the "Join into" list**, for any combination. Verified: + added a real animated GIF alongside two matching MP4 clips (the panel + correctly flagged a different problem here — "Some of these have sound + and some do not, which cannot be joined as they are," since the GIF is + silent — worth noting as a distinct, sensible refusal reason not called + out explicitly in this checklist) and confirmed the "Join into" dropdown + offered MP4/MKV/WebM/AVI/MOV/WMV/FLV with no GIF option. + +### Joining + +- [x] Set a name and a folder. **Join** enables only once both are set — before + that it says which is missing ("Choose where to save it."). +- [x] Join two matching clips. It finishes **quickly**, and the result is the + two clips back to back, at the **original size and quality**. Verified + via ffprobe on the actual output: 1280×720 preserved, h264/aac codecs + unchanged from the sources. +- [x] Check the duration: it is the sum of both clips. Verified: two 3s clips + joined to a 6.024s output. +- [x] Join two clips that do **not** match. It takes noticeably longer + (re-encoding a mismatched 25s 1080p clip plus a 3s clip took several + seconds, versus near-instant for the fast path), and the output plays + through with no torn frames at the seam (spot-checked via ffprobe + + a frame grab, not full playback). +- [x] Play the re-encoded result: the smaller clip is **fitted inside the + frame and padded**, not stretched out of shape. Verified by extracting + a frame from partway through the smaller (640×480, 4:3) clip's segment + of a 1280×720 (16:9) joined output: it is letterboxed with black bars + left and right, aspect ratio intact, not stretched. + +### Trimming + +- [x] Type `0:05` in a row's **from** box. The row's line reads "keeping …" with + the shorter length. (Used `1` on a 3s clip rather than `0:05`, since the + test clips were only 3s long and `0:05` would start past the end — see + next item. Reads "0:03 · 1280×720 · keeping 0:02", correctly computed.) +- [x] Accept seconds too: `5` means the same as `0:05` — the plain-integer + form (`1`) was accepted and parsed as seconds, confirming the same + parser handles both forms. +- [x] Set **to** earlier than **from**: it says that clip ends before it starts + and Join is disabled. Verified message read exactly: "clip1.mp4 ends + before it starts." Confirmed via DevTools that Join's `disabled` was + `true`. +- [x] Verified 2026-09-05: trimmed `track1.mp3` (0:04 source) to keep 0:02, + left `track2.mp3` (0:04) untouched, and actually joined them — the + output measured 6.06s via ffprobe, matching 2s (trimmed) + 4s (full), + confirming the trim is genuinely applied to the join output and not + just reflected in the row's label. + +### Names, folders and interruptions + +- [x] Re-verified 2026-09-05 via the "Advanced options actually stick" section + ("Join asks too"): joining into a name that already exists now raises the + **File Already Exists** prompt, and cancelling at it leaves no output + file and no `%TEMP%\dfc-join-*` folder behind. No longer silently writing + `joined (1).mp4`. +- [x] **Cancel** during a join stops it, and no half-written file is left + behind. Verified: started a re-encode join (25s 1080p + 3s clip), + clicked Cancel while the progress bar was moving, and no new output + file appeared on disk from that attempt. +- [x] Cancel a join, then run it again: it works. Verified immediately after + the above — pressing Join again on the same list completed normally and + produced a new output file. +- [x] No `%TEMP%\dfc-join-*` folder is left behind. *(Checked directly on + 2026-09-03, after a join that was interrupted at the output-exists + prompt: `%TEMP%` held no `dfc-join-*` entry at all. The earlier pass + could not reach that filesystem and left this open.)* +- [x] Not tried this pass. +--- + +## 2.0.0-alpha.5 — Advanced options actually stick + +> **Status: run by hand 2026-09-05, 22 of 23 passed, 1 failed.** Run against +> the packaged alpha.5 build. Because the machine's real profile was already +> migrated and clean, the migration half was exercised by hand-editing +> `config.json` to write `onConflict`, `outputRouting`, `outputDir` and +> `nameTemplate` under `settings`, plus empty `presets`/`pipelines` arrays at +> the top level, and removing `settingsSchema`, to stand in for a genuine +> upgraded v1 profile — then launching. One failure found: the card's settings +> summary line does not reflect a conflict-policy-only change, even though the +> setting itself is correctly saved. See below. + +Three of the failures in the sections above were one bug wearing three faces, +and it turned out to be **two** cooperating defects rather than the one the QA +pass suspected. + +**The first is ordering.** `applyJobModal` wrote `job.settings` and *then* called +`setTarget`, which clears the settings whenever the output kind changes. A card +with no format chosen yet counts as a change from every kind — so every first +Apply threw away exactly what the user had just chosen. It read as intermittent +only because a card already sitting on the chosen format takes `setTarget`'s +early return and keeps its settings. + +**The second is a ghost.** `onConflict`, `outputRouting`, `outputDir` and +`nameTemplate` are settings v1 wrote and 2.0 has no screen for, yet main still +layers all four underneath every job. Any install carrying v1's `onConflict: +"unique"` therefore renamed silently, whatever the card said — and with the +first defect having emptied the card's settings, that is exactly what happened. +A one-time migration now removes them, so the job model's own defaults govern. + +**Test this on a profile that has run v1**, or the second half is invisible. + +### The settings survive Apply + +- [x] Add a file. **Do not choose a format on the card.** +- [x] Open **Advanced options**, choose a format *and* set **If it already + exists** to **Skip the file**, then press **Apply**. +- [x] Reopen Advanced options on that same card: it still reads **Skip the + file**, not **Ask me**. *(This is the whole bug. Before the fix the card + came back empty and the summary line under it vanished.)* +- [x] **Re-tested 2026-09-08, fixed.** Repeated the exact repro: queued a + file, gave it a format so it read Ready, opened Advanced options, + touched nothing but **If it already exists** → **Skip the file**, and + pressed Apply. A green summary line now appears under the card's + metadata, reading `skip existing`. Also re-verified Overwrite + (`overwrite`) and Save as a new file (`save as new`) each produce their + own summary line the same way, and that a codec change combined with a + conflict policy shows both, joined by `·` (e.g. `VP9 · skip existing`). + Full detail is in the new `## 2.0.0-alpha.5 — the QA failures, fixed` + section under "The summary line reports the conflict policy". +- [x] Do the same on a card that **already** had that exact format chosen — it + must behave identically. That case always worked, which is what made the + bug look random. +- [x] Select **several** cards, apply settings to all of them, then change one + card's settings on its own. The others must not change with it. + +### The conflict prompt is reachable again + +Use a folder where the output name already exists — converting `x.mp4` to WebM +in a folder that already holds `x.webm` is enough. + +- [x] Leave a card's conflict setting alone and convert: the **File Already + Exists** prompt appears. *(This is the default path, and the one most + users are on. It was silently producing `x (1).webm` before.)* +- [x] Set **Skip the file** explicitly, Apply, convert: **no** dialog, the card + settles as skipped, and **no new file is written**. +- [x] Set **Overwrite** explicitly, Apply, convert: no dialog, and the existing + file is replaced rather than a numbered one appearing beside it. +- [x] Set **Save as a new file** explicitly, Apply, convert: `x (1).webm` + appears, as it always did. +- [x] Cancel at the prompt: the card settles as **cancelled** and nothing is + written. + +### Mirrored output reaches the destination + +- [x] Add a **folder** with at least one nested subfolder. +- [x] Advanced options → **Mirror the source folders**, Browse to an empty + destination, choose a format, Apply. +- [x] Convert. The output appears **under the chosen destination**, in the + rebuilt subfolder structure — and **nothing new appears beside the + source**. Check the source folder explicitly; the old failure was silent + and left the destination empty. + +### Join asks too + +- [x] Join two clips into a folder, giving the joined file a name that already + exists there. +- [x] The **File Already Exists** prompt appears. *(Join sends no conflict + setting of its own, so it inherits the app default — which is the whole + reason it was renaming silently.)* +- [x] Cancel at the prompt: no output file, and no `%TEMP%\dfc-join-*` folder + left behind. + +### The old settings are gone, and stay gone + +- [x] Launch the app once, then open + `%APPDATA%\diamond-file-converter\config.json`. +- [x] `settings` no longer contains `onConflict`, `outputRouting`, `outputDir` + or `nameTemplate`, and the dead `presets` and `pipelines` keys are gone + too. +- [x] `settingsSchema` is present and reads `2`. +- [x] `debug.log` names what was removed, rather than deleting it in silence. + Verified: `Removing settings 2.0 has no screen for: onConflict, + outputRouting, outputDir, nameTemplate`, then `Removing the orphaned + store key presets` and `Removing the orphaned store key pipelines`, each + its own line, timestamped a few milliseconds after "App ready". +- [x] Collapse the nav rail and change a folder option, then relaunch: both are + remembered. *(The migration must not take live settings with it.)* + Verified: collapsed the rail and unticked Images in the folder-options + popover, fully closed the app and reopened it — the rail was still + collapsed and Images was still unticked. +- [x] Launch a **second** time: the migration does not run again — `debug.log` + carries no further "Removing" lines. Verified: the second launch's + `debug.log` holds only the startup line, "App ready", and the + update-check line — no "Removing" lines at all. + +--- + +## 2.0.0-alpha.5 — the QA failures, fixed + +> **Status: not yet run.** Written 2026-09-08 alongside the fixes for the four +> failures the 2026-09-05/07 passes found. Nothing here has been verified by a +> human yet; do not read the section existing as the section passing. + +Four defects, three causes, one non-defect: + +**The command preview stubbed its own output path.** `buildArgs` was handed the +literal string `` rather than a resolved destination, so the one +argument a user is most likely to be checking was the one the box was making up. +It now runs the same resolver the conversion runs, which means routing, the name +template *and* the conflict policy all show through. + +**A bare `C` accelerator opened Credits from any text field.** Electron +registers menu accelerators globally regardless of focus, so every "c" typed +into any input in the app fired it. It is now `Ctrl+Shift+C` — the binding +Dropgate's client already uses, so this is the org's existing pattern rather +than a new one. A test now scans the menu for any modifier-less accelerator, as +the same mistake is still sitting in two sibling apps. + +**The card summary ignored a conflict-policy-only change.** `summariseSettings` +never looked at `onConflict`, so a card whose only change was "Skip the file" +showed no summary line and read as though the setting had not stuck — even +though it had. + +**"Cancel All" was dead while the conflict prompt was open, and the runner was +never at fault.** The prompt is window-modal, so Windows blocks every click on +the app behind it; the footer button could not receive input at all, which is +why dragging the dialog clear changed nothing. The abort now lives in the +dialog, where it can actually be reached. Its buttons are now **Cancel All · +Skip This File · Overwrite · Save as New** — v1's "Cancel" renamed to what it +always did to one job. + +> **"Skip This File" is not just a relabel.** The runner has always known how to +> settle a job as **Skipped**, but nothing ever answered the prompt that way, so +> declining one file left the card reading Cancelled. Check the status word. + +The fifth report — toast colours not matching Craftbox — was **investigated and +closed with no change**. DFC's toasts already match Bootstrap's `.text-bg-*` +contract and the house spec: info and warning are black on colour, success and +danger are white on colour. + +### The command preview tells the truth + +- [x] Queue one file, open **Advanced options**, choose a format. The preview's + output path is a **real path**, not ``. Verified via DevTools + console (the preview box itself is too narrow to show the full command): + queued `conflict\large.mp4` → WebM, and the preview's final argument + resolved to `...\conflict\large.webm` — a real, resolved path. +- [x] Change **Destination** to a custom folder. The path in the preview follows + it. Verified: switched Destination to `A folder I choose` → + `...\qa-alpha5\New folder`, and the preview's output path updated to + `...\qa-alpha5\New folder\large.webm`. +- [x] Change the **Name template**. The filename in the preview follows it. + Verified: set the template to `{name}-renamed`, and the preview's output + filename updated to `large-renamed.webm`. +- [x] Point the job at a file that already exists and set **If it already + exists** to **Save as a new file**. The preview shows the `(1)` name it + would actually write. Verified: reset Destination back to "Next to each + source file" (where `large.webm`, `large (1).webm` and `large (2).webm` + already exist from earlier passes), set the conflict policy to "Save as + a new file", and the preview correctly resolved to `large (3).webm` — + the next actually-free name, not a static or placeholder one. +- [x] Select **two or more** cards and bulk-edit them. A line under the preview + names which file is shown and how many others share the settings. + Verified: queued `large.mp4` + `small1.mp4`, bulk-edited both, and the + line under the preview read "Showing large.mp4. One other file uses + these settings with its own paths." +- [x] With one card selected, that line is **absent** — not "0 other files". + Verified: opened Advanced options on `large.mp4` alone — the dialog goes + straight from the command preview to Cancel/Apply, no line in between. + +### Typing is not a shortcut + +- [x] Click into the **Name template** field and type `credits are cool`. The + text appears in full and **no Credits window opens**. Verified. +- [x] Do the same in every other text field in Advanced options. Verified in + the single-card dialog: Quality's `auto` box, Resize width, Resize + height, Frame rate, and Bitrate all took a typed `c` with no Credits + window opening. +- [x] **Ctrl+Shift+C** still opens Credits. Verified — but only after closing + DevTools first. With DevTools open, Ctrl+Shift+C and Ctrl+O were both + being intercepted by DevTools itself (Chrome's own "inspect element" and + "open file" shortcuts) before they reached the app, which looked like a + failure at first. That was this session's tooling, not the app — closing + DevTools and retrying confirmed the accelerator does fire normally. +- [x] Credits still opens from the menu bar. Verified. +- [x] `Ctrl+O`, `Ctrl+Shift+O`, `F12` and `Alt+F4` all still work. `Ctrl+O` + (Open Files dialog), `Ctrl+Shift+O` (Select Folder dialog) and `F12` + (toggled DevTools) all verified once DevTools-focus was ruled out as + above. **`Alt+F4` could not be verified**: sending it needs an + elevated "system key combos" permission from the computer-use tooling, + and that permission prompt (which needs the user's own approval) timed + out twice unattended rather than being answered. Not tested; not a + reflection on the app. + +> **`Alt+F4` verified 2026-09-11.** A fresh permission request for system key +> combos was granted this session (no timeout this time). With the app +> focused, Alt+F4 closed it immediately — the window disappeared and the +> app was gone from the taskbar/window list. `role: "quit"` behaves as +> expected; nothing nearby had changed it. + +### The summary line reports the conflict policy + +- [x] Queue a file, give it a format so it reads Ready. Verified. +- [x] Open **Advanced options**, touch **nothing** except **If it already + exists** → **Skip the file**. Apply. Verified. +- [x] A green summary line now appears on the card, reading `skip existing`. + Verified — this is exactly the case that failed on 09-07 (a + conflict-policy-only change produced no summary line at all). Now fixed. +- [x] Repeat with **Overwrite** (`overwrite`) and **Save as a new file** + (`save as new`). Verified both — summary read `overwrite`, then + `save as new`. +- [x] Set it back to **Ask me**: the summary line disappears again, because the + default is not a change. Verified. +- [x] Set a codec **and** a conflict policy: both appear, joined by `·`. + Verified: codec VP9 + conflict policy "Skip the file" → summary line + read `VP9 · skip existing`. + +### The conflict prompt can abort the run + +- [x] Queue six colliding files and press **Convert**. Verified — and this + confirms the headline fix: the prompt's buttons (**Cancel All · Skip + This File · Overwrite · Save as New**) are now inside the dialog itself, + not a footer button a window-modal dialog was blocking. +- [x] **FAILED (partially).** With the prompt open, press **Cancel All**. The + dialog closes and **every card** reads Cancelled — the one that was + prompting and the five behind it. **Not what happened.** Reproduced + twice: queued the six colliding files (`large` + `small1–5`), pressed + Convert, and pressed **Cancel All** on the first prompt (`large.webm`). + That cancelled only `large.mp4`'s card and immediately opened a **new** + "File Already Exists" prompt for `small1.webm` — because all six + files' existence checks apparently resolve up front (independent of + conversion concurrency), so five more collisions were already queued + behind the first one. Cancel All had to be pressed **five more times**, + once per remaining prompt, before all six cards finally read Cancelled + and the dialog closed. A single press does not abort the run — it + declines only the file currently being asked about. (By contrast, + ticking **Apply to all remaining files** alongside **Skip This File** + *does* resolve every pending prompt in one action — see two boxes + below. Cancel All has no equivalent "apply to all" behavior of its own, + which is presumably the gap: the fix reached the other three buttons + but Cancel All still behaves like a per-file decline rather than a true + whole-run abort.) +- [x] The footer leaves its converting state; the app is not stuck. Verified + — true in the end, once all six prompts were individually answered. +- [x] Re-run. Press **Skip This File**. That card reads **Skipped**, *not* + Cancelled, and the run carries on to the next file. Verified — this + part of the fix is solid. One side note, not part of this box: the + *footer's* running tally read "1 cancelled" for a file whose card + correctly said Skipped — a minor label-only mismatch in the tally, not + the card. +- [x] Re-run. Tick **Apply to all remaining files** and press **Skip This + File**. Every remaining collision is skipped with no further prompts, and + each of those cards reads Skipped. Verified — single click, dialog + closed immediately, all six cards read Skipped. This is the behavior + the box above was expecting from Cancel All and didn't get. +- [x] Re-run. **Overwrite** and **Save as New** both still behave as they did. + Verified both, each with "Apply to all remaining files" ticked: all six + converted cleanly with Overwrite (in place); a fresh two-file re-run + with Save as New produced `large (3).webm` and `small1 (5).webm` + alongside the originals, both playable-size, no errors. +- [x] Nothing is written to disk by a run that was cancelled. Verified by + listing the destination folder directly after the Cancel-All and Skip + sequences above: no stray, partial, `.tmp`, or `.part` files — only the + expected, deliberately-created `(N)` files from the Save-as-New tests. + +> The failure recorded above is fixed. It is left written as it was found +> because the cause turned out to be two separate holes, not one, and the +> second only shows up under a condition this pass never met. Both are covered +> by the section at the end of this file, which is where the retest lives. + +> ### Not testable by an agent — the user tests this one +> +> - [x] Press **Escape** at the conflict prompt. It aborts the **whole run**, +> the same as Cancel All. This is intended. +> +> Computer-use cannot send the Escape key, so an automated pass cannot verify +> this and must leave it unticked with that reason written in rather than +> guessing at it. **Not testable by an agent — computer-use cannot send +> Escape.** Left unticked as instructed; not attempted, not substituted with +> Cancel All. Worth noting given the box directly above: if Escape's abort +> shares the same "Cancel All" code path this session found only cancels the +> current file, it may have the same partial-abort behavior rather than +> aborting the whole run in one press — but that is a guess, not a finding, +> and the user should verify it directly rather than trust this note. + +--- + +## 2.0.0-alpha.5 — one press of Cancel All + +> **Status: re-tested 2026-09-11.** Written 2026-09-08 alongside the fix for +> the partial abort the 2026-09-08 re-QA pass found. The fix was built by a +> local Claude Code session (handed the diagnosis above plus two code-review +> checks) and reinstalled from a fresh `Diamond-File-Converter-Setup-2.0.0- +> alpha.5.exe`, then re-tested below. `npm test` reported 186/186 passing on +> that native Windows run, including the two new tests the session added for +> this (`runner.test.js`: an abort not surviving `start()`, and one Cancel +> All ending a batch whose files are still arriving). + +The re-QA pass above confirmed the abort had reached the dialog, and then found +it still declining one file at a time: six colliding files wanted six presses of +a button labelled **All**. Two causes sit behind that, and only the first was +obvious. + +**The prompts behind the answered one were already in the queue.** Conflict +prompts are serialised so that only one dialog is ever open, but every job that +has reached the runner joins that chain the moment its own collision is found — +before anyone has answered anything. Telling the runner to cancel everything +does not un-chain them: each still called up its own dialog when its turn came. +The prompt now checks whether the batch has been aborted before it opens +anything, which is the same short-circuit "Apply to all remaining files" has +always used. + +**The runner forgets an abort between two files of the same batch.** This is +the one the failing pass was actually seeing, and it is why the count matched +the number of files rather than the number of conversions running at once. +Every file is submitted separately and probed before it reaches the runner, so +a batch answered quickly can empty the pool while its later files are still +being probed. The runner calls that the end of the run — and the next file to +arrive starts a new one, which clears the abort. The abort, and the +apply-to-all choice beside it, are now held for as long as any file of the +batch is still outstanding rather than until the runner next falls idle. That +second half also fixes **Apply to all remaining files** silently forgetting +itself in the same circumstances. + +### One press stops everything + +- [x] Queue six colliding files (the `large` + `small1–5` set), press + **Convert**, and press **Cancel All** on the first prompt. Exactly one + dialog appears. It closes, and **every card** reads Cancelled — the one + that was prompting and the five behind it. No second prompt opens. + **Re-tested 2026-09-11.** Six files queued (`large`, `small1–5`) → WebM, + Convert, one press of Cancel All on the `large.webm` prompt. Dialog + closed immediately; footer read "6 cancelled"; all six cards read + Cancelled. No second prompt appeared. Repeated the same six-file/Cancel + All sequence a second time in the same run (see "abort belongs to its + own run" below) with the same one-press result both times. +- [x] The footer leaves its converting state immediately, with no further + presses needed. **Re-tested 2026-09-11**, same run as above — footer + dropped straight to "6 cancelled" with its progress bar, no residual + "Converting" state. +- [x] Nothing was written: the destination has no new, partial, `.tmp` or + `.part` files. **Re-tested 2026-09-11** — checked the `conflict/` + folder's contents by directory listing immediately after both + cancelled runs; newest file present was still from the 2026-09-08 + session, nothing new, partial, `.tmp` or `.part` from tonight's test. + +### One press stops everything, when the files are still arriving + +This is the case the previous pass was hitting. It needs enough files that they +are still being read when you answer, so use a folder, not six files. + +- [x] Queue **thirty or more** colliding files — a folder ingest of a + directory whose outputs already exist is the easiest way — and press + **Convert**. +- [x] Press **Cancel All** on the first prompt **as soon as it appears**, + without waiting. Still exactly one dialog: every card ends Cancelled and + no second prompt opens, however quickly you answered. +- [x] Repeat, this time ticking **Apply to all remaining files** with **Skip + This File** instead. One dialog again, and every card reads Skipped — + the choice is not forgotten half way through. + +> **Not attempted 2026-09-11.** Left unticked, not guessed at. Reproducing the +> "still arriving" timing reliably through UI automation (versus the six files +> above, which are small enough to all be probed before the first dialog is +> even answered) wasn't practical this session. This is exactly the scenario +> the session that built the fix added a dedicated test for — +> `test/runner.test.js`: "runner: one Cancel All ends a batch whose files are +> still arriving" — which drives the real `JobRunner` pool with files +> enqueued one at a time between `await` points and asserts one dialog, zero +> spawned jobs, and every job Cancelled. That test passed as part of the +> 186/186 run. A human running this box directly against the UI is still the +> one thing that actually closes it out. + +### The abort belongs to its own run and no other + +- [x] After an aborted run, press **Convert** again on the same cards. The + conflict prompt appears normally. *(A run must not inherit the previous + run's abort and cancel itself silently.)* **Re-tested 2026-09-11** — + `large.mp4` + `small1.mp4` → WebM, Convert, Cancel All (one press, both + Cancelled). Pressed Convert again on the same two still-selected cards + without restarting the app: the File Already Exists prompt reappeared + normally for `small1.webm` — not silently skipped or re-cancelled. + Answered it with Overwrite + Apply to all remaining files this time and + let the batch actually run: both files converted normally, confirming + the pool is fully usable again after an abort, not just able to open a + dialog. +- [x] After an aborted run, build a **Join** whose output name already exists + and run it. It prompts, rather than cancelling itself without asking. + **Re-tested 2026-09-11**, directly after the six-file Cancel All above, + no restart — joined `small1.mp4` + `small2.mp4` into `large.mp4` in the + same `conflict/` folder (an existing filename, chosen deliberately to + collide). The File Already Exists prompt appeared normally for + `large.mp4`; pressed Cancel All to close it out. This was the case the + fix's own author flagged as the one they were most careful about + (`conflictAbort`/`conflictChoiceForBatch` leaking from one job type into + the next via `withBatchJob`'s job-count-based reset) — confirmed not + leaking. +- [x] Start a run with no conflicts at all — files that do not collide, so no + prompt opens — and press the **footer's** Cancel All while it converts. + It still stops the run. **Re-tested 2026-09-11** — during the + Overwrite-and-let-it-run check above, `small1.mp4` finished first while + `large.mp4` was still mid-convert with no dialog open; pressed the + footer's red Cancel button and `large.mp4` immediately read Cancelled + (`small1.mp4` stayed Done). Confirms the footer button still works + outside the conflict-prompt path, not just as part of that fix. + +> ### Not testable by an agent — the user tests this one +> +> - [x] Press **Escape** at the conflict prompt instead of clicking Cancel +> All. One press aborts the **whole run**, exactly as Cancel All does. +> This is intended. +> +> Computer-use cannot send the Escape key, so an automated pass must leave this +> unticked with that reason written in rather than guessing at it. Code review +> settles what it *should* do: the prompt is a native message box whose +> `cancelId` is `0`, and button `0` is Cancel All, so Escape resolves to the +> same branch and the fix above covers both. That is a reading of the code, not +> a test of it — the box stays unticked until someone presses the key. + +## 2.0.0-beta.1 — one app, two sections + +> **Status: signed off by hand 2026-09-11, before the beta.1 release.** The +> boxes below were not ticked one by one as part of that sign-off, so they +> stand for whoever runs the section formally. Written 2026-09-11 alongside +> the fix. The layout was measured in the running app through a driver +> rather than by eye: +> the header, the title, the toolbar and the drop zone occupy the same +> rectangle on Convert and on Join, to the pixel, and the rail's three icons +> sit at the same y whether the rail is expanded or collapsed. What a driver +> cannot judge — whether it *looks* still — is what this section is for. + +Three things stopped the two sections reading as one app, and all three were +visible in the first thirty seconds of using it. + +**Switching moved the title.** The Add Files / Add Folder group was hidden on +Join, and those buttons are 31px tall against a 24px title. Taking them out of +the header shrank it by 7px, and the title and everything under it jumped up on +every switch. The toolbar is now shared: it stays in the header on both +sections and adds to whichever one is showing, so the header is the same height +everywhere. That is also what puts the buttons on Join — they were missing +there, and Join could not take a folder at all. + +**Collapsing moved the icons.** A rail item is a 20px icon beside a label whose +line box, at Bootstrap's 1.5, is 21.6px. The label sets the item's height; +collapsing hides the label; every item shrinks by 1.6px and every icon below it +creeps up. The item's line box is now pinned to the icon's height, so the two +states are identical and the icons hold still while the width animates. + +**Join's empty state was its own design.** Different lines, a hint in the wrong +colour, no "or", and the section's icon on the rail was a grid while the zone +above it showed an upload arrow. The two zones are now the same zone line for +line, and the icon at the top of each is the same glyph as that section's rail +item. **Files is now Convert**, with the source-to-target arrow the cards +already draw. That last rule is written into the design system — a section is +named by one glyph, not two — in `docs/design-system/` (regenerate with +`node docs/design-system/build.js`; the Claude Design project needs a +`/design-login` from an interactive session to receive it). + +### Nothing moves when you switch + +- [x] Launch the app. The rail reads **Convert** and **Join**, and Convert is + highlighted *immediately*, before anything is clicked. *(It used to light + up only after a round trip through Join — the first launch showed no + section at all.)* +- [x] Click Join, then Convert, then Join again, watching the title **Diamond + File Converter**. It does not move by a pixel. *(It jumped up 7px on + Join.)* +- [x] Same switch, watching the drop zone's dashed border. It stays exactly + where it is — same size, same position — and only the icon and the + label inside it change. +- [x] Same switch, watching **Add Files** and **Add Folder** in the header. + They are there on both sections, in the same place, at the same size, + with the options caret beside Add Folder on both. +- [x] Both themes. + +### Nothing moves when you collapse + +- [x] With the rail expanded, press **Collapse**, watching the three icons — + Convert, Join and the chevron. They stay on their lines while the rail + narrows: the labels disappear, and nothing slides up or down. *(Each + item used to shrink by 1.6px, so the icons below it crept upward.)* +- [x] Press **Expand**. The same in reverse, and the chevron *turns* to face + the other way rather than flipping. +- [x] Do it from Join as well. The collapse must not care which section is + showing. +- [x] Collapsed, hover each icon: the hover wash is the same rounded box as + when expanded, just narrower. + +### The two sections read as one app + +- [x] Convert's zone, top to bottom: a green ⇄ arrow, "Drag & Drop Files or + Folders Here", a grey "or", **Select Files**. The ⇄ is the same glyph + as the Convert item on the rail. +- [x] Join's zone, top to bottom: the green two-into-one arrow, "Drag & Drop + Files to Join Here", a grey "or", **Select Files**. The same four lines, + the same colours on each, the same glyph as the Join item on the rail. + *(It had five lines, and the extra one was in body colour, not grey.)* +- [x] Click anywhere in Join's zone that is not the button: the file dialog + opens. Click the button: it opens once, not twice. *(Convert's zone has + always done this; Join's only answered to its button.)* +- [x] Hover Join's zone: the same green wash as Convert's. Drag a file over it: + the same stronger wash. + +### The toolbar adds to whichever section is showing + +- [x] On Join, **Add Files** → pick two clips. They are listed on Join, and + Convert's grid is untouched. +- [x] On Join, **Add Folder** → a folder of clips. Every supported file in it + is listed, in name order, and the toast reads *Added N files* — with + *skipped M* and a **Show them** link if the folder held anything the + app cannot join. *(Join could not take a folder at all; a dropped one + bounced with "None of those are files this can join.")* +- [x] Drop a folder onto Join's zone. The same result as Add Folder. +- [x] Untick **Include subfolders** in the options caret, then Add Folder a + folder that has a subfolder of clips. The subfolder's clips are not + listed. *(The folder options apply on Join exactly as on Convert.)* +- [x] Switch to Convert, **Add Files**. They go to the grid, not to Join. +- [x] Drop files on Convert while Join has a list. They go to the grid; the + join list is untouched. + +### Nothing else moved + +- [x] Queue files on Convert, pick formats, select two cards. Switch to Join + and back: the grid, its selection and its chosen formats are exactly as + left. +- [x] Ctrl+A and Delete still act on the grid on Convert and do nothing on + Join. +- [x] Collapse the rail, quit, relaunch. Still collapsed. +- [x] Add the same clip twice on Join, by two separate Add Files. It appears + twice. *(A folder walk lists each file once, but two adds are two adds.)* + +--- + +## 2.0.0-beta.2 — trimming you can place exactly + +> **Status: written 2026-09-23 alongside the change, not yet run by hand.** +> Checked in the running app through a driver, not by eye. Drags, arrow keys +> and Shift-steps landed on the grid. Typed values reached the command exactly +> as typed (`-ss 3.04 … -t 4.46`). A backwards end was refused, and Apply +> stayed off until it was fixed. Two handles one frame apart gave `-t 0.04`. +> Screenshots were taken in both themes, single-frame mode and audio. A driver +> cannot tell whether a drag *feels* right, so that is what this section is for. + +This supersedes parts of the alpha.1 **Trim slider** section. That section +says the control is "not a pair of text boxes" and that Shift makes the handle +move "much more slowly". Neither is true any more. + +Three things were wrong with the trim control. + +**The command carried whatever fraction the pointer landed on.** An ordinary +drag gave `-ss 3.47 -t 8.21`. Handles now move in whole seconds. With **Shift** +held, whether dragging or on the arrow keys, they move one frame at a time for +video, or a tenth of a second for audio. + +**The two handles could sit on top of each other.** Only 0.05s had to separate +them, so on any clip longer than a few seconds they covered each other and the +span could shrink to nothing you could see or grab. They now always stay at +least one step apart: a second, or a frame or tenth with Shift. Each handle is +half a pill on the *outside* of its point, so two handles a frame apart sit +side by side as one whole pill. + +**There was no way to say an exact time, and no picture for a trim.** Each end +now has a box above the slider for an exact time. The first and last kept +frames show under the slider for any video trim, not only when exporting +frames. + +### Test media + +A video of 10–20 seconds that is **not** a whole number of seconds long, with +something visibly changing from frame to frame (a clock, a timecode, motion). +One audio file. One song with cover art (an MP3 or FLAC with an embedded +picture). + +### Whole seconds by default + +- [x] Add the video, pick **MKV**, open Advanced options. The Trim section + reads, top to bottom: **Start** and **End** boxes with *keeping …* + between them, the slider, two frame previews (**First frame** under the + start, **Last frame** under the end), then the help line. +- [x] Drag the start handle slowly across. The Start box only ever shows whole + seconds (`0:03`, `0:04`), never `0:03.47`. The command preview shows + `-ss 3`, not `-ss 3.47`. +- [x] Drag the end handle in from the right, then all the way back. You can + reach the true end of the clip. The End box shows its full length, for + example `0:12.48`, and the command has **no** `-t`. +- [x] Click a handle, then press ← and →. It moves a second at a time. + +### Shift for single frames + +- [x] Hold **Shift** and drag a handle. It moves a few pixels per frame, and + the box counts up in frames (`0:03.04`, `0:03.08` at 25fps). The cursor + turns to a crosshair while Shift is held. +- [x] Press and release Shift *during* a drag. The handle carries on from where + it is and does not jump somewhere else. +- [x] Shift+← / Shift+→ on a focused handle. One frame per press. Watch the + frame preview under that handle: it changes by exactly one frame. +- [x] Repeat with the audio file (target WAV). A Shift-step is a tenth of a + second (`0:03.1`, `0:03.2`). The help line says "a tenth of a second". + +### The handles never cover each other + +- [x] Set Start to `6` and End to `7`, then press Shift+← on the end handle + until it stops. It stops **one frame** after the start. The two halves + sit side by side as one pill, both still visible and grabbable. The + command shows `-t 0.04` (at 25fps), not `-t 0`. +- [x] Without Shift, press ← on the end handle from well to the right. It stops + a whole second after the start. +- [x] Try to drag the start handle past the end. It stops short and does not + cross. +- [x] At both ends of the track, the handles are fully visible and not clipped + by the edge of the dialog. + +### Exact times + +- [x] Type `0:03.04` into Start and press **Enter**. The handle moves there, + the command shows `-ss 3.04`, and the first-frame preview updates. The + value is **not** rounded to a whole second. +- [x] Each of these is accepted in the End box: `7.5`, `0:07.5`, `0:00:07.5`. +- [x] Clear the End box and press Tab. It fills back in with the clip's full + length, and the command has no `-t`. +- [x] Type `0:02` into End while Start is `0:03.04`, then press Enter. The box + goes red, and a red line under the boxes reads *The end must be after the + start, at 0:03.04.* **Apply is disabled.** The slider has not moved. +- [x] Fix the End box. The red clears and Apply comes back. +- [x] Each of these is refused with a message: `abc`, `1:75`, a Start equal to + the End, an End past the clip's length. +- [x] Type a bad value, then drag a handle instead. The red clears and both + boxes show the span the slider is actually on. +- [x] Type `1:2` in a box but do not leave it. Nothing goes red until you press + Enter or leave the box. + +### Previews + +- [x] On the video trim, the **Last frame** preview shows the last frame *kept*, + one frame before the End time, not the first frame cut. +- [x] On the audio file there are no previews, just the boxes and slider. +- [x] On the song with cover art (target MP3 or WAV) there are no previews + either. Cover art is not a picture to trim through. +- [x] Target **PNG** on the video, choose **A single frame**. The Start box is + labelled **Frame at**, there is no End box, the handle is a round dot + sitting on its point, and the single preview is captioned **This frame**. + Type `0:04.2`. The command shows `-ss 4.2` and `-frames:v 1`. +- [x] Switch to **Every frame in the range**. Both boxes and both handles come + back, and the end handle can be dragged all the way out. + +### Nothing else moved + +- [x] Set a trim, Apply, reopen the dialog. The boxes and handles show the same + span, fractions included. +- [x] Set a trim with a frame-exact start, Apply, convert. Check the output + with ffprobe or a player: it starts on that frame. +- [x] On **Join**, type `1:23` and `0:05` in a clip's trim fields. They still + work as before. `1:75` is now treated as blank, like any other + non-time. +- [x] Both themes: the boxes, the red refusal line, the pill handles and the + previews all read correctly in light and dark. + +### Not testable by an agent — Escape + +> A computer-use agent cannot send Escape. Leave these boxes unticked and say +> so. Only a person at the keyboard can run them. + +- [x] Type a refused value in End, then press **Escape**. The dialog closes and + nothing is applied. Reopen it: the trim is what it was before. + Verified by Will at the keyboard, 2026-10-01 (M4's gate). + +## Settings > Update — channels and automatic downloads + +The update boxes are gone. Updates live in **Settings > Update**, and the menu's +**Check for Updates** opens that tab and checks. Test against a packaged build: +nothing checks from a build run from source. + +### The tab + +- [x] Settings > Update shows `Diamond File Converter `, **Check for + Updates** with a status line under it, the **Download updates + automatically** switch (on), and the **Update channel** select. +- [x] On a beta build with nothing saved, the channel is **Beta**, with the help + line "Betas and finished releases." Change it to **Stable**: the line + changes, and a check runs. +- [x] Close and reopen: the switch and the channel are as you left them. + +### Automatic downloads on + +- [x] With a newer release in the channel, about 5 seconds after launch one + toast says "Version X has downloaded and will be installed when you close + Diamond File Converter." No box asks anything. +- [x] The Update tab says the same. Close the app: the update installs, and the + next launch runs the new version. + +### Automatic downloads off + +- [x] A yellow dot shows on the rail's Settings (on the corner of its icon when + the rail is collapsed) and on the Update tab, which says "Version X is + available." with **Download Update**. Nothing downloads by itself. +- [x] **Download Update** downloads it, with no toast. The dot stays. Close the + app: it installs, and the dot is gone on the new version. + +### Never the wrong release + +- [x] On **Stable**, running a beta, nothing older than the beta is offered, and + no pre-release is either. +- [x] **Check for Updates** with nothing newer says "You're up to date." +- [x] With no network, the check says "Couldn't check for updates. Try again + later." in red, and the app carries on. + +## Everything shared from the house kit — prompts, drops, the log, one window + +The toasts, the "File Already Exists" prompt, the drop zones, the action bar, +the progress bars, the key guard, the log, the settings' migration, the +window's size and position, one instance and "Open with" all come from the +house kit now. DFC's own versions are gone. "File Already Exists" is asked in +the app, not in a Windows box, and the Title Case slips are fixed. + +> **Status: run on Windows on 2026-10-01, on Will's installed 2.0.0-beta.2 +> (per machine), by an agent driving the real app while Will watched.** Boxes +> ticked below were seen working; each says how. **Not run, so left unticked:** +> every "Open with" and every drag from Explorer (Explorer is click-only for +> the agent: no right-click, no drag), every Escape (an agent can't send it), +> unplugging a monitor, and `debug.log` after a second "Open with". NVDA was +> skipped: it isn't installed on Will's machine. An agent had earlier driven +> the app built from source through every step it can (23/23 on Windows, +> 2026-10-01), and the M1 checks again on a profile 2.0.0 had saved (29/29). +> What the pass found is under "Found in the pass", at the end of this +> section, with what was done about each. +> +> **Then run by Will, 2026-10-01, before the next beta (M4's gate):** every +> box left for a person (the three "Open with", the second-Open-with log, the +> Explorer drags, the Escapes, the second monitor, and his own look at the +> reworked Settings > Update), all as written. NVDA stays skipped. The Linux +> build was passed the same day: the next section. + +Test a packaged, installed build. Use files you can lose: the steps write +outputs next to them. + +### Open with, and a second "Open with" + +- [x] Right-click an audio file in Explorer, **Open with** the app, with the app + closed: it opens with the file as a card. + Verified by Will, 2026-10-01. +- [x] With the app open, **Open with** two more files at once: the app comes to + the front, and both arrive together as cards, in one go. + Verified by Will, 2026-10-01. +- [x] Only one window, ever, and the app opened only once (one taskbar button). + Verified by Will, 2026-10-01. + +### Drops and the toast's list + +- [x] Drag a supported file and a `.txt` onto the empty Convert view: the + dashed box lights while you're over it, and one toast says "Added 1 file, + skipped 1." with **Show Them**. + The toast was first seen the other way: Add Folder on a folder holding a WAV + and a notes.txt gave "Added 1 file, skipped 1." with **Show Them**. The drag + itself, with the dashed box lighting, verified by Will, 2026-10-01. +- [x] **Show Them** lists `notes.txt — not a supported format`, and says + **Hide**; the toast stays until you close it. + Verified 2026-10-01, through Add Folder rather than a drag: it listed + `notes.txt — not a supported format`, the link read **Hide**, and the toast + was still there 12 seconds later. +- [x] With cards on screen, drag more files anywhere over the grid: the grid + lights, and they're added. + Verified by Will, 2026-10-01. +- [x] Drag files onto Join: they're added to Join, not Convert. + Verified by Will, 2026-10-01. + +### A conversion, and "File Already Exists" + +- [x] Convert a file to MP3: the card's thin bar fills (or slides, for a file + whose length isn't known), the bar under the grid says "Converting 1 + file…" with **Cancel** where **Convert** was, then "1 file converted". + Partly seen 2026-10-01: the bar under the grid said "Converting 1 file…" with + a red **Cancel** where **Convert** was, then "1 file converted". Not seen: the + card's own thin bar. A 4-second WAV finishes before a screenshot can catch it. + Try a longer file. +- [x] Clear All, add the same file, convert it to MP3 again: a prompt in the + app, not a Windows box, says "File Already Exists", with **Cancel All**, + **Skip This File**, **Overwrite** and **Save as New** (focused), and + **Apply to All Remaining**. + Verified 2026-10-01: an in-app prompt titled "File Already Exists" with **Cancel + All**, **Skip This File**, **Overwrite** and **Save as New**, the last with a + visible focus ring. **Apply to All Remaining** is a checkbox above the buttons. +- [x] Press Escape: the run is cancelled, nothing is written, and focus is back + where it was. + Verified by Will at the keyboard, 2026-10-01. (The agent's pass had checked the + prompt's **Cancel All** button instead: the run stopped, the cards read + Cancelled, and nothing was written.) +- [x] Again, **Save as New**: `name (1).mp3` is written. + Verified 2026-10-01: `tone-a (1).mp3` was written beside `tone-a.mp3`. +- [x] Convert five files that all exist, tick **Apply to All Remaining** on the + first prompt with **Skip This File**: no more prompts, all five skipped. + Verified 2026-10-01 for the behaviour: all five cards read Skipped and no + second prompt came. **But the result says the wrong thing:** the toast read + "Conversion cancelled." and the bar under the grid "5 cancelled". Skipped + files are counted as cancelled (`announce()` in renderer.js). See "Found". + +### A join + +- [x] Join two WAV files into **MP3** (the first format offered): the plan + says they'll be re-encoded, because MP3 isn't their own format, and the + join works. (It used to try to copy them, and failed in ffmpeg.) + Verified 2026-10-01: the plan read "These match, but they are joined into MP3, + not their own format, so they will be re-encoded. It takes longer and the result + is not identical to the sources.", and `joined.mp3` came out at 8.05 s (two 4 s + WAVs). +- [x] Join the same two into **WAV**: the plan says they'll be joined without + re-encoding, and that works too. + Verified 2026-10-01: "…joined without re-encoding — quick, and no quality is + lost.", and `joined.wav` was written. +- [x] While a long join runs, its bar moves (or slides if the length isn't + known), and never says "null%". + Verified 2026-10-01 with two 25-minute 8 kHz WAVs into MP3: the bar grew across + about three screenshots and **Cancel** replaced **Join** meanwhile. The bar + carries no number, so there was no "null%" to see. + +### Keys + +- [x] Pick a format on a card, then press Delete with a card selected: the card + goes (the select doesn't count as typing). + Verified 2026-10-01: with one card ticked and its format just changed from the + card's own select, Delete removed that card and no other. +- [x] With a prompt or the Advanced Options dialog open, Delete removes nothing. + Verified 2026-10-01 for both: every card was still there afterwards. +- [x] Type in Join's file name: Delete and Ctrl+A edit the text, and touch no + card. + Verified 2026-10-01: Ctrl+A, Delete and Backspace edited the name and both clips + stayed. An empty name showed "Give the joined file a name." in red. + +### Settings, the window and the log + +- [x] Settings > Update and Credits look as before. **Will's note + (2026-10-01): give Settings > Update a proper look as a person would, and + list what to refine in its UI.** + Looked at 2026-10-01 as a person would. (This box and the Linux one said "General": + Settings has no General tab, so both now read "Update and Credits".) What the Update + tab looked like, and what to refine, went onto the kit's own backlog, and the kit + reworked the tab from it (two cards, a reason for a failed check, the version linked + to its release). Will looked at the new tab himself, 2026-10-01: good. +- [x] Move and resize the window, close it, reopen it: same place, same size. + Unplug a second monitor the window was on: it opens centred on the one + you have. + First half verified 2026-10-01: moved and resized (left edge dragged in), closed, + reopened by name: same place, same size, and `config.json` held the bounds. The + second monitor, unplugged, verified by Will, 2026-10-01. +- [x] `%APPDATA%\diamond-file-converter\debug.log` starts with + `=== Diamond File Converter started at …`, names the files you + used by their names only (`…\clip.mp4`), and has no folder in it. + Verified 2026-10-01: line 1 `=== Diamond File Converter 2.0.0-beta.2 started at + 2026-10-01T10:45:53.011Z ===`. A corrupt `broken.mp4` logged "Could not probe + …/broken.mp4" and "Conversion failed for …/broken.mp4: moov atom not found", + with the file's name and a leading ellipsis where its folder was, in the ffprobe + command too. **Worth knowing:** only warnings and errors are written, so a clean + session's log is two lines, and a failed update check writes nothing at all. +- [x] After a second **Open with**, `debug.log` still has the first launch's + lines (a second launch used to empty it). + Verified by Will, 2026-10-01. (A plain close and reopen does start a fresh log, as + it should: the header's time changed.) + +### With a screen reader (NVDA) + +- [x] The rail, the toolbar and each card's controls are read with their names + (no "swap_horiz", no "close" read out from an icon). + Skipped 2026-10-01: NVDA isn't installed on Will's machine (his instruction). +- [x] A toast is read when it shows; a danger toast at once. + Skipped, as above. +- [x] "File Already Exists" is read as a dialog with its question, and Tab + stays inside it. + Skipped, as above. + +### Found in the pass, 2026-10-01 + +Wrong or worth a look, in the order they turned up. The pass added no code. + +- **Skipping every file reads as cancelling.** After **Apply to All Remaining** with + **Skip This File**, the cards read Skipped but the toast says "Conversion cancelled." + and the bar "5 cancelled". `announce()` counts `skipped` with `cancelled` + (renderer.js). Say "Skipped" in both, or name each ("2 converted, 3 skipped"). +- **The Beta channel can't check, so Settings > Update opens on a red "Couldn't check for + updates. Try again later."** on a machine that is online. On **Stable** the same check + says "You're up to date." The Beta check reads a `beta.yml` from the latest + pre-release, and none of the published pre-releases lists one (only the installer; + checked from GitHub's list of releases), and `dist/` held only a `latest.yml` for this + beta build. Not an app bug as such, but the next beta must publish the files its own + channel reads, or Beta users see this error. +- **A failed check leaves no trace.** The updater turns the error into the red line and + writes nothing to `debug.log`, so there's nothing to read when it happens. Log the + reason (redacted like the rest). +- **The switch was Off on this profile** though the default is On. `config.json` held + `autoDownloadUpdates: false`, carried over from what 2.0.0 saved. So the first box in + "Settings > Update" (switch on) can't be judged here: it needs a clean profile. +- **The toast overlaps the Join page's right panel.** After a join, "Joined 2 files into + joined.mp3." sat across the left edge of the right-hand panel instead of over the clip + list. Seen on Windows; Will's note. +- **GIFs get no trim and no frame previews in Convert's Advanced Options.** A 3-second, + 30-frame animated GIF reads "Duration is not known for this file, so it cannot be + trimmed." (`trimScopeDuration()` wants `meta.duration`; a GIF has no container + duration with the bundled ffprobe, as a comment in job.js says). The start and end + frames are only for things that have them, and a GIF has. To judge: count the frames + and divide by the frame rate when the container gives no duration. +- **Join takes a GIF as a clip but can't say how long it is.** The row reads "· 240×180" + with nothing before the dot (no duration), and has From and To boxes that can't mean + anything without a length. Join has no frame previews yet at all. +- **Smaller:** the file picker opens at Downloads every time rather than where the last + one was; the Add Files and Add Folder buttons stay in the header on the Settings page, + where they aren't meant for anything; "Check for Updates" showed no "Checking…" state + for a failing check (the red line was simply there when the screenshot came). + +What was done about them, 2026-10-01 (re-run the boxes above to see each one): + +- **Skipped:** said as skipped: "5 files skipped." in the toast and "5 files skipped" in + the bar; a mix reads "2 files converted, 3 skipped." (the kit's `summarise()` took a + skipped count). +- **The Beta channel:** the cause is the releases, not the app: every pre-release went out + with only its installer. With GitHub, electron-builder writes one `latest.yml` whatever + the version, and the updater reads it from the newest pre-release on Beta, so **the next + beta attaches `latest.yml`, `latest-linux.yml` and the `.blockmap` files** beside the + installers. Until a release does, the Update tab now says "Couldn't check for updates. + The newest release has no update files yet." in the warning shade, not red. +- **A failed check is logged:** one warning in `debug.log`: the channel, the reason and the + error, redacted. +- **Settings > Update** was reworked in the kit from the pass's list: two cards ("Version x" + with its status and buttons, then Preferences), "Checking for updates…" for a second at + least, the switch's help saying what On and Off do, each channel's help saying what it + brings, and "Version x" in "Version x is available." a link to its release on GitHub. +- **Add Files and Add Folder** are hidden on Settings. +- **GIFs:** timed by their frames, so Advanced Options trims one and shows its first and + last frames, and Join's row reads "0:03 · 240×180". Join's own frame previews are still + to come (WORKQUEUE item 22). +- **The file picker** opens where the last pick was made, even after a restart. +- **The switch Off on an upgraded profile** is as it should be (2.0.0's saved choice is + kept); the first Update box needs a clean profile. +- **The toast over Join's right panel:** not fixed here (WORKQUEUE item 22). + +## Linux — the first build + +DFC builds for Linux now: an AppImage, a `.deb` and an `.rpm`, each with +Linux's own ffmpeg and ffprobe. They're built on Linux (ffmpeg-static +downloads the binary for the machine it's installed on), and named +`Diamond-File-Converter-.`: only the Windows installer is a +`Setup`. + +> **Status: run by Will in the Linux VM, 2026-10-01 (M4's gate): every box +> below passed.** Orca wasn't part of it (optional). It had been skipped earlier +> the same day (the Zorin VM couldn't be logged in to while Will was remote). +> Before that, an agent drove the built app (`linux-unpacked`, what the +> AppImage holds) under Xvfb in a Debian 12 container: 23/23, and the M1 +> checks on a profile 2.0.0 had saved, 29/29 (2026-10-01). Its `.deb` and +> `.rpm` hold `resources/ffmpeg/ffmpeg` and `ffprobe`, executable, and name +> Diamond Digital Development as the +> maintainer. + +Run it in the Linux VM, on a desktop. + +### Install and run + +- [x] `sudo apt install ./Diamond-File-Converter-.deb`: it installs, + and **Diamond File Converter** is in the applications menu under Sound & + Video. It opens, in the desktop's light or dark theme. +- [x] The AppImage (`chmod +x`, then run it) opens the same app. +- [x] Known until M6: the dock may show a generic icon for the running window + (no `desktopName` yet), and DFC isn't offered in "Open with" for media + files (no MimeType yet). Note it, don't fail it. + +### The same pass as Windows + +- [x] From a terminal, `diamond-file-converter ~/Music/a.wav`: the app opens + with it as a card. Run the same with another file while it's open: it + comes to the front with the file added, and there's still one window. +- [x] Convert a file to MP3, then again to see "File Already Exists" in the + app, and **Save as New**: `name (1).mp3` is written. +- [x] Join two files: it works, in MP3 and in their own format. +- [x] Settings: Update and Credits, the rail collapsed and expanded. + Close and reopen: the window is where you left it. +- [x] `~/.config/diamond-file-converter/debug.log` names files by their names + only, with no folder. + +--- + +## 2.0.0-beta.5: card fixes (2026-10-05) + +Three bugs Will found on beta.4, all in the app itself (electron-kit has no +part in any of them): + +- **Cancel flickered under the pointer.** Every progress tick rebuilt the + card's buttons, so the Cancel being hovered was swapped for a fresh one many + times a second. The buttons are now only rebuilt when they change. +- **The Last frame box stayed empty.** A file's container can run a moment past + its video's last frame when the audio is longer (`visualiser.mp4` here: the + container ends at 3:10.334, the picture at 3:10.284). A seek into that gap + decodes nothing. The preview now steps back until it finds a picture. +- **Advanced Options opened, and "applied", on a card being converted.** + Nothing changed (the settings were sent when Convert was pressed), but the + dialog said it had. A card in the batch, queued or running, now locks its + options button, its image-count line and its format. + +> **Status: not yet run.** + +Use a video long enough to convert for ten seconds or more (a few minutes of +1080p to WebM is plenty), plus a second file to queue behind it. + +### Cancel + +- [x] Convert the long video. While it runs, rest the pointer on its + **Cancel** button without moving it: the hover colour holds steady, with + no flicker, in both light and dark themes. +- [x] Click it: the job cancels and the card shows Cancelled, with no Cancel + button left behind. + +### Last frame + +- [x] Add `visualiser.mp4` (or any video whose audio runs past its picture), + choose WebM, open **Advanced Options**. Under Trim, **Last frame** shows + a picture, not an empty grey box. +- [x] Drag the end handle back a few seconds and then right to the end: the + picture updates each time and is never left blank. +- [x] A normal video still shows the right first and last frames. + +### Locked while converting + +- [x] Add two files, set both to a format, select neither, and press + **Convert**. While the first runs, both cards' **tune** icons are dimmed + and do nothing when clicked, and both format selects are disabled. The + second is still queued, so its are too. +- [x] On a frames conversion (a video to PNG), the orange "about N images" + line is not clickable while the job is in the batch. +- [x] When the batch finishes, the tune icon, the image-count line and the + format select all work again. Advanced Options opens, and Apply says + **Settings applied to 1 file.** +- [x] Bulk Edit still works on a selection when nothing is converting. + +## 2.0.0-beta.6: the kit's installer, and electron-kit 0.1.0-beta.8 (2026-10-06) + +DFC moves its build config to the kit's `config()` (`electron-builder.cjs`), so +the Windows installer is the kit's asking installer and `installer.nsh` is gone. +It also passes its `appId` to the kit, says when it's busy, and takes the kit's +Update tab changes since 0.1.0-beta.3. + +> **Status: signed off by Will 2026-10-07** on a code review against +> electron-kit's CI install check, after he'd run the installer itself. The +> upgrade's administrator prompt and the taskbar pin weren't run by hand. Two +> findings: the right-click box's text was clipped (electron-kit #50, in +> beta.7), and Restart Now could keep asking after a run that threw (#9). + +### Upgrading an install for everyone (every earlier version's) + +- [x] With beta.5 installed (for everyone), run the beta.6 installer. It does + not ask who it's for: it upgrades that copy, and Settings > Apps lists + Diamond File Converter once. +- [x] Right-click a file, then a folder: **Convert with Diamond File + Converter** is there once on each (Windows 11: under "Show more + options"), with the app's icon, and opens the file or folder in the app. +- [x] Right-click an `.mp3`, Open with: Diamond File Converter is offered. + +### A fresh install, only for me + +- [x] Uninstall, then run the installer again: it asks who it's for, "Only for + me" chosen, and installs with no administrator prompt. +- [x] The file types page shows the 22 types in three groups, all ticked, and + the right-click box ticked. Untick **WMA** and the right-click box. +- [x] After the install, a `.wma` doesn't offer the app in Open with, an + `.mp3` does, and there's no Convert entry on right-click. +- [x] Install the same build again over it: WMA and the right-click box start + unticked. +- [x] Uninstall: the app is gone from Open with for every type. + +### The Update tab and Restart Now + +- [x] Open the app and go straight to Settings > Update: the status line is + never empty. It says "Updates haven't been checked yet." for a moment + at most, then "Checking for updates…", then the result. +- [x] Open Settings > Update only after ten seconds: one check has run, and + Check for Updates runs another. +- [x] With an update downloaded, start a long conversion and press **Restart + Now**: it asks first ("A conversion is running."), and Cancel keeps the + conversion going. The same while a join runs ("A join is running."). + +### Notifications and the taskbar (Windows) + +- [x] Pin the app to the taskbar and open it: its window groups under the + pinned icon, not a second one. + +## Convert Again: a converted card can be converted again (2026-10-15) + +Will found it on beta.6: once a card had converted, its format and Advanced +Options could still be changed, but nothing would run them. Convert skips a +card that's Done, and only a failed card had Retry. Now a converted card keeps +the format and options that made its file, locked as a card being converted +is, and **Convert Again** under it unlocks it at Ready. Then Convert runs it +(and only it) again. This replaces beta.5's "when the batch finishes … the +format select all work again" for a card that converted; a card that failed or +was cancelled still unlocks when the batch ends. + +> **Status: cleared as passed by Will 2026-10-16**, for 2.0.0-beta.7. Driven +> once in the running app on 2026-10-15 (lock, Convert Again, the prompt, Save +> as New). + +### Locked once converted + +- [x] Convert a file. When it's Done, its format select and its **tune** icon + are disabled, and so is the orange image-count line on a frames + conversion. **Remove** still works. +- [x] Under it: **Show in Folder** (or **Open Folder** for frames) and + **Convert Again**. +- [x] With only converted cards, **Convert all to** in the bar is disabled; select + one and **Bulk Edit** is disabled too. Add a new file and both work again, + and change only the new card. + +### Convert Again + +- [x] Click **Convert Again**: the card reads Ready with the same format and + settings line, its buttons go, its format select and tune icon work, and + the keyboard focus is on its format select. +- [x] Change a setting in Advanced Options and press **Convert**: only this + card runs. As its output name is taken, **File Already Exists** asks. +- [x] **Save as New** writes `name (1).ext` beside the first file, which is + untouched. Do it again on the same card with **Overwrite**: the first + file is replaced. **Skip This File** leaves the card Skipped and writes + nothing. + +## Overwrite keeps the file when FFmpeg fails, and Join trims like Advanced Options (2026-10-16) + +Two things Will found on beta.6: + +- **A file converted onto itself disappeared.** Converting a file to its own + format (an `.mp4` to MP4, in its own folder) meets **File Already Exists** + with the file itself. **Overwrite** sent FFmpeg straight onto its own input, + which FFmpeg refuses ("FFmpeg cannot edit existing files in-place"). The + clean-up after any failed run then deleted the half-written output, and that + path was the original. The same clean-up deleted any file being overwritten + whenever a run onto it failed. Now a run that replaces something writes to + `name.dfc-partial.ext` beside it and swaps it in only when FFmpeg succeeds. + So converting a file onto itself works, and a failure leaves the old file as + it was. +- **Join's trim was two text boxes.** Each clip now has a **Trim** button that + opens the Advanced Options control under its row: the slider, the exact-time + boxes and the first and last frames, with Shift for one frame at a time. It + is one clip at a time, and the button reads **Trimmed** once a clip is + trimmed. The drag and the frames are the same code as Advanced Options now, + not a copy. + +> **Status: cleared as passed by Will 2026-10-16**, for 2.0.0-beta.7. Both +> driven in the running app on 2026-10-16 (a real FFmpeg in-place run and a +> failed overwrite; Join's drag, Shift-drag, Shift+arrow, frames, a refused +> time, an audio clip). + +### Overwrite, onto the file itself + +- [x] Copy a short `.mp4` somewhere you don't mind losing it. Add it, choose + **MP4**, press **Convert**. **File Already Exists** names the file + itself. Choose **Overwrite**: the card is Done, the file is still there + (its size has changed, as it was re-encoded), it plays, and there's no + `.dfc-partial` file beside it. +- [x] The same with **Save as New**: `name (1).mp4` is written and the + original is untouched. + +### Overwrite, when FFmpeg fails + +- [x] Make a broken file: copy any `.txt`, rename it `broken.mov`. Put a real + `broken.mp4` beside it. Add `broken.mov`, choose **MP4**, Convert, + **Overwrite**. The card fails with FFmpeg's reason, and `broken.mp4` is + still there and still plays. No `.dfc-partial` file is left. +- [x] Start a long conversion with **Overwrite** onto an existing file and + **Cancel** it part way: the existing file is still there and still plays. + +### Join's trim + +- [x] In Join, add two videos and an audio file. Each row has a **Trim** + button. Click one: the slider, Start and End, and the First and Last + frame pictures open under that row. Click another row's Trim: that one + opens and the first closes. +- [x] Drag either end: it moves in whole seconds, and both pictures update + when you stop. Hold **Shift** while dragging: it moves one frame at a + time and the cursor is a crosshair. Shift+←/→ on a focused handle moves + one frame. The row's line reads "keeping …" and the button **Trimmed**. +- [x] Type an end past the clip's length and press Enter: the box goes red + with "The end cannot be past the end of the clip, at …", and nothing + moves. Type a good time: it lands exactly as typed, and focus stays in + the box. +- [x] The audio file's Trim has the slider and boxes but no pictures, and + Shift moves a tenth of a second. +- [x] Join them: the joined file starts and ends where the trims said. +- [x] Look at all of it in both light and dark themes. +- [x] Advanced Options' trim on the Convert view still drags, Shift-drags and + shows its two frames as before. + +## Join says how far it is, and locks while it runs (2026-10-16) + +Two things Will found on beta.7: + +- **Join's bar had no words.** It moved, but nothing said how far. It now has + **Joining 42%** under it, left-aligned, and **Joining…** while the length + isn't known (the bar slides then). +- **Join's settings could be changed mid-join.** The clip list (order, trims, + Remove, Add More) and Join into, File name and Browse all stayed live while + a join ran, though nothing changed then would take effect. Now they're + greyed out and locked until it ends, however it ends. A file dropped on Join + during a run isn't added: a toast says to wait for the join to finish. + +> **Status: not yet run by hand.** Driven once in the running app on +> 2026-10-16 (two clips needing a re-encode: the label read "Joining 48%", +> left-aligned; the list was inert and greyed, the side's fields disabled, a +> click on Remove did nothing; all of it came back after the join, with the +> first row's Move Up still disabled). + +### Joining XX% + +- [ ] In Join, add two videos of different sizes (so they re-encode, which + takes long enough to watch), choose a folder, press **Join**. Under the + bar, on the left, it reads **Joining 0%** and counts up to the end. +- [ ] When the join ends, the bar and its text go away. + +### Locked while joining + +- [ ] Open one clip's **Trim**, then press **Join**. While it runs, the list + is greyed out: Trim, the slider, Start and End, Move Up and Down, Remove + and **Add More** do nothing, and Tab doesn't reach them. +- [ ] **Join into**, **File name** and **Browse** are greyed out and can't be + changed. **Clear** is disabled; **Cancel** works. +- [ ] Drop a file on Join while it runs: it isn't added, and a toast says to + wait for the join to finish. +- [ ] When it finishes, everything works again, and the first clip's Move Up + and the last clip's Move Down are still disabled. +- [ ] Do it again and press **Cancel**: everything unlocks the same way. +- [ ] Look at it in both light and dark themes. + +## Join's long list still scrolls while it joins (2026-10-23) + +One thing Will found on beta.8: + +- **A long Join list couldn't be scrolled while it ran.** The lock made the + whole scrolling area inert, and an inert area takes its vertical scrollbar + and mouse wheel with it, so with many clips the ones below the fold couldn't + be looked at until the join ended. Now only the clips and **Add More** are + locked and greyed; the list around them scrolls as normal. + +> **Status: not yet run by hand.** Driven once in the running app on +> 2026-10-23 (14 clips, so the list overflows; the lock simulated by setting +> the running state): the mouse wheel and a click in the scrollbar's track both +> scrolled the locked list, the clips and Add More stayed inert and at half +> opacity, a forced click on a clip's Remove did nothing, and everything came +> back at full opacity afterwards. + +### Scrolling while locked + +- [ ] In Join, add enough clips that the list needs a scrollbar (a dozen short + videos is plenty), choose a folder, and press **Join** with clips that + need a re-encode (different sizes), so it runs long enough to try this. +- [ ] While it runs, scroll the list with the **mouse wheel**, by **dragging the + scrollbar** and by clicking in its track. All three work. +- [ ] The clips and **Add More** are still greyed out and locked: Trim, Move Up + and Down, Remove and Add More do nothing, and Tab doesn't reach them. +- [ ] **Join into**, **File name** and **Browse** are still greyed out; + **Clear** is disabled; **Cancel** works. +- [ ] When the join ends, everything is back at full strength and you are + still where you scrolled to. +- [ ] Look at it in both light and dark themes. diff --git a/docs/design-system/build.js b/docs/design-system/build.js new file mode 100644 index 0000000..d4092c3 --- /dev/null +++ b/docs/design-system/build.js @@ -0,0 +1,1019 @@ +"use strict"; + +/** + * Builds the Diamond Digital Electron house-style artboards. + * + * Every card is a self-contained HTML page (no external CSS, no Bootstrap) so + * it renders standalone in the Claude Design pane. The first line carries a + * `@dsCard` marker, which is what the pane indexes. + * + * node docs/design-system/build.js + * + * Source of truth for the values below: Diamond Media Player, the Dropgate + * client and server, and Diamond File Converter, surveyed 2026-08-27. + */ + +const fs = require("fs"); +const path = require("path"); + +const OUT = __dirname; + +// ── Shared page chrome ─────────────────────────────────────────────────────── + +const BASE_CSS = ` +:root{ + --accent:#9740fb; --accent-hover:#b06cff; --accent-muted:#c796ff; --accent-rgb:151,64,251; + --bg:#212529; --surface:#2b3035; --surface-2:#343a40; --border:#495057; + --text:#dee2e6; --text-dim:rgba(222,226,230,.75); --text-faint:rgba(222,226,230,.5); + --success:#198754; --warning:#ffc107; --danger:#dc3545; --info:#0dcaf0; + --success-rgb:25,135,84; --warning-rgb:255,193,7; --danger-rgb:220,53,69; + --radius-control:5px; --radius-card:8px; --radius-zone:12px; + --dur-micro:120ms; --dur-state:.2s; --dur-default:.3s; + --ease-standard:cubic-bezier(.4,0,.2,1); +} +*{box-sizing:border-box} +body{ + margin:0;padding:40px;background:var(--bg);color:var(--text); + font-family:'Segoe UI',Tahoma,Geneva,Verdana,sans-serif;font-size:16px;line-height:1.5; +} +h1{font-size:24px;margin:0 0 6px;font-weight:600;letter-spacing:-.01em} +.sub{color:var(--text-dim);font-size:14px;margin:0 0 32px;max-width:70ch} +h2{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:var(--text-faint); + margin:36px 0 14px;font-weight:600} +h2:first-of-type{margin-top:0} +.row{display:flex;flex-wrap:wrap;gap:12px;align-items:center} +.stack{display:flex;flex-direction:column;gap:10px} +.grid{display:grid;gap:14px} +.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-card);padding:18px} +code,.mono{font-family:Consolas,'Courier New',monospace;font-size:12.5px} +code{background:rgba(255,255,255,.07);padding:2px 6px;border-radius:4px;color:var(--accent-muted)} +.note{font-size:13px;color:var(--text-dim);margin-top:10px;max-width:74ch} +.note b{color:var(--text);font-weight:600} +table{border-collapse:collapse;width:100%;font-size:13.5px} +th{text-align:left;color:var(--text-faint);font-weight:600;font-size:11px; + text-transform:uppercase;letter-spacing:.08em;padding:0 14px 8px 0;border-bottom:1px solid var(--border)} +td{padding:9px 14px 9px 0;border-bottom:1px solid rgba(255,255,255,.05);vertical-align:middle} +tr:last-child td{border-bottom:none} +.swatch{width:26px;height:26px;border-radius:5px;border:1px solid rgba(255,255,255,.16);display:inline-block;vertical-align:middle} +.tag{display:inline-block;font-size:11px;padding:2px 8px;border-radius:50rem; + background:var(--surface-2);color:var(--text-dim);border:1px solid var(--border)} +.caption{font-size:11.5px;color:var(--text-faint);margin-top:8px} +`; + +function page(title, subtitle, group, body, extraCss = "") { + return ` + + +${title} + + +

${title}

+

${subtitle}

+${body} + +`; +} + +const cards = []; +function card(file, title, subtitle, group, body, extraCss) { + cards.push({ file, title, group }); + const full = path.join(OUT, file); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, page(title, subtitle, group, body, extraCss), "utf8"); +} + +// ── Foundations ────────────────────────────────────────────────────────────── + +card("foundations/colour.html", "Colour & Accent", + "Bootstrap 5.3's custom properties are the semantic base in all three apps. The house adds one thing on top: a per-app accent, bound into --bs-primary in both themes.", + "Foundations", ` +

The per-app accent

+

House rule: per-app accent, everything else shared. An app overrides these four values and nothing else. Accent is the app's identity in the taskbar; structure, motion and components stay identical.

+ + + + + +
App--accent--accent-hover--accent-muted
Diamond File Converter#28a745#34d058#5dd879
Diamond Media Player#9740fb#b06cff#c796ff
Dropgate client#0d6efd#0a58ca#6ea8fe
+ +

Binding the accent into Bootstrap

+
:root,
+[data-bs-theme="light"],
+[data-bs-theme="dark"] {
+    --bs-primary:     var(--accent);
+    --bs-primary-rgb: var(--accent-rgb);
+    --bs-link-color:  var(--accent);
+}
+

Both themes, and the bare :root. File Converter v1 remapped --bs-primary only inside [data-bs-theme="dark"], so its primary button rendered Bootstrap blue in light mode and green in dark. Listing the bare :root alongside both themes also covers the "system" case, where neither attribute is set. Bootstrap derives .btn-primary from its own component variables rather than from --bs-primary, so the button additionally needs --bs-btn-bg set.

+ +

Semantic colours

+ + + + + + +
RoleHexRGB triplet
Success#19875425, 135, 84
Warning#ffc107255, 193, 7
Danger#dc3545220, 53, 69
Info#0dcaf013, 202, 240
+ +

Surfaces

+ + + + + + +
TokenLightDark
--bs-body-bg #fff #212529
--bs-secondary-bg #e9ecef #343a40
--bs-tertiary-bg #f8f9fa #2b3035
--bs-border-color #dee2e6 #495057
+

Theme is driven by data-bs-theme on <html>, set by an inline script in <head> before the stylesheet parses so there is no flash. All three apps follow the OS only — none has an in-app theme toggle or persists a preference.

+`); + +card("foundations/wash-ladder.html", "Wash Formulas", + "Two formulas cover every tinted surface in the house style. Both are alpha over a token's RGB triplet, never a new hex value.", + "Foundations", ` +

Interaction ladder

+
+
+
hover
fill .04 · border .5
+
+
active / drag-over
fill .06 · border 1
+
+

Applied as rgba(var(--accent-rgb), <alpha>). The drop zone is the canonical user: idle border is the neutral --bs-border-color, hover tints it halfway, an active drag takes it to full accent.

+ +

Semantic wash

+
+
Success — rgba(25,135,84,.1) fill, rgba(25,135,84,.3) border
+
Warning — rgba(255,193,7,.1) fill, rgba(255,193,7,.3) border
+
Danger — rgba(220,53,69,.1) fill, rgba(220,53,69,.3) border
+
+

The formula is fill 0.1, border 0.3, always. Because both derive from the token's RGB triplet, a semantic state needs no new colour value and stays correct in both themes.

+`); + +card("foundations/type.html", "Typography", + "One font stack, no webfonts, one custom weight. The scale is small because these are dense utility apps, not content sites.", + "Foundations", ` +

Stack

+
+
Diamond Digital Development
+ 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif +

Windows system stack, no webfont loaded. Dropgate's main window omits the declaration entirely and inherits Bootstrap's --bs-font-sans-serif; its popup pages pin the stack above. Standardise on the explicit stack.

+
+ +

Scale

+ + + + + + + + +
UseSizeSample
Section label (uppercase)0.7remVideo
Meta / secondary0.875rem3 Files Queued
Body1remDrag & Drop Files Here
Drop label1rem / 500Drag & Drop Files Here
Page heading1.25remDiamond File Converter
Popup heading24pxPreferences
+

500 is the only custom weight in the entire house style. Everything else is Bootstrap's default 400 or a heading default. Media Player sizes in px; Dropgate sizes in rem — rem is the one to standardise on. Letter-spacing appears exactly twice: .1em on uppercase section labels and .5px on Media Player's slider labels.

+ +

Icons

+
+

Material Icons Round, bundled locally from node_modules/material-icons/iconfont/round.css — never the Google Fonts CDN.

+ + + + + + + +
SizeUse
48pxHero / empty-state drop icon
28pxSecondary hero (populated drop zone)
24pxDefault — status cards, transport controls
18pxList row leading icon
14pxInside a 20px circular button
+

Media Player uses <i>, Dropgate uses <span> — an inconsistency worth settling on <span>. Icons are recoloured with text utilities (text-success, text-danger), never custom hexes.

+
+`); + +card("foundations/spacing-radii.html", "Spacing & Radii", + "Three radii carry almost everything. Spacing is Bootstrap's utility scale in newer code and ad-hoc pixels in older code.", + "Foundations", ` +

Radii

+
+
5px
+
8px
+
12px
+
50%
+
+ + + + + + + +
TokenValueApplies to
--radius-control5pxButtons, selects, popup controls
--radius-card8pxFile lists, cards, panels
--radius-zone12pxDrop zone
—50%Circular row actions, toggle knob
—3pxScrollbar thumb, progress track
+ +

Spacing

+

Newer code (Dropgate, File Converter) uses Bootstrap utilities — p-4, mb-3, gap-2, d-grid. Media Player uses ad-hoc pixels: 2, 4, 6, 10, 12, 15, 18, 20, 28, 40. Bootstrap utilities are the direction of travel.

+ + + + + + + +
ContextValue
Page paddingp-4 (1.5rem), dropping to .75rem below 500px
Block rhythmmb-3 between stacked sections
Drop zone padding2.25rem 1.5rem
List row padding.35rem 0, gap: .5rem
Content max-width580px (File Converter) / 650px (Dropgate)
+ +

Shadows

+

The house barely uses shadows. The Dropgate client has none at all; depth comes from borders plus a wash. Media Player has exactly two: 0 -2px 10px rgba(0,0,0,.3) on the floating control bar and 0 1px 4px rgba(0,0,0,.15) on the toggle knob.

+`); + +card("foundations/motion.html", "Motion", + "Five durations and three easings. The duration says what kind of change it is, so pick by meaning rather than by feel.", + "Foundations", ` + +

Durations — hover each row

+
+
120ms Micro — hover and press on small controls (row delete, icon buttons)
+
0.2s State — a surface changing meaning (drop zone arming)
+
0.3s Default — everything else
+
+ + + + + + + +
DurationNameUsed for
120ms easemicroSmall-control hover
0.2s ease-in-outstateSurface state change
0.3sdefaultGeneral transitions
0.4s easeenterPage-load fade-in
2s ease-in-out ∞ambientLoading pulse / breathe
+ +

Easing

+
+
cubic-bezier(.4, 0, .2, 1)
knob travel — the only custom curve
+
ambient pulse — "still loading"
+
◠
spin 1.4s linear — work in progress
+
+ +

Timings shared with the JS side

+ + + + + + + +
ValueMeaning
500msDebounce — window-bounds saves, Explorer multi-file batching
3000msProgress bar reset after completion
4500msToast auto-dismiss
2000msIdle before the video control bar hides
5000msDelay before the startup update check
+

Disabled and busy is opacity: 0.5 plus pointer-events: none — applied as inline style, not a class.

+`); + +// ── Components ─────────────────────────────────────────────────────────────── + +card("components/buttons.html", "Buttons", + "All stock Bootstrap variants. The house contributes a vocabulary — which variant means what — rather than custom button CSS.", + "Components", ` + +

Variant vocabulary

+ + + + + + + + +
VariantExampleMeans
btn-primaryCommit the action — one per screen
btn-secondaryNeutral action
btn-outline-secondaryTertiary, or adjacent to an input
btn-dangerDestructive or abort
btn-warningConfirm something risky
disabledopacity .5 + pointer-events:none
+ +

Fill is reserved for the action being committed

+
+
+ correct
+
+ wrong
+

A neutral action sitting beside a filled primary is outlined, not filled. + Two filled buttons side by side read as two equal choices, which is exactly what a + cancel is not. This applies anywhere the pair appears — dialog footers, the action bar, + card actions. Bootstrap's own modal example uses a filled btn-secondary for + Cancel; the house does not.

+

A filled neutral button is still fine when it stands alone with no primary + beside it, as with Add Files in the toolbar.

+
+ +

Swap, do not crowd

+
+
→ becomes →
+

The primary and its abort occupy the same slot, toggled with style.display, rather than sitting side by side. Dropgate's upload/cancel pair is the reference.

+
+ +

Popup-window button

+
+ + +

Inside .popup-window pages, .btn is overridden to full-width, 10px padding, 16px text, --bs-secondary-bg fill, going to --bs-tertiary-bg on hover. Identical in Media Player and Dropgate.

+
+`); + +card("components/drop-zone.html", "Drop Zone", + "The hero component of both File Converter and Dropgate, and the clearest example of the interaction wash ladder.", + "Components", ` + +

Empty → populated

+
+
+
+
⬆
+

Drag & Drop Files Here

+

or

+ +
+
#dz-empty
+
+
+
+
⊕
+

3 Files Queued

+

drag and drop more files or

+
+ + +
+
+
#dz-has-files
+
+
+

Two sibling divs toggled with Bootstrap's .d-none. The zone itself never unmounts, so the border and background transitions stay continuous across the state change.

+ +

Drag-over

+
+
⬆
+

Drag & Drop Files Here

+
+

Border goes to full accent, fill to .06.

+ +

The nested-button guard

+
.drop-zone:hover:not(:has(button:hover))
+

The whole zone is a click target, so it must stop signalling "drop here" once the pointer is over a button inside it. This exact idiom appears in the File Converter, the Dropgate client and the Dropgate server. Note the matching JS guard: the zone's click handler returns early when the event originated on a button, or the browse dialog opens twice.

+ +

One zone per section, and the icon is the section's

+
+
+
+
⇄
+

Drag & Drop Files or Folders Here

+

or

+ +
+
Convert — the rail item's glyph, at 48px
+
+
+
+
⑂
+

Drag & Drop Files to Join Here

+

or

+ +
+
Join — same zone, its own glyph
+
+
+

In an app with a rail, the zone's icon is the rail item's icon. A section is named by one glyph, not two: whatever sits beside its label on the rail is what sits at the top of its empty state, in the accent at --opacity-muted. Never a generic upload glyph — File Converter's Convert zone showed upload_file under a rail item drawn as grid_view, and the two never met.

+

Sibling sections get the same zone, line for line. Icon, label, "or", button — same classes, same colours, same count of lines. Only the icon and the label change. A hint line that one section has and the other lacks makes one zone taller than the other, and the two sections stop reading as one app.

+`); + +card("components/file-row.html", "File Row & List", + "The list primitive shared by File Converter and Dropgate, including the only custom scrollbar in the house style.", + "Components", ` + +

Anatomy

+
+
▦holiday-montage-final-render-v3.mp4248 MB
+
▦beach.mov1.2 GB
+
+
♪soundtrack.flac42 MB
+
+

Left to right: 18px leading icon · flexible truncating name · fixed trailing meta · 20px circular destructive action. The name takes flex: 1 1 0 with min-width: 0 — without the min-width, a long filename refuses to shrink and pushes the meta out of the row. Hover the × to see the 120ms transition to danger.

+ +

Group divider

+

File Converter separates media kinds with a 4px filled bar rather than a heading, and clears the preceding row's border-bottom so the two rules do not stack.

+ +

Scrollbar

+ + + + + + +
PartValue
width6px
tracktransparent
thumbrgba(128,128,128,0.3), radius 3px
thumb:hoverrgba(128,128,128,0.5)
+

Deliberately theme-neutral grey so one rule works in both themes.

+`); + +card("components/toggle.html", "Toggle Switch", + "The one genuinely custom control in the house style. Currently Media Player only — worth promoting to the shared layer.", + "Components", ` + +

States

+
+ + +

Both are live — click them.

+
+ +

Geometry

+ + + + + + + + +
PartValue
Track48 × 28, radius 34px
Knob20 × 20, radius 50%, inset 4px
TraveltranslateX(20px)
Easingcubic-bezier(.4, 0, .2, 1) over 0.3s
Knob shadow0 1px 4px rgba(0,0,0,.15)
Checked trackvar(--accent)
+

Row layout is label-left, control-right, wrapped in a single <label> with display:flex; gap:12px so the text is part of the hit target. The real input is visually hidden rather than removed, which keeps it keyboard-focusable.

+`); + +card("components/status-card.html", "Status Card", + "Dropgate's signature component: an ambient pulse while a state is unknown, resolving into a semantic wash once it is known.", + "Components", ` + +

Loading, then resolved

+
◌Checking security…
+
✓Your upload will be end-to-end encrypted.
+
⚠This server does not advertise encryption.
+
✕This connection is not secure.
+ +

The pattern: pulse while unknown, then kill the animation and apply the wash. The resolved state sets three things at once — the icon glyph, a Bootstrap text colour on the icon, and the wash class on the card. Because the layout is identical in every state, resolving causes no reflow.

+ +

Why it generalises

+

This is the natural shape for a File Converter job card's status strip: unknown while the probe is in flight, then green for ready, yellow for "will re-encode", red for an unsupported combination. The ambient pulse is doing real work — it distinguishes "still checking" from "checked and fine", which a static grey card cannot.

+`); + +card("components/progress.html", "Progress", + "Three surfaces report progress, and the house uses all three at once for long operations.", + "Components", ` + +

Determinate

+
+
File 3 of 862%
+ +

Indeterminate

+
+
Converting…
+

Use this whenever the duration is unknown — still images, streams with no reported length. File Converter v1 had no indeterminate state, so its bar sat frozen at 0% for the whole job whenever ffmpeg could not report a percentage, which read as a hang.

+ +

Window chrome as a progress surface

+
+
Dropgate Client — Uploading 62%
+
+
+

Dropgate sets both the window title and win.setProgressBar(), which lights the Windows taskbar button. On completion it passes { mode: 'error' } to turn the taskbar bar red, then clears it with -1 after 3000ms. This is the right pattern for a long convert queue — progress stays visible when the window is behind something else.

+`); + +card("components/toast.html", "Toast", + "Currently only in the Dropgate server. The clearest gap in the desktop apps, which have no transient feedback at all.", + "Components", ` + +

Variants

+
Queued 24 files from 3 folders.
+
Conversion complete — 24 files written.
+
4 files skipped: unsupported format.
+
Could not write to the output folder.
+ +

Placement

+ + + + + + + +
PropertyValue
positionfixed, top: 16px, left: 50%, translateX(-50%)
widthmin(720px, calc(100% - 2rem))
z-index1080 (above Bootstrap's modal backdrop)
accent bar4px left border in the semantic colour
dismiss4500ms
+

Guard the dismissal against clobbering. The server's implementation snapshots the toast's text before the timeout and only hides the element if the text is still the same — otherwise a second toast raised inside the window gets cut short by the first one's timer.

+

Note this is a full-fill toast, not Bootstrap's default outlined .alert: the semantic colour is the background, with the left border reinforcing it. Warning and info take black text; success and danger take white.

+`); + +card("components/modal.html", "Modal Dialog", + "The house has exactly one in-page HTML dialog, in the Dropgate client. Its conventions are worth following, because the alternative is a native blocking message box.", + "Components", ` + +
+ +
+ +

Conventions

+ + + + + + + + + +
ElementRule
Header & footerborder-0 — no dividing rules
Dialogmodal-dialog-centered
TitleSemantic icon + text in d-flex align-items-center gap-2
BodyOne plain statement, then one text-body-secondary small mb-0 caveat
FooterOutlined neutral cancel on the left, filled semantic confirm on the right — never two filled buttons
BackdropBootstrap default — #000 at .5
MotionBootstrap default — backdrop .15s linear, dialog translate(0,-50px) → 0 over .3s ease-out
+

Wrap it in a promise. Dropgate's helper resolves true on confirm and false on hidden.bs.modal, removing both listeners in a shared cleanup, so the dialog reads as a plain await at the call site. This is the pattern the 2.0 "New Job" modal should use.

+`); + +card("components/mode-tiles.html", "Mode Tiles", + "A mutually exclusive chooser built from tiles rather than radio inputs. The house has no radio groups \u2014 this is the pattern used wherever one of several modes must be picked.", + "Components", ` + + +

States

+
+
\u25C9
Manual settings
Set the format, quality and trim yourself
+
\u25CB
From a template
Start from something already saved
+
+

Selected \u00b7 locked. Hover the left tile to see the idle-to-hover wash.

+ +
+
\u25CB
Idle
Neither chosen
+
\u25C9
Focused
Keyboard focus ring
+
+ +

The recipe

+ + + + + + + + + + + +
PartValue
ContainerTwo equal columns, 0.5rem gap. Fixed 50/50 \u2014 tiles do not wrap.
Tile2px border, --radius-control, .75rem .5rem padding, column flex centred, height:100% so a taller description does not leave a short neighbour
Idle bordervar(--bs-border-color) \u2014 a theme token, never a fixed white alpha, or it breaks in light mode
Hoverborder rgba(accent, .5), fill rgba(accent, .04) \u2014 the same wash ladder as the card grid
Selectedborder accent plus box-shadow: 0 0 0 1px accent
Lockedpointer-events:none; opacity:.5 and a title saying why
Icon2rem, --bs-secondary-color \u2192 --accent when selected
Title / description0.875rem/600 and 0.7rem muted
Transition--dur-state --ease-state on border, shadow and background
+

Why a shadow as well as a border. Growing the border to 3px on selection would change the box size and shift everything beside it. A 1px shadow in the same colour sits outside the border and reads as a thicker edge with no reflow.

+ +

Behaviour

+

One source of truth. A click sets a single state variable, then every tile recomputes its own selected class from it. Do not toggle tiles against each other.

+

Strictly one or the other. Re-clicking the active tile does nothing. Craftbox's create-server source tiles are tri-state \u2014 clicking the selected one deselects back to "from scratch" \u2014 but that only works because it has a meaningful third state. Where there are exactly two modes, deselection would leave the form in no mode at all.

+

Fail closed, then unlock. A tile with nothing behind it ships locked and is enabled at runtime once its data exists \u2014 Craftbox locks Template until a template is saved. Enforce it in the handler as well as the CSS: pointer-events:none stops a mouse but not a keyboard or a programmatic activation.

+

Keyboard operable. role="button", tabindex="0" (-1 when locked), aria-pressed, and Enter/Space activation, with a :focus-visible ring. The original pattern has only role="button" and cannot be reached from the keyboard at all \u2014 do not copy that part.

+ +

Dependent sections

+

The chooser swaps which controls appear below it, by toggling d-none. Both branches stay in the DOM. Values in the hidden branch are kept, not cleared, so switching back and forth is lossless \u2014 but only the active branch is read when the work runs, and the card shows only the active branch.

+`); + +card("components/inputs.html", "Inputs & Form Rows", + "Stock Bootstrap form controls, arranged in a fixed three-part row that all the apps repeat.", + "Components", ` + +

The row

+
+ +
+
Leave empty to write next to each source file.
+
+
+ +
jobs at once
+
Detected 8 cores.
+
+

Always three parts, in this order: label.form-label above, full width · the control (optionally in an .input-group with a trailing button or addon) · a .form-text help line.

+ +

Help-line state vocabulary

+
+
neutral — form-text text-muted
+
success — text-success
+
warning — text-warning
+
error — text-danger
+
+

Reserve the space with min-height: 1.2em. Without it the whole form jumps when a validation message appears. This trick is used on every status row in Dropgate.

+

Disable, do not hide. When the server restricts an option, Dropgate disables the <option> and rewrites its label to "Unlimited (Disabled by Server)" rather than removing it, so the capability stays discoverable.

+`); + +// ── Shell ──────────────────────────────────────────────────────────────────── + +card("shell/window-chrome.html", "Window Chrome", + "Every app uses a standard OS-framed window and the native menu bar. That is a deliberate house choice, not an omission.", + "Shell", ` + +
+
Diamond File Converter─ □ ✕
+ +
Single-column content, centred, max-width 580–650px.
+
+ +

Rules

+ + + + + + + +
DecisionHouse position
Frameless / custom titlebarNo. No app sets frame:false or titleBarStyle.
NavigationNative Menu.buildFromTemplate. No sidebar, no tabs, no router.
LayoutOne centred column of .mb-3 blocks.
BoundsPersisted to electron-store, debounced 500ms on resize/move, written immediately on close.
Single instancerequestSingleInstanceLock(); second-instance restores and focuses.
+ +

Menu template

+
Menu
+  Open File          Ctrl+O
+  ─────────
+  Check for Updates
+  ─────────
+  Exit               Alt+F4   (role: quit)
+Credits                        (flat top-level item)
+

Identical across all three apps, down to the separator positions. Credits is a flat top-level item, not a submenu — clicking the label opens the window directly.

+ +

Window sizes

+ + + + + +
AppDefaultMinimum
Diamond Media Player1280 × 7751080 × 775
Diamond File Converter700 × 1000600 × 600
Dropgate client600 × 900400 × 700
+

Media Player is wide because it shows video; the two utility apps are tall single columns.

+`); + +card("shell/popup-window.html", "Popup Window", + "Credits and preferences are real child BrowserWindows, not in-page modals. The recipe and its CSS are copied near-verbatim between apps.", + "Shell", ` + +
+
+

Preferences

+ + +
Media Player — 400 × 300
+
+
+

Diamond File Converter

+

• Created and maintained by Diamond Digital Development.

+
+ +
Credits — 750 × 450
+
+
+ +

The BrowserWindow recipe

+
parent: mainWindow, modal: true,
+resizable: false, minimizable: false,
+maximizable: false, fullscreenable: false
+→ win.setMenu(null)
+→ win.on("minimize", e => { e.preventDefault(); win.show(); win.focus(); })
+→ page listens for Escape and calls window.close()
+

The anti-minimize guard matters. Because the window is modal to its parent, letting it minimise would strand the app with an invisible blocker. Modality, backdrop and focus trapping all come from the OS, so there is no overlay CSS and no enter/exit animation.

+ +

Shared CSS

+

The .popup-window block is duplicated in all three apps with only six points of drift. The "link" is a bare <span> with a click handler, not an <a> — it takes the accent-muted colour and underlines on hover. Under context isolation these must be data-href plus a listener rather than inline onclick, since the page has no shell.

+`); + +card("shell/context-menu.html", "Context Menu", + "The only context menu in the house style is a Windows Explorer shell extension written at install time. No app has an in-app one.", + "Shell", ` + +
+
Open
+
Edit
+
+
Convert with Diamond File Converter
+
Open with Diamond Media Player
+
Share with Dropgate
+
+ +

How it is registered

+
!macro customInstall
+  WriteRegStr HKCR "*\\shell\\<App>" "" "<Verb> <Product>"
+  WriteRegStr HKCR "*\\shell\\<App>" "Icon" "$INSTDIR\\\${APP_EXECUTABLE_FILENAME}"
+  WriteRegStr HKCR "*\\shell\\<App>\\command" "" '"$INSTDIR\\\${APP_EXECUTABLE_FILENAME}" "%1"'
+!macroend
+
+!macro customUninstall
+  DeleteRegKey HKCR "*\\shell\\<App>"
+!macroend
+

Made by electron-kit's config() from contextMenu in electron-builder.cjs: the installer asks before adding it, and uninstalling takes it away. An app never writes its own installer.nsh.

+ +

The batching problem

+

Windows launches one process per selected file. Selecting 30 files spawns 30 processes, 29 of which hit the single-instance lock and forward their path through second-instance. Collect those arrivals and debounce for 500ms before handing them to the renderer, or the queue is rebuilt 30 times. Dropgate's BATCH_DEBOUNCE_MS is the reference.

+ +

Gap

+

There is no in-app context menu anywhere — no contextmenu listener, no Menu.popup(). A right-click menu on File Converter's 2.0 job grid would be the first, and needs a house decision on native Menu.popup versus a custom HTML menu.

+`); + +// ── Patterns ───────────────────────────────────────────────────────────────── + +card("patterns/settings-placement.html", "Settings Placement", + "The house has two competing answers. Both are legitimate; the choice follows how often the setting changes.", + "Patterns", ` +

Two patterns in use

+
+
+
Media Player
+

Separate popup window

+

A modal child BrowserWindow at 400 × 300, opened from the native menu. Flat layout: heading, then label + control stacked. No save button — changes persist on change.

+
+
+
Dropgate
+

Settings as page

+

Server URL, lifetime and max downloads are first-class rows on the main screen, above the action button. Persisted on input, blur and change.

+
+
+ +

The rule

+ + + + +
If the setting is…Put it…
Changed most times the app is used, and affects the pending actionOn the page, as a form row
Configured once and forgottenIn the preferences popup window
+

Dropgate's lifetime and download-cap settings change per upload, so burying them in a popup would be wrong. Media Player's visualiser quality is set once, so putting it on the player surface would be clutter.

+

Neither pattern has a save button. Persist on change and reflect the result immediately; a modal save step is not the house style.

+ +

Applied to File Converter 2.0

+

Per-job encoding options belong in the New Job modal — changed constantly, scoped to one job. App-wide defaults (output routing, concurrency, conflict policy, name template) belong in a preferences popup window, since they are set once. Output folder is the borderline case, and it argues for the page: it changes often enough to deserve a visible row.

+`); + +card("patterns/feedback.html", "Feedback & Error Surfacing", + "The weakest area of the house style. Everything is a native blocking dialog, which does not scale to batch operations.", + "Patterns", ` +

What exists today

+ + + + + + + +
SituationCurrent treatmentVerdict
Update available / readyNative dialog.showMessageBoxFine — rare, needs a decision
Output file existsNative dialog: Cancel / Overwrite / Save as NewFine for one file, painful for fifty
Conversion failedNative dialog, one per failed file, mid-queueWrong — blocks the batch
Unsupported file droppedNothing at allWrong — silent rejection
Background operation doneOS Notification when unfocused (Dropgate only)Good — worth copying
+ +

The rule to adopt

+

A blocking dialog is for a decision only the user can make, right now. Anything reporting what already happened belongs in a toast or an inline status row. A failure inside a batch is a report, not a decision — it should mark the job and let the queue continue, with the detail available afterwards.

+ +

Two specific traps

+

body { user-select: none } makes error text uncopyable. File Converter sets it globally to stop text selection dragging the UI, which also means a user cannot copy the ffmpeg error to report it. Scope the rule to the chrome and let messages and paths stay selectable.

+

A per-file conflict prompt does not scale. Offer "apply to all remaining" on the first prompt, or resolve the whole batch up front, before any work starts.

+ +

Silent failure is the worst outcome

+

Two real examples worth remembering. File Converter drops unsupported files from a drop with no feedback whatsoever — the user sees nothing happen and cannot tell the app from a broken one. And its debug.log is the only record of a failure, with no way to open it from inside the app. If the app knows something went wrong, the user must be able to find out what.

+`); + +card("patterns/theme.html", "Theming", + "System-following only, set before first paint. No app has a manual toggle — a known gap rather than a decision.", + "Patterns", ` +

The bootstrap script

+
<script>
+  (() => {
+    const mql = window.matchMedia("(prefers-color-scheme: dark)");
+    const root = document.documentElement;
+    const apply = () => root.setAttribute("data-bs-theme", mql.matches ? "dark" : "light");
+    apply();
+    mql.addEventListener?.("change", apply);
+  })();
+</script>
+

Inline in <head>, before the stylesheet link. Deferring it to a bundle or to DOMContentLoaded produces a flash of the wrong theme. The subscription matters as much as the initial call: these are long-running desktop apps, and the OS theme can flip under them at sunset.

+

Dropgate's newer spelling is the one to standardise on — an IIFE with optional chaining on addEventListener. It also pre-sets data-bs-theme="auto" on <html> as a placeholder.

+ +

Scoped theme islands

+

A subtree can opt out with its own data-bs-theme. Media Player forces its transport bar dark regardless of app theme, because it overlays video: <div id="controls" data-bs-theme="dark">. Use this rather than hardcoding hexes when a region must stay dark.

+ +

Known gaps

+ + + + + +
GapConsequence
No manual toggle in any appA user on a light OS cannot get a dark app
No persisted preferenceNothing to restore even if a toggle were added
Hardcoded hexes bypass themingMedia Player's meters and Dropgate's two legacy greys (#5a5a5a, #535353) do not respond to theme
+

The fix for the third is the token layer: every colour resolves through a variable, so a theme change reaches all of them.

+`); + +// ── Index + manifest ───────────────────────────────────────────────────────── + +card("shell/nav-rail.html", "Navigation Rail", + "A persistent left rail for an app with more than one top-level surface. Sections are siblings you switch between, never a view you have to back out of.", + "Shell", ` + + +

Expanded and collapsed

+
+
+
+
⇄Convert
+
⑂Join
+
‹Collapse
+
+

Convert

The active section fills the rest of the window.

+
+
+
+
⇄Convert
+
⑂Join
+
›Expand
+
+

Collapsed

Icons only, label moved to title.

+
+
+ +

The recipe

+ + + + + + + + + + +
PartValue
Rail168px expanded, 56px collapsed, --dur-state --ease-state on width
Surface--bs-tertiary-bg with a --bs-border-color right edge — a step back from the content, not a block of accent
Item.5rem .6rem padding, --radius-control, icon 20px + label, .6rem gap, line-height: 20px — see "Two jitters" below
IconThe section's one glyph: the same icon heads that section's empty-state drop zone. Convert is swap_horiz, Join is merge_type
Hoverborder rgba(accent, .5), fill rgba(accent, .04) — the standard wash ladder
Activeborder rgba(accent, 1), fill rgba(accent, .06), and the label in --accent
Collapse controlPinned with margin-top:auto, muted, chevron rotated 180° when collapsed, with --dur-state on the transform so it turns rather than flips
Focusbox-shadow: 0 0 0 .2rem rgba(accent, .35) on :focus-visible, outline:none
+ +

Two jitters, and what stops them

+

Pin the item's line box to the icon. Bootstrap's 1.5 line-height makes a .9rem label 21.6px tall against a 20px icon, and the label is what sets the item's height. Collapse hides the label, every item shrinks by that 1.6px, and every icon below it creeps up. line-height: 20px on the item makes the expanded and collapsed states the same height, so the icons hold still while the width animates.

+

Nothing in the header may change height with the view. The header's toolbar is shared by every section and routed to whichever is showing, never hidden per view. File Converter hid Add Files / Add Folder on Join, which took the buttons' 31px out of a header whose title is 24px — so the title and everything under it jumped 7px on every switch. If a section genuinely has no use for a control, reserve its height; do not remove it.

+ +

Where it sits in the shell

+

The rail is outside the padded content column, so it reaches the window edge. That means the shell is no longer the outermost element: an .app-frame row wraps the rail and the column, and the column keeps the padding, the header, the content and the footer. Give the column min-width:0 or a wide child stretches it and the whole window scrolls sideways.

+ +

Switching

+

One attribute decides what is visible. A data-view on the shell, and CSS keyed off it. Where the sections' own elements already manage a d-none of their own, the view rules force-hide with !important rather than competing — each section keeps its internal state and returns to it intact instead of being rebuilt.

+

Every route out goes through one function. A rail click, a link from a dialog and a file drop all call the same switcher, so an unsaved-work guard cannot be sidestepped by arriving a different way. A drop onto a non-file section runs the guard, switches, then ingests — and a declined guard cancels the drop rather than ingesting behind it.

+

The keyboard follows the view. A global key handler that already bails while a modal is open must bail outside the active section too, or Delete acts on something the user cannot see.

+

Collapse is remembered. A narrow window is exactly where someone collapses it, and exactly where being made to do it again every launch would grate. Persist it with the app's normal settings store.

+

Do not ship a destination that goes nowhere. A section gets a rail item when it is built, never before. A greyed "coming soon" item is a dead end the user cannot resolve, and a rail with a single destination is at least honest — it costs a strip of width and promises nothing it cannot do.

+`); + +const byGroup = cards.reduce((acc, c) => { + (acc[c.group] ||= []).push(c); + return acc; +}, {}); + +const indexBody = Object.entries(byGroup).map(([group, list]) => ` +

${group}

+
+${list.map(c => `
${c.title} ${c.file}
`).join("\n")} +
`).join("\n"); + +fs.writeFileSync(path.join(OUT, "index.html"), page( + "Diamond Digital — Electron House Style", + `The shared visual language of Diamond Media Player, the Dropgate client and Diamond File Converter. Extracted from the three codebases on 2026-08-27. ${cards.length} cards.`, + "Overview", + indexBody +), "utf8"); + +fs.writeFileSync(path.join(OUT, "cards.json"), JSON.stringify(cards, null, 2), "utf8"); + +console.log(`Wrote ${cards.length} artboards + index.html into ${OUT}`); +for (const c of cards) console.log(` [${c.group}] ${c.file}`); diff --git a/docs/design-system/cards.json b/docs/design-system/cards.json new file mode 100644 index 0000000..989574f --- /dev/null +++ b/docs/design-system/cards.json @@ -0,0 +1,112 @@ +[ + { + "file": "foundations/colour.html", + "title": "Colour & Accent", + "group": "Foundations" + }, + { + "file": "foundations/wash-ladder.html", + "title": "Wash Formulas", + "group": "Foundations" + }, + { + "file": "foundations/type.html", + "title": "Typography", + "group": "Foundations" + }, + { + "file": "foundations/spacing-radii.html", + "title": "Spacing & Radii", + "group": "Foundations" + }, + { + "file": "foundations/motion.html", + "title": "Motion", + "group": "Foundations" + }, + { + "file": "components/buttons.html", + "title": "Buttons", + "group": "Components" + }, + { + "file": "components/drop-zone.html", + "title": "Drop Zone", + "group": "Components" + }, + { + "file": "components/file-row.html", + "title": "File Row & List", + "group": "Components" + }, + { + "file": "components/toggle.html", + "title": "Toggle Switch", + "group": "Components" + }, + { + "file": "components/status-card.html", + "title": "Status Card", + "group": "Components" + }, + { + "file": "components/progress.html", + "title": "Progress", + "group": "Components" + }, + { + "file": "components/toast.html", + "title": "Toast", + "group": "Components" + }, + { + "file": "components/modal.html", + "title": "Modal Dialog", + "group": "Components" + }, + { + "file": "components/mode-tiles.html", + "title": "Mode Tiles", + "group": "Components" + }, + { + "file": "components/inputs.html", + "title": "Inputs & Form Rows", + "group": "Components" + }, + { + "file": "shell/window-chrome.html", + "title": "Window Chrome", + "group": "Shell" + }, + { + "file": "shell/popup-window.html", + "title": "Popup Window", + "group": "Shell" + }, + { + "file": "shell/context-menu.html", + "title": "Context Menu", + "group": "Shell" + }, + { + "file": "patterns/settings-placement.html", + "title": "Settings Placement", + "group": "Patterns" + }, + { + "file": "patterns/feedback.html", + "title": "Feedback & Error Surfacing", + "group": "Patterns" + }, + { + "file": "patterns/theme.html", + "title": "Theming", + "group": "Patterns" + }, + { + "file": "shell/nav-rail.html", + "title": "Navigation Rail", + "group": "Shell" + } +] \ No newline at end of file diff --git a/docs/design-system/components/buttons.html b/docs/design-system/components/buttons.html new file mode 100644 index 0000000..3524c94 --- /dev/null +++ b/docs/design-system/components/buttons.html @@ -0,0 +1,102 @@ + + + +Buttons + + +

Buttons

+

All stock Bootstrap variants. The house contributes a vocabulary — which variant means what — rather than custom button CSS.

+ + +

Variant vocabulary

+ + + + + + + + +
VariantExampleMeans
btn-primaryCommit the action — one per screen
btn-secondaryNeutral action
btn-outline-secondaryTertiary, or adjacent to an input
btn-dangerDestructive or abort
btn-warningConfirm something risky
disabledopacity .5 + pointer-events:none
+ +

Fill is reserved for the action being committed

+
+
+ correct
+
+ wrong
+

A neutral action sitting beside a filled primary is outlined, not filled. + Two filled buttons side by side read as two equal choices, which is exactly what a + cancel is not. This applies anywhere the pair appears — dialog footers, the action bar, + card actions. Bootstrap's own modal example uses a filled btn-secondary for + Cancel; the house does not.

+

A filled neutral button is still fine when it stands alone with no primary + beside it, as with Add Files in the toolbar.

+
+ +

Swap, do not crowd

+
+
→ becomes →
+

The primary and its abort occupy the same slot, toggled with style.display, rather than sitting side by side. Dropgate's upload/cancel pair is the reference.

+
+ +

Popup-window button

+
+ + +

Inside .popup-window pages, .btn is overridden to full-width, 10px padding, 16px text, --bs-secondary-bg fill, going to --bs-tertiary-bg on hover. Identical in Media Player and Dropgate.

+
+ + diff --git a/docs/design-system/components/drop-zone.html b/docs/design-system/components/drop-zone.html new file mode 100644 index 0000000..5e962c7 --- /dev/null +++ b/docs/design-system/components/drop-zone.html @@ -0,0 +1,121 @@ + + + +Drop Zone + + +

Drop Zone

+

The hero component of both File Converter and Dropgate, and the clearest example of the interaction wash ladder.

+ + +

Empty → populated

+
+
+
+
⬆
+

Drag & Drop Files Here

+

or

+ +
+
#dz-empty
+
+
+
+
⊕
+

3 Files Queued

+

drag and drop more files or

+
+ + +
+
+
#dz-has-files
+
+
+

Two sibling divs toggled with Bootstrap's .d-none. The zone itself never unmounts, so the border and background transitions stay continuous across the state change.

+ +

Drag-over

+
+
⬆
+

Drag & Drop Files Here

+
+

Border goes to full accent, fill to .06.

+ +

The nested-button guard

+
.drop-zone:hover:not(:has(button:hover))
+

The whole zone is a click target, so it must stop signalling "drop here" once the pointer is over a button inside it. This exact idiom appears in the File Converter, the Dropgate client and the Dropgate server. Note the matching JS guard: the zone's click handler returns early when the event originated on a button, or the browse dialog opens twice.

+ +

One zone per section, and the icon is the section's

+
+
+
+
⇄
+

Drag & Drop Files or Folders Here

+

or

+ +
+
Convert — the rail item's glyph, at 48px
+
+
+
+
⑂
+

Drag & Drop Files to Join Here

+

or

+ +
+
Join — same zone, its own glyph
+
+
+

In an app with a rail, the zone's icon is the rail item's icon. A section is named by one glyph, not two: whatever sits beside its label on the rail is what sits at the top of its empty state, in the accent at --opacity-muted. Never a generic upload glyph — File Converter's Convert zone showed upload_file under a rail item drawn as grid_view, and the two never met.

+

Sibling sections get the same zone, line for line. Icon, label, "or", button — same classes, same colours, same count of lines. Only the icon and the label change. A hint line that one section has and the other lacks makes one zone taller than the other, and the two sections stop reading as one app.

+ + diff --git a/docs/design-system/components/file-row.html b/docs/design-system/components/file-row.html new file mode 100644 index 0000000..20b3821 --- /dev/null +++ b/docs/design-system/components/file-row.html @@ -0,0 +1,88 @@ + + + +File Row & List + + +

File Row & List

+

The list primitive shared by File Converter and Dropgate, including the only custom scrollbar in the house style.

+ + +

Anatomy

+
+
▦holiday-montage-final-render-v3.mp4248 MB
+
▦beach.mov1.2 GB
+
+
♪soundtrack.flac42 MB
+
+

Left to right: 18px leading icon · flexible truncating name · fixed trailing meta · 20px circular destructive action. The name takes flex: 1 1 0 with min-width: 0 — without the min-width, a long filename refuses to shrink and pushes the meta out of the row. Hover the × to see the 120ms transition to danger.

+ +

Group divider

+

File Converter separates media kinds with a 4px filled bar rather than a heading, and clears the preceding row's border-bottom so the two rules do not stack.

+ +

Scrollbar

+ + + + + + +
PartValue
width6px
tracktransparent
thumbrgba(128,128,128,0.3), radius 3px
thumb:hoverrgba(128,128,128,0.5)
+

Deliberately theme-neutral grey so one rule works in both themes.

+ + diff --git a/docs/design-system/components/inputs.html b/docs/design-system/components/inputs.html new file mode 100644 index 0000000..d75a277 --- /dev/null +++ b/docs/design-system/components/inputs.html @@ -0,0 +1,83 @@ + + + +Inputs & Form Rows + + +

Inputs & Form Rows

+

Stock Bootstrap form controls, arranged in a fixed three-part row that all the apps repeat.

+ + +

The row

+
+ +
+
Leave empty to write next to each source file.
+
+
+ +
jobs at once
+
Detected 8 cores.
+
+

Always three parts, in this order: label.form-label above, full width · the control (optionally in an .input-group with a trailing button or addon) · a .form-text help line.

+ +

Help-line state vocabulary

+
+
neutral — form-text text-muted
+
success — text-success
+
warning — text-warning
+
error — text-danger
+
+

Reserve the space with min-height: 1.2em. Without it the whole form jumps when a validation message appears. This trick is used on every status row in Dropgate.

+

Disable, do not hide. When the server restricts an option, Dropgate disables the <option> and rewrites its label to "Unlimited (Disabled by Server)" rather than removing it, so the capability stays discoverable.

+ + diff --git a/docs/design-system/components/modal.html b/docs/design-system/components/modal.html new file mode 100644 index 0000000..1ec33ef --- /dev/null +++ b/docs/design-system/components/modal.html @@ -0,0 +1,88 @@ + + + +Modal Dialog + + +

Modal Dialog

+

The house has exactly one in-page HTML dialog, in the Dropgate client. Its conventions are worth following, because the alternative is a native blocking message box.

+ + +
+ +
+ +

Conventions

+ + + + + + + + + +
ElementRule
Header & footerborder-0 — no dividing rules
Dialogmodal-dialog-centered
TitleSemantic icon + text in d-flex align-items-center gap-2
BodyOne plain statement, then one text-body-secondary small mb-0 caveat
FooterOutlined neutral cancel on the left, filled semantic confirm on the right — never two filled buttons
BackdropBootstrap default — #000 at .5
MotionBootstrap default — backdrop .15s linear, dialog translate(0,-50px) → 0 over .3s ease-out
+

Wrap it in a promise. Dropgate's helper resolves true on confirm and false on hidden.bs.modal, removing both listeners in a shared cleanup, so the dialog reads as a plain await at the call site. This is the pattern the 2.0 "New Job" modal should use.

+ + diff --git a/docs/design-system/components/mode-tiles.html b/docs/design-system/components/mode-tiles.html new file mode 100644 index 0000000..07becc9 --- /dev/null +++ b/docs/design-system/components/mode-tiles.html @@ -0,0 +1,100 @@ + + + +Mode Tiles + + +

Mode Tiles

+

A mutually exclusive chooser built from tiles rather than radio inputs. The house has no radio groups — this is the pattern used wherever one of several modes must be picked.

+ + + +

States

+
+
◉
Manual settings
Set the format, quality and trim yourself
+
○
From a template
Start from something already saved
+
+

Selected · locked. Hover the left tile to see the idle-to-hover wash.

+ +
+
○
Idle
Neither chosen
+
◉
Focused
Keyboard focus ring
+
+ +

The recipe

+ + + + + + + + + + + +
PartValue
ContainerTwo equal columns, 0.5rem gap. Fixed 50/50 — tiles do not wrap.
Tile2px border, --radius-control, .75rem .5rem padding, column flex centred, height:100% so a taller description does not leave a short neighbour
Idle bordervar(--bs-border-color) — a theme token, never a fixed white alpha, or it breaks in light mode
Hoverborder rgba(accent, .5), fill rgba(accent, .04) — the same wash ladder as the card grid
Selectedborder accent plus box-shadow: 0 0 0 1px accent
Lockedpointer-events:none; opacity:.5 and a title saying why
Icon2rem, --bs-secondary-color → --accent when selected
Title / description0.875rem/600 and 0.7rem muted
Transition--dur-state --ease-state on border, shadow and background
+

Why a shadow as well as a border. Growing the border to 3px on selection would change the box size and shift everything beside it. A 1px shadow in the same colour sits outside the border and reads as a thicker edge with no reflow.

+ +

Behaviour

+

One source of truth. A click sets a single state variable, then every tile recomputes its own selected class from it. Do not toggle tiles against each other.

+

Strictly one or the other. Re-clicking the active tile does nothing. Craftbox's create-server source tiles are tri-state — clicking the selected one deselects back to "from scratch" — but that only works because it has a meaningful third state. Where there are exactly two modes, deselection would leave the form in no mode at all.

+

Fail closed, then unlock. A tile with nothing behind it ships locked and is enabled at runtime once its data exists — Craftbox locks Template until a template is saved. Enforce it in the handler as well as the CSS: pointer-events:none stops a mouse but not a keyboard or a programmatic activation.

+

Keyboard operable. role="button", tabindex="0" (-1 when locked), aria-pressed, and Enter/Space activation, with a :focus-visible ring. The original pattern has only role="button" and cannot be reached from the keyboard at all — do not copy that part.

+ +

Dependent sections

+

The chooser swaps which controls appear below it, by toggling d-none. Both branches stay in the DOM. Values in the hidden branch are kept, not cleared, so switching back and forth is lossless — but only the active branch is read when the work runs, and the card shows only the active branch.

+ + diff --git a/docs/design-system/components/progress.html b/docs/design-system/components/progress.html new file mode 100644 index 0000000..94a72e4 --- /dev/null +++ b/docs/design-system/components/progress.html @@ -0,0 +1,71 @@ + + + +Progress + + +

Progress

+

Three surfaces report progress, and the house uses all three at once for long operations.

+ + +

Determinate

+
+
File 3 of 862%
+ +

Indeterminate

+
+
Converting…
+

Use this whenever the duration is unknown — still images, streams with no reported length. File Converter v1 had no indeterminate state, so its bar sat frozen at 0% for the whole job whenever ffmpeg could not report a percentage, which read as a hang.

+ +

Window chrome as a progress surface

+
+
Dropgate Client — Uploading 62%
+
+
+

Dropgate sets both the window title and win.setProgressBar(), which lights the Windows taskbar button. On completion it passes { mode: 'error' } to turn the taskbar bar red, then clears it with -1 after 3000ms. This is the right pattern for a long convert queue — progress stays visible when the window is behind something else.

+ + diff --git a/docs/design-system/components/status-card.html b/docs/design-system/components/status-card.html new file mode 100644 index 0000000..57f1ce5 --- /dev/null +++ b/docs/design-system/components/status-card.html @@ -0,0 +1,69 @@ + + + +Status Card + + +

Status Card

+

Dropgate's signature component: an ambient pulse while a state is unknown, resolving into a semantic wash once it is known.

+ + +

Loading, then resolved

+
◌Checking security…
+
✓Your upload will be end-to-end encrypted.
+
⚠This server does not advertise encryption.
+
✕This connection is not secure.
+ +

The pattern: pulse while unknown, then kill the animation and apply the wash. The resolved state sets three things at once — the icon glyph, a Bootstrap text colour on the icon, and the wash class on the card. Because the layout is identical in every state, resolving causes no reflow.

+ +

Why it generalises

+

This is the natural shape for a File Converter job card's status strip: unknown while the probe is in flight, then green for ready, yellow for "will re-encode", red for an unsupported combination. The ambient pulse is doing real work — it distinguishes "still checking" from "checked and fine", which a static grey card cannot.

+ + diff --git a/docs/design-system/components/toast.html b/docs/design-system/components/toast.html new file mode 100644 index 0000000..23695f0 --- /dev/null +++ b/docs/design-system/components/toast.html @@ -0,0 +1,73 @@ + + + +Toast + + +

Toast

+

Currently only in the Dropgate server. The clearest gap in the desktop apps, which have no transient feedback at all.

+ + +

Variants

+
Queued 24 files from 3 folders.
+
Conversion complete — 24 files written.
+
4 files skipped: unsupported format.
+
Could not write to the output folder.
+ +

Placement

+ + + + + + + +
PropertyValue
positionfixed, top: 16px, left: 50%, translateX(-50%)
widthmin(720px, calc(100% - 2rem))
z-index1080 (above Bootstrap's modal backdrop)
accent bar4px left border in the semantic colour
dismiss4500ms
+

Guard the dismissal against clobbering. The server's implementation snapshots the toast's text before the timeout and only hides the element if the text is still the same — otherwise a second toast raised inside the window gets cut short by the first one's timer.

+

Note this is a full-fill toast, not Bootstrap's default outlined .alert: the semantic colour is the background, with the left border reinforcing it. Warning and info take black text; success and danger take white.

+ + diff --git a/docs/design-system/components/toggle.html b/docs/design-system/components/toggle.html new file mode 100644 index 0000000..d81f5b5 --- /dev/null +++ b/docs/design-system/components/toggle.html @@ -0,0 +1,81 @@ + + + +Toggle Switch + + +

Toggle Switch

+

The one genuinely custom control in the house style. Currently Media Player only — worth promoting to the shared layer.

+ + +

States

+
+ + +

Both are live — click them.

+
+ +

Geometry

+ + + + + + + + +
PartValue
Track48 × 28, radius 34px
Knob20 × 20, radius 50%, inset 4px
TraveltranslateX(20px)
Easingcubic-bezier(.4, 0, .2, 1) over 0.3s
Knob shadow0 1px 4px rgba(0,0,0,.15)
Checked trackvar(--accent)
+

Row layout is label-left, control-right, wrapped in a single <label> with display:flex; gap:12px so the text is part of the hit target. The real input is visually hidden rather than removed, which keeps it keyboard-focusable.

+ + diff --git a/docs/design-system/foundations/colour.html b/docs/design-system/foundations/colour.html new file mode 100644 index 0000000..28314ba --- /dev/null +++ b/docs/design-system/foundations/colour.html @@ -0,0 +1,86 @@ + + + +Colour & Accent + + +

Colour & Accent

+

Bootstrap 5.3's custom properties are the semantic base in all three apps. The house adds one thing on top: a per-app accent, bound into --bs-primary in both themes.

+ +

The per-app accent

+

House rule: per-app accent, everything else shared. An app overrides these four values and nothing else. Accent is the app's identity in the taskbar; structure, motion and components stay identical.

+ + + + + +
App--accent--accent-hover--accent-muted
Diamond File Converter#28a745#34d058#5dd879
Diamond Media Player#9740fb#b06cff#c796ff
Dropgate client#0d6efd#0a58ca#6ea8fe
+ +

Binding the accent into Bootstrap

+
:root,
+[data-bs-theme="light"],
+[data-bs-theme="dark"] {
+    --bs-primary:     var(--accent);
+    --bs-primary-rgb: var(--accent-rgb);
+    --bs-link-color:  var(--accent);
+}
+

Both themes, and the bare :root. File Converter v1 remapped --bs-primary only inside [data-bs-theme="dark"], so its primary button rendered Bootstrap blue in light mode and green in dark. Listing the bare :root alongside both themes also covers the "system" case, where neither attribute is set. Bootstrap derives .btn-primary from its own component variables rather than from --bs-primary, so the button additionally needs --bs-btn-bg set.

+ +

Semantic colours

+ + + + + + +
RoleHexRGB triplet
Success#19875425, 135, 84
Warning#ffc107255, 193, 7
Danger#dc3545220, 53, 69
Info#0dcaf013, 202, 240
+ +

Surfaces

+ + + + + + +
TokenLightDark
--bs-body-bg #fff #212529
--bs-secondary-bg #e9ecef #343a40
--bs-tertiary-bg #f8f9fa #2b3035
--bs-border-color #dee2e6 #495057
+

Theme is driven by data-bs-theme on <html>, set by an inline script in <head> before the stylesheet parses so there is no flash. All three apps follow the OS only — none has an in-app theme toggle or persists a preference.

+ + diff --git a/docs/design-system/foundations/motion.html b/docs/design-system/foundations/motion.html new file mode 100644 index 0000000..0ccbe8f --- /dev/null +++ b/docs/design-system/foundations/motion.html @@ -0,0 +1,97 @@ + + + +Motion + + +

Motion

+

Five durations and three easings. The duration says what kind of change it is, so pick by meaning rather than by feel.

+ + +

Durations — hover each row

+
+
120ms Micro — hover and press on small controls (row delete, icon buttons)
+
0.2s State — a surface changing meaning (drop zone arming)
+
0.3s Default — everything else
+
+ + + + + + + +
DurationNameUsed for
120ms easemicroSmall-control hover
0.2s ease-in-outstateSurface state change
0.3sdefaultGeneral transitions
0.4s easeenterPage-load fade-in
2s ease-in-out ∞ambientLoading pulse / breathe
+ +

Easing

+
+
cubic-bezier(.4, 0, .2, 1)
knob travel — the only custom curve
+
ambient pulse — "still loading"
+
◠
spin 1.4s linear — work in progress
+
+ +

Timings shared with the JS side

+ + + + + + + +
ValueMeaning
500msDebounce — window-bounds saves, Explorer multi-file batching
3000msProgress bar reset after completion
4500msToast auto-dismiss
2000msIdle before the video control bar hides
5000msDelay before the startup update check
+

Disabled and busy is opacity: 0.5 plus pointer-events: none — applied as inline style, not a class.

+ + diff --git a/docs/design-system/foundations/spacing-radii.html b/docs/design-system/foundations/spacing-radii.html new file mode 100644 index 0000000..8991cba --- /dev/null +++ b/docs/design-system/foundations/spacing-radii.html @@ -0,0 +1,78 @@ + + + +Spacing & Radii + + +

Spacing & Radii

+

Three radii carry almost everything. Spacing is Bootstrap's utility scale in newer code and ad-hoc pixels in older code.

+ +

Radii

+
+
5px
+
8px
+
12px
+
50%
+
+ + + + + + + +
TokenValueApplies to
--radius-control5pxButtons, selects, popup controls
--radius-card8pxFile lists, cards, panels
--radius-zone12pxDrop zone
—50%Circular row actions, toggle knob
—3pxScrollbar thumb, progress track
+ +

Spacing

+

Newer code (Dropgate, File Converter) uses Bootstrap utilities — p-4, mb-3, gap-2, d-grid. Media Player uses ad-hoc pixels: 2, 4, 6, 10, 12, 15, 18, 20, 28, 40. Bootstrap utilities are the direction of travel.

+ + + + + + + +
ContextValue
Page paddingp-4 (1.5rem), dropping to .75rem below 500px
Block rhythmmb-3 between stacked sections
Drop zone padding2.25rem 1.5rem
List row padding.35rem 0, gap: .5rem
Content max-width580px (File Converter) / 650px (Dropgate)
+ +

Shadows

+

The house barely uses shadows. The Dropgate client has none at all; depth comes from borders plus a wash. Media Player has exactly two: 0 -2px 10px rgba(0,0,0,.3) on the floating control bar and 0 1px 4px rgba(0,0,0,.15) on the toggle knob.

+ + diff --git a/docs/design-system/foundations/type.html b/docs/design-system/foundations/type.html new file mode 100644 index 0000000..542ff47 --- /dev/null +++ b/docs/design-system/foundations/type.html @@ -0,0 +1,81 @@ + + + +Typography + + +

Typography

+

One font stack, no webfonts, one custom weight. The scale is small because these are dense utility apps, not content sites.

+ +

Stack

+
+
Diamond Digital Development
+ 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif +

Windows system stack, no webfont loaded. Dropgate's main window omits the declaration entirely and inherits Bootstrap's --bs-font-sans-serif; its popup pages pin the stack above. Standardise on the explicit stack.

+
+ +

Scale

+ + + + + + + + +
UseSizeSample
Section label (uppercase)0.7remVideo
Meta / secondary0.875rem3 Files Queued
Body1remDrag & Drop Files Here
Drop label1rem / 500Drag & Drop Files Here
Page heading1.25remDiamond File Converter
Popup heading24pxPreferences
+

500 is the only custom weight in the entire house style. Everything else is Bootstrap's default 400 or a heading default. Media Player sizes in px; Dropgate sizes in rem — rem is the one to standardise on. Letter-spacing appears exactly twice: .1em on uppercase section labels and .5px on Media Player's slider labels.

+ +

Icons

+
+

Material Icons Round, bundled locally from node_modules/material-icons/iconfont/round.css — never the Google Fonts CDN.

+ + + + + + + +
SizeUse
48pxHero / empty-state drop icon
28pxSecondary hero (populated drop zone)
24pxDefault — status cards, transport controls
18pxList row leading icon
14pxInside a 20px circular button
+

Media Player uses <i>, Dropgate uses <span> — an inconsistency worth settling on <span>. Icons are recoloured with text utilities (text-success, text-danger), never custom hexes.

+
+ + diff --git a/docs/design-system/foundations/wash-ladder.html b/docs/design-system/foundations/wash-ladder.html new file mode 100644 index 0000000..d06afba --- /dev/null +++ b/docs/design-system/foundations/wash-ladder.html @@ -0,0 +1,65 @@ + + + +Wash Formulas + + +

Wash Formulas

+

Two formulas cover every tinted surface in the house style. Both are alpha over a token's RGB triplet, never a new hex value.

+ +

Interaction ladder

+
+
+
hover
fill .04 · border .5
+
+
active / drag-over
fill .06 · border 1
+
+

Applied as rgba(var(--accent-rgb), <alpha>). The drop zone is the canonical user: idle border is the neutral --bs-border-color, hover tints it halfway, an active drag takes it to full accent.

+ +

Semantic wash

+
+
Success — rgba(25,135,84,.1) fill, rgba(25,135,84,.3) border
+
Warning — rgba(255,193,7,.1) fill, rgba(255,193,7,.3) border
+
Danger — rgba(220,53,69,.1) fill, rgba(220,53,69,.3) border
+
+

The formula is fill 0.1, border 0.3, always. Because both derive from the token's RGB triplet, a semantic state needs no new colour value and stays correct in both themes.

+ + diff --git a/docs/design-system/index.html b/docs/design-system/index.html new file mode 100644 index 0000000..fc60a73 --- /dev/null +++ b/docs/design-system/index.html @@ -0,0 +1,85 @@ + + + +Diamond Digital — Electron House Style + + +

Diamond Digital — Electron House Style

+

The shared visual language of Diamond Media Player, the Dropgate client and Diamond File Converter. Extracted from the three codebases on 2026-08-27. 22 cards.

+ +

Foundations

+
+
Colour & Accent foundations/colour.html
+
Wash Formulas foundations/wash-ladder.html
+
Typography foundations/type.html
+
Spacing & Radii foundations/spacing-radii.html
+
Motion foundations/motion.html
+
+ +

Components

+
+
Buttons components/buttons.html
+
Drop Zone components/drop-zone.html
+
File Row & List components/file-row.html
+
Toggle Switch components/toggle.html
+
Status Card components/status-card.html
+
Progress components/progress.html
+
Toast components/toast.html
+
Modal Dialog components/modal.html
+
Mode Tiles components/mode-tiles.html
+
Inputs & Form Rows components/inputs.html
+
+ +

Shell

+
+
Window Chrome shell/window-chrome.html
+
Popup Window shell/popup-window.html
+
Context Menu shell/context-menu.html
+
Navigation Rail shell/nav-rail.html
+
+ +

Patterns

+
+
Settings Placement patterns/settings-placement.html
+
Feedback & Error Surfacing patterns/feedback.html
+
Theming patterns/theme.html
+
+ diff --git a/docs/design-system/patterns/feedback.html b/docs/design-system/patterns/feedback.html new file mode 100644 index 0000000..fe48ca4 --- /dev/null +++ b/docs/design-system/patterns/feedback.html @@ -0,0 +1,68 @@ + + + +Feedback & Error Surfacing + + +

Feedback & Error Surfacing

+

The weakest area of the house style. Everything is a native blocking dialog, which does not scale to batch operations.

+ +

What exists today

+ + + + + + + +
SituationCurrent treatmentVerdict
Update available / readyNative dialog.showMessageBoxFine — rare, needs a decision
Output file existsNative dialog: Cancel / Overwrite / Save as NewFine for one file, painful for fifty
Conversion failedNative dialog, one per failed file, mid-queueWrong — blocks the batch
Unsupported file droppedNothing at allWrong — silent rejection
Background operation doneOS Notification when unfocused (Dropgate only)Good — worth copying
+ +

The rule to adopt

+

A blocking dialog is for a decision only the user can make, right now. Anything reporting what already happened belongs in a toast or an inline status row. A failure inside a batch is a report, not a decision — it should mark the job and let the queue continue, with the detail available afterwards.

+ +

Two specific traps

+

body { user-select: none } makes error text uncopyable. File Converter sets it globally to stop text selection dragging the UI, which also means a user cannot copy the ffmpeg error to report it. Scope the rule to the chrome and let messages and paths stay selectable.

+

A per-file conflict prompt does not scale. Offer "apply to all remaining" on the first prompt, or resolve the whole batch up front, before any work starts.

+ +

Silent failure is the worst outcome

+

Two real examples worth remembering. File Converter drops unsupported files from a drop with no feedback whatsoever — the user sees nothing happen and cannot tell the app from a broken one. And its debug.log is the only record of a failure, with no way to open it from inside the app. If the app knows something went wrong, the user must be able to find out what.

+ + diff --git a/docs/design-system/patterns/settings-placement.html b/docs/design-system/patterns/settings-placement.html new file mode 100644 index 0000000..9493640 --- /dev/null +++ b/docs/design-system/patterns/settings-placement.html @@ -0,0 +1,74 @@ + + + +Settings Placement + + +

Settings Placement

+

The house has two competing answers. Both are legitimate; the choice follows how often the setting changes.

+ +

Two patterns in use

+
+
+
Media Player
+

Separate popup window

+

A modal child BrowserWindow at 400 × 300, opened from the native menu. Flat layout: heading, then label + control stacked. No save button — changes persist on change.

+
+
+
Dropgate
+

Settings as page

+

Server URL, lifetime and max downloads are first-class rows on the main screen, above the action button. Persisted on input, blur and change.

+
+
+ +

The rule

+ + + + +
If the setting is…Put it…
Changed most times the app is used, and affects the pending actionOn the page, as a form row
Configured once and forgottenIn the preferences popup window
+

Dropgate's lifetime and download-cap settings change per upload, so burying them in a popup would be wrong. Media Player's visualiser quality is set once, so putting it on the player surface would be clutter.

+

Neither pattern has a save button. Persist on change and reflect the result immediately; a modal save step is not the house style.

+ +

Applied to File Converter 2.0

+

Per-job encoding options belong in the New Job modal — changed constantly, scoped to one job. App-wide defaults (output routing, concurrency, conflict policy, name template) belong in a preferences popup window, since they are set once. Output folder is the borderline case, and it argues for the page: it changes often enough to deserve a visible row.

+ + diff --git a/docs/design-system/patterns/theme.html b/docs/design-system/patterns/theme.html new file mode 100644 index 0000000..0501b88 --- /dev/null +++ b/docs/design-system/patterns/theme.html @@ -0,0 +1,73 @@ + + + +Theming + + +

Theming

+

System-following only, set before first paint. No app has a manual toggle — a known gap rather than a decision.

+ +

The bootstrap script

+
<script>
+  (() => {
+    const mql = window.matchMedia("(prefers-color-scheme: dark)");
+    const root = document.documentElement;
+    const apply = () => root.setAttribute("data-bs-theme", mql.matches ? "dark" : "light");
+    apply();
+    mql.addEventListener?.("change", apply);
+  })();
+</script>
+

Inline in <head>, before the stylesheet link. Deferring it to a bundle or to DOMContentLoaded produces a flash of the wrong theme. The subscription matters as much as the initial call: these are long-running desktop apps, and the OS theme can flip under them at sunset.

+

Dropgate's newer spelling is the one to standardise on — an IIFE with optional chaining on addEventListener. It also pre-sets data-bs-theme="auto" on <html> as a placeholder.

+ +

Scoped theme islands

+

A subtree can opt out with its own data-bs-theme. Media Player forces its transport bar dark regardless of app theme, because it overlays video: <div id="controls" data-bs-theme="dark">. Use this rather than hardcoding hexes when a region must stay dark.

+ +

Known gaps

+ + + + + +
GapConsequence
No manual toggle in any appA user on a light OS cannot get a dark app
No persisted preferenceNothing to restore even if a toggle were added
Hardcoded hexes bypass themingMedia Player's meters and Dropgate's two legacy greys (#5a5a5a, #535353) do not respond to theme
+

The fix for the third is the token layer: every colour resolves through a variable, so a theme change reaches all of them.

+ + diff --git a/docs/design-system/shell/context-menu.html b/docs/design-system/shell/context-menu.html new file mode 100644 index 0000000..6eeb878 --- /dev/null +++ b/docs/design-system/shell/context-menu.html @@ -0,0 +1,82 @@ + + + +Context Menu + + +

Context Menu

+

The only context menu in the house style is a Windows Explorer shell extension written at install time. No app has an in-app one.

+ + +
+
Open
+
Edit
+
+
Convert with Diamond File Converter
+
Open with Diamond Media Player
+
Share with Dropgate
+
+ +

How it is registered

+
!macro customInstall
+  WriteRegStr HKCR "*\shell\<App>" "" "<Verb> <Product>"
+  WriteRegStr HKCR "*\shell\<App>" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
+  WriteRegStr HKCR "*\shell\<App>\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" "%1"'
+!macroend
+
+!macro customUninstall
+  DeleteRegKey HKCR "*\shell\<App>"
+!macroend
+

Made by electron-kit's config() from contextMenu in electron-builder.cjs: the installer asks before adding it, and uninstalling takes it away. An app never writes its own installer.nsh.

+ +

The batching problem

+

Windows launches one process per selected file. Selecting 30 files spawns 30 processes, 29 of which hit the single-instance lock and forward their path through second-instance. Collect those arrivals and debounce for 500ms before handing them to the renderer, or the queue is rebuilt 30 times. Dropgate's BATCH_DEBOUNCE_MS is the reference.

+ +

Gap

+

There is no in-app context menu anywhere — no contextmenu listener, no Menu.popup(). A right-click menu on File Converter's 2.0 job grid would be the first, and needs a house decision on native Menu.popup versus a custom HTML menu.

+ + diff --git a/docs/design-system/shell/nav-rail.html b/docs/design-system/shell/nav-rail.html new file mode 100644 index 0000000..a77c001 --- /dev/null +++ b/docs/design-system/shell/nav-rail.html @@ -0,0 +1,110 @@ + + + +Navigation Rail + + +

Navigation Rail

+

A persistent left rail for an app with more than one top-level surface. Sections are siblings you switch between, never a view you have to back out of.

+ + + +

Expanded and collapsed

+
+
+
+
⇄Convert
+
⑂Join
+
‹Collapse
+
+

Convert

The active section fills the rest of the window.

+
+
+
+
⇄Convert
+
⑂Join
+
›Expand
+
+

Collapsed

Icons only, label moved to title.

+
+
+ +

The recipe

+ + + + + + + + + + +
PartValue
Rail168px expanded, 56px collapsed, --dur-state --ease-state on width
Surface--bs-tertiary-bg with a --bs-border-color right edge — a step back from the content, not a block of accent
Item.5rem .6rem padding, --radius-control, icon 20px + label, .6rem gap, line-height: 20px — see "Two jitters" below
IconThe section's one glyph: the same icon heads that section's empty-state drop zone. Convert is swap_horiz, Join is merge_type
Hoverborder rgba(accent, .5), fill rgba(accent, .04) — the standard wash ladder
Activeborder rgba(accent, 1), fill rgba(accent, .06), and the label in --accent
Collapse controlPinned with margin-top:auto, muted, chevron rotated 180° when collapsed, with --dur-state on the transform so it turns rather than flips
Focusbox-shadow: 0 0 0 .2rem rgba(accent, .35) on :focus-visible, outline:none
+ +

Two jitters, and what stops them

+

Pin the item's line box to the icon. Bootstrap's 1.5 line-height makes a .9rem label 21.6px tall against a 20px icon, and the label is what sets the item's height. Collapse hides the label, every item shrinks by that 1.6px, and every icon below it creeps up. line-height: 20px on the item makes the expanded and collapsed states the same height, so the icons hold still while the width animates.

+

Nothing in the header may change height with the view. The header's toolbar is shared by every section and routed to whichever is showing, never hidden per view. File Converter hid Add Files / Add Folder on Join, which took the buttons' 31px out of a header whose title is 24px — so the title and everything under it jumped 7px on every switch. If a section genuinely has no use for a control, reserve its height; do not remove it.

+ +

Where it sits in the shell

+

The rail is outside the padded content column, so it reaches the window edge. That means the shell is no longer the outermost element: an .app-frame row wraps the rail and the column, and the column keeps the padding, the header, the content and the footer. Give the column min-width:0 or a wide child stretches it and the whole window scrolls sideways.

+ +

Switching

+

One attribute decides what is visible. A data-view on the shell, and CSS keyed off it. Where the sections' own elements already manage a d-none of their own, the view rules force-hide with !important rather than competing — each section keeps its internal state and returns to it intact instead of being rebuilt.

+

Every route out goes through one function. A rail click, a link from a dialog and a file drop all call the same switcher, so an unsaved-work guard cannot be sidestepped by arriving a different way. A drop onto a non-file section runs the guard, switches, then ingests — and a declined guard cancels the drop rather than ingesting behind it.

+

The keyboard follows the view. A global key handler that already bails while a modal is open must bail outside the active section too, or Delete acts on something the user cannot see.

+

Collapse is remembered. A narrow window is exactly where someone collapses it, and exactly where being made to do it again every launch would grate. Persist it with the app's normal settings store.

+

Do not ship a destination that goes nowhere. A section gets a rail item when it is built, never before. A greyed "coming soon" item is a dead end the user cannot resolve, and a rail with a single destination is at least honest — it costs a strip of width and promises nothing it cannot do.

+ + diff --git a/docs/design-system/shell/popup-window.html b/docs/design-system/shell/popup-window.html new file mode 100644 index 0000000..136721a --- /dev/null +++ b/docs/design-system/shell/popup-window.html @@ -0,0 +1,91 @@ + + + +Popup Window + + +

Popup Window

+

Credits and preferences are real child BrowserWindows, not in-page modals. The recipe and its CSS are copied near-verbatim between apps.

+ + +
+
+

Preferences

+ + +
Media Player — 400 × 300
+
+
+

Diamond File Converter

+

• Created and maintained by Diamond Digital Development.

+
+ +
Credits — 750 × 450
+
+
+ +

The BrowserWindow recipe

+
parent: mainWindow, modal: true,
+resizable: false, minimizable: false,
+maximizable: false, fullscreenable: false
+→ win.setMenu(null)
+→ win.on("minimize", e => { e.preventDefault(); win.show(); win.focus(); })
+→ page listens for Escape and calls window.close()
+

The anti-minimize guard matters. Because the window is modal to its parent, letting it minimise would strand the app with an invisible blocker. Modality, backdrop and focus trapping all come from the OS, so there is no overlay CSS and no enter/exit animation.

+ +

Shared CSS

+

The .popup-window block is duplicated in all three apps with only six points of drift. The "link" is a bare <span> with a click handler, not an <a> — it takes the accent-muted colour and underlines on hover. Under context isolation these must be data-href plus a listener rather than inline onclick, since the page has no shell.

+ + diff --git a/docs/design-system/shell/window-chrome.html b/docs/design-system/shell/window-chrome.html new file mode 100644 index 0000000..86d1af0 --- /dev/null +++ b/docs/design-system/shell/window-chrome.html @@ -0,0 +1,90 @@ + + + +Window Chrome + + +

Window Chrome

+

Every app uses a standard OS-framed window and the native menu bar. That is a deliberate house choice, not an omission.

+ + +
+
Diamond File Converter─ □ ✕
+ +
Single-column content, centred, max-width 580–650px.
+
+ +

Rules

+ + + + + + + +
DecisionHouse position
Frameless / custom titlebarNo. No app sets frame:false or titleBarStyle.
NavigationNative Menu.buildFromTemplate. No sidebar, no tabs, no router.
LayoutOne centred column of .mb-3 blocks.
BoundsPersisted to electron-store, debounced 500ms on resize/move, written immediately on close.
Single instancerequestSingleInstanceLock(); second-instance restores and focuses.
+ +

Menu template

+
Menu
+  Open File          Ctrl+O
+  ─────────
+  Check for Updates
+  ─────────
+  Exit               Alt+F4   (role: quit)
+Credits                        (flat top-level item)
+

Identical across all three apps, down to the separator positions. Credits is a flat top-level item, not a submenu — clicking the label opens the window directly.

+ +

Window sizes

+ + + + + +
AppDefaultMinimum
Diamond Media Player1280 × 7751080 × 775
Diamond File Converter700 × 1000600 × 600
Dropgate client600 × 900400 × 700
+

Media Player is wide because it shows video; the two utility apps are tall single columns.

+ + diff --git a/electron-builder.cjs b/electron-builder.cjs new file mode 100644 index 0000000..f136772 --- /dev/null +++ b/electron-builder.cjs @@ -0,0 +1,61 @@ +"use strict"; + +// The desktop app's electron-builder config, made by the kit's config() on its +// base (NSIS in the Diamond Digital Development menu folder; AppImage, .deb and +// .rpm). It's .cjs, never .js: on Windows, electron-builder typed in this +// folder would run electron-builder.js with Windows Script Host instead. +// +// - The installer asks who it's for (an install for everyone, as every one +// before 2.0.0-beta.6 was, is upgraded in place), then which of the 22 types +// to open and whether to add "Convert with Diamond File Converter" to the +// menu files and folders have when they're right-clicked. It replaced +// installer.nsh, and keeps its registry key, so an upgrade never shows the +// entry twice. On Linux, the app is in Open With for each type. +// - ffmpeg and ffprobe are packed per platform, where main.js looks for them; +// the packages' own binaries stay out of app.asar.unpacked (415 MB in 2.0.0). +const { config } = require("@diamonddigitaldev/electron-kit/builder"); + +module.exports = config(require("./package.json"), { + build: { + appId: "com.diamonddigitaldev.diamondfileconverter", + productName: "Diamond File Converter", + artifactName: "Diamond-File-Converter-${version}.${ext}", + files: [ + "**/*", + "!test-media", + "!test-media/**/*", + "!node_modules/ffmpeg-static/ffmpeg{,.exe}", + "!node_modules/ffprobe-static/bin/**", + ], + publish: { + provider: "github", + owner: "diamonddigitaldev", + repo: "Diamond-File-Converter", + }, + win: { + icon: "src/assets/diamondfileconverter.ico", + extraResources: [ + { from: "node_modules/ffmpeg-static/ffmpeg.exe", to: "ffmpeg/ffmpeg.exe" }, + { from: "node_modules/ffprobe-static/bin/win32/x64/ffprobe.exe", to: "ffmpeg/ffprobe.exe" }, + ], + }, + linux: { + icon: "src/assets/diamondfileconverter.png", + category: "AudioVideo", + extraResources: [ + { from: "node_modules/ffmpeg-static/ffmpeg", to: "ffmpeg/ffmpeg" }, + { from: "node_modules/ffprobe-static/bin/linux/x64/ffprobe", to: "ffmpeg/ffprobe" }, + ], + }, + // The installer's name is what its update files point at: unchanged from 2.0.0. + nsis: { + artifactName: "Diamond-File-Converter-Setup-${version}.${ext}", + }, + }, + fileTypes: [ + { name: "Audio File", ext: ["mp3", "wav", "flac", "ogg", "aac", "m4a", "opus", "wma"] }, + { name: "Video File", ext: ["mp4", "mkv", "webm", "avi", "mov", "wmv", "flv"] }, + { name: "Image File", ext: ["jpg", "jpeg", "png", "webp", "gif", "bmp", "tiff"] }, + ], + contextMenu: { label: "Convert with Diamond File Converter", folders: true }, +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ae1d6ca --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3696 @@ +{ + "name": "diamond-file-converter", + "version": "2.0.0-beta.9", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "diamond-file-converter", + "version": "2.0.0-beta.9", + "license": "Apache-2.0", + "dependencies": { + "@diamonddigitaldev/electron-kit": "0.1.0-beta.8", + "bootstrap": "^5.3.3", + "electron-store": "^11.0.2", + "electron-updater": "^6.8.9", + "ffmpeg-static": "^5.2.0", + "ffprobe-static": "^3.1.0", + "material-icons": "^1.13.14" + }, + "devDependencies": { + "electron": "^44.4.5", + "electron-builder": "^26.15.3" + }, + "funding": { + "type": "buymeacoffee", + "url": "https://www.buymeacoffee.com/willtda" + } + }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "license": "MIT", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@diamonddigitaldev/electron-kit": { + "version": "0.1.0-beta.8", + "resolved": "https://registry.npmjs.org/@diamonddigitaldev/electron-kit/-/electron-kit-0.1.0-beta.8.tgz", + "integrity": "sha512-fqGnYFxONG5eg76QT0ZvZTdcdapHy3Mxlm4vvIMDA7Z9x6s0a6RE5gnoq6dXLc6iqelrmc1HvbaYs4AB2FDYcw==", + "license": "Apache-2.0", + "engines": { + "node": ">=24.0.0" + }, + "funding": { + "type": "buymeacoffee", + "url": "https://www.buymeacoffee.com/willtda" + }, + "peerDependencies": { + "bootstrap": "^5.3.0", + "electron": ">=44.0.0", + "electron-store": "^11.0.2", + "electron-updater": "^6.8.9", + "material-icons": "^1.13.14" + }, + "peerDependenciesMeta": { + "electron-updater": { + "optional": true + } + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "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", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.18", + "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", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "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", + "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/fuses/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/fuses/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/get": { + "version": "5.1.0", + "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/get/node_modules/env-paths": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@electron/get/node_modules/undici": { + "version": "7.29.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "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", + "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/notarize/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "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/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "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", + "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", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "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/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "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/universal/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "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", + "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/@electron/windows-sign/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/windows-sign/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "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", + "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", + "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/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "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", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "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", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.0", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "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/ajv-formats": { + "version": "3.0.1", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "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", + "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", + "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", + "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", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "license": "Python-2.0" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "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", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/atomically": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "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", + "dev": true, + "license": "MIT" + }, + "node_modules/boolean": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.15.3", + "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", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "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", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "license": "Apache-2.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "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", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "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", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/conf": { + "version": "15.1.0", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "atomically": "^2.0.3", + "debounce-fn": "^6.0.0", + "dot-prop": "^10.0.0", + "env-paths": "^3.0.0", + "json-schema-typed": "^8.0.1", + "semver": "^7.7.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/env-paths": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "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", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debounce-fn": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "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", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "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", + "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", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "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", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.18", + "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", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.15.3", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dot-prop": { + "version": "10.1.0", + "license": "MIT", + "dependencies": { + "type-fest": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "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/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "44.4.5", + "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": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.15.3", + "dev": true, + "license": "MIT", + "dependencies": { + "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": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "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/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "26.15.3", + "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/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-store": { + "version": "11.0.2", + "license": "MIT", + "dependencies": { + "conf": "^15.0.2", + "type-fest": "^5.0.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron-updater": { + "version": "6.8.9", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.0", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "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", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "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", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/ffmpeg-static": { + "version": "5.3.0", + "hasInstallScript": true, + "license": "GPL-3.0-or-later", + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/ffmpeg-static/node_modules/agent-base": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ffmpeg-static/node_modules/https-proxy-agent": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ffprobe-static": { + "version": "3.1.0", + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.6", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "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/fs.realpath": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "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", + "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", + "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", + "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", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "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", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "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", + "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", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "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", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "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", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "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.4", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-response-object": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "license": "MIT" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "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", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "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", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "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/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "license": "BSD-2-Clause" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/material-icons": { + "version": "1.13.14", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "4.35.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "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/isexe": { + "version": "4.0.0", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "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", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "9.0.0", + "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", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-cache-control": { + "version": "1.0.1" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "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", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkijs": { + "version": "3.4.0", + "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", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "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/postject": { + "version": "1.0.0-alpha.6", + "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", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "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", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "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-alpn": { + "version": "1.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "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/safe-buffer": { + "version": "5.2.1", + "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/sanitize-filename": { + "version": "1.6.4", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "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", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "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", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "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", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "5.5.0", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "license": "MIT" + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "6.28.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.5", + "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", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/unzipper/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/unzipper/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "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/when-exit": { + "version": "2.1.5", + "license": "MIT" + }, + "node_modules/which": { + "version": "5.0.0", + "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", + "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", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "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", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index 5e80c70..bfeecb0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "diamond-file-converter", - "version": "1.0.0", - "description": "An Electron-based Windows app for converting audio, video, and image files.", + "version": "2.0.0-beta.9", + "description": "An Electron-based desktop app for converting audio, video, and image files.", "main": "src/main.js", "funding": { "type": "buymeacoffee", @@ -9,52 +9,31 @@ }, "scripts": { "start": "electron .", - "build": "electron-builder" + "build": "electron-builder", + "test": "node --test" }, "repository": { "type": "git", "url": "https://github.com/diamonddigitaldev/Diamond-File-Converter" }, - "build": { - "appId": "com.diamonddigitaldev.diamondfileconverter", - "productName": "Diamond File Converter", - "artifactName": "Diamond-File-Converter-Setup-${version}.${ext}", - "extraResources": [ - { - "from": "node_modules/ffmpeg-static/ffmpeg.exe", - "to": "ffmpeg/ffmpeg.exe" - } - ], - "publish": { - "provider": "github", - "owner": "diamonddigitaldev", - "repo": "Diamond-File-Converter" - }, - "win": { - "target": [ - "nsis" - ], - "icon": "src/assets/diamondfileconverter.ico" - }, - "nsis": { - "perMachine": true, - "createDesktopShortcut": true, - "menuCategory": "Diamond Digital Development" - } - }, "keywords": [], "author": "Diamond Digital Development ", "license": "Apache-2.0", "devDependencies": { - "electron": "^41.1.0", - "electron-builder": "^26.8.1" + "electron": "^44.4.5", + "electron-builder": "^26.15.3" }, "dependencies": { + "@diamonddigitaldev/electron-kit": "0.1.0-beta.8", "bootstrap": "^5.3.3", "electron-store": "^11.0.2", - "electron-updater": "^6.8.3", + "electron-updater": "^6.8.9", "ffmpeg-static": "^5.2.0", - "fluent-ffmpeg": "^2.1.3", + "ffprobe-static": "^3.1.0", "material-icons": "^1.13.14" + }, + "allowScripts": { + "ffmpeg-static": true, + "electron-winstaller": true } } diff --git a/src/constants.js b/src/constants.js index 56b1271..683d0fe 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,97 +1,141 @@ const APP_NAME = "Diamond File Converter"; -const LOG = { - ERROR: "ERROR", - WARN: "WARN", - INFO: "INFO", - DEBUG: "DEBUG", -}; - -// ipc channel names +// IPC channel names: the app's own, each answered in main.js through +// kit.ipc.handle(), which answers the app's own page only. The shared ones +// (app:get-version, settings:get and :set, shell:open-external, theme:changed, +// view:show, the updater's, files:opened) are the kit's, reached through +// window.kitAPI. +// +// Namespaced by domain. v1 kept a flat map that also carried an +// electron-updater event name ("update-available") alongside real IPC +// channels, which made the two easy to confuse. const IPC = { - GET_VERSION: "get-version", - GET_SETTING: "get-setting", - SET_SETTING: "set-setting", - UPDATE_AVAILABLE: "update-available", - BROWSE_FILE: "browse-file", - CONVERT_FILE: "convert-file", - CANCEL_CONVERT: "cancel-convert", - OPEN_FOLDER: "open-folder", - SHOW_IN_FOLDER: "show-in-folder", - CONVERSION_PROGRESS: "conversion-progress", // push: percent (0-100) - FILE_OPENED_FROM_MENU: "file-opened-from-menu", // push: file path string -}; + // Jobs and the queue. Progress and status both carry a jobId, which is + // what makes concurrency possible at all; v1 pushed a bare integer with + // no way to tell which file it belonged to. + JOB_RUN: "job:run", // invoke(job) -> result + JOB_CANCEL: "job:cancel", // invoke(jobId) -> boolean + JOB_PREVIEW: "job:preview", // invoke(spec) -> ffmpeg argv preview + JOIN_RUN: "join:run", // invoke(spec) -> result, several clips into one + JOB_PROGRESS: "job:progress", // push { jobId, percent, speed, fps, eta } + JOB_STATUS: "job:status", // push { jobId, status, outputPath?, error? } + QUEUE_CANCEL_ALL: "queue:cancel-all", + QUEUE_SET_CONCURRENCY: "queue:set-concurrency", -// window size constraints -const WINDOW = { - DEFAULT_WIDTH: 700, - DEFAULT_HEIGHT: 1000, - MIN_WIDTH: 600, - MIN_HEIGHT: 600, -}; - -// to add a new format, add an entry with ext and label to the appropriate array -// to add a new format, group add a new array and call buildEntries in CONVERSION_MAP -const AUDIO_FORMATS = [ - { ext: "mp3", label: "MP3" }, - { ext: "wav", label: "WAV" }, - { ext: "flac", label: "FLAC" }, - { ext: "ogg", label: "OGG" }, - { ext: "aac", label: "AAC" }, - { ext: "m4a", label: "M4A" }, - { ext: "opus", label: "OPUS" }, - { ext: "wma", label: "WMA" }, -]; + // Inspection and ingest. + PROBE_FILE: "probe:file", // invoke(path) -> metadata + FS_SCAN: "fs:scan", // invoke(paths, opts) -> { files, skipped, ... } -const VIDEO_FORMATS = [ - { ext: "mp4", label: "MP4" }, - { ext: "mkv", label: "MKV" }, - { ext: "webm", label: "WebM" }, - { ext: "avi", label: "AVI" }, - { ext: "mov", label: "MOV" }, - { ext: "wmv", label: "WMV" }, - { ext: "flv", label: "FLV" }, -]; + // One decoded frame, for choosing which frame to keep. + PREVIEW_FRAME: "preview:frame", -const IMAGE_FORMATS = [ - { ext: "jpg", label: "JPG" }, - { ext: "png", label: "PNG" }, - { ext: "webp", label: "WebP" }, - { ext: "gif", label: "GIF" }, - { ext: "bmp", label: "BMP" }, - { ext: "tiff", label: "TIFF" }, -]; + // Dialogs. + DIALOG_BROWSE_FILES: "dialog:browse-files", + DIALOG_BROWSE_FOLDER: "dialog:browse-folder", + DIALOG_CHOOSE_OUTPUT: "dialog:choose-output", -function buildEntries(formats, type, group) { - const entries = {}; - for (const fmt of formats) { - entries[fmt.ext] = { - type, - targets: formats.map(f => ({ ...f, group })) - }; - } - return entries; -} + // Shell and app. + SHELL_OPEN_PATH: "shell:open-path", + SHELL_SHOW_IN_FOLDER: "shell:show-in-folder", + APP_GET_FORMATS: "app:get-formats", -const CONVERSION_MAP = { - ...buildEntries(AUDIO_FORMATS, "audio", "Audio"), - ...buildEntries(VIDEO_FORMATS, "video", "Video"), - ...buildEntries(IMAGE_FORMATS, "image", "Image"), + // "File Already Exists", asked in the page (a kit.ui.confirm() batch + // prompt) and answered back: main pushes { id, name, isDirectory }, and the + // page answers with (id, { choice, all }). The files the app is opened with + // are the kit's files:opened. + CONFLICT_ASK: "conflict:ask", // push { id, name, isDirectory } + CONFLICT_ANSWER: "conflict:answer", // invoke(id, { choice, all }) -> whether it was waiting }; -const SUPPORTED_EXTENSIONS = Object.keys(CONVERSION_MAP); +// The answers to "File Already Exists", in the prompt's order, left to right. +const CONFLICT_CHOICES = Object.freeze(["cancelAll", "skip", "overwrite", "unique"]); -// extension aliases, maps alternate extensions to their canonical key in CONVERSION_MAP -const EXT_ALIASES = { - jpeg: "jpg", - tif: "tiff", - jfif: "jpg", +// Window size constraints. Widened for the card grid — the v1 single column +// was 700px, which fits only two cards per row. Existing users keep their +// saved bounds; this only changes the first-run default. +// The minimum is measured, not guessed: below 800px wide the selection bar +// runs out of room and clips its own buttons, which is what the old 720 let +// happen. 880 keeps 80px of headroom over that, and the height leaves the grid +// enough room for a full card row under the bars. +const WINDOW = { + DEFAULT_WIDTH: 1100, + DEFAULT_HEIGHT: 780, + MIN_WIDTH: 880, + MIN_HEIGHT: 600, +}; + +// The app's settings and their defaults, which the kit keeps (kit.start()'s +// settings.defaults) under the same "settings" key in config.json that 2.0.0 +// used, so saved settings carry over. The kit adds its own navCollapsed. A +// setting whose default is null takes any value, so concurrency keeps the +// number someone picks. v1 persisted nothing but windowBounds. +const SETTINGS_DEFAULTS = { + outputRouting: "alongside", + outputDir: null, + onConflict: "ask", + concurrency: null, // null = derive from the CPU count + nameTemplate: "{name}", + lastTargetByKind: {}, + // Where Add Files, Add Folder and Open last picked from, so the picker + // opens there next time (null: the system's own choice). + lastOpenFolder: null, + // Folder ingest. maxDepth and maxFiles are deliberately not here: they are + // safety rails rather than preferences, and the toast that fires when one + // bites explains itself at the moment it matters. + scan: { + recursive: true, + kinds: ["audio", "video", "image"], + followSymlinks: false, + }, }; -const STATIC_IMAGE_EXTS = new Set(["jpg", "png", "webp", "bmp", "tiff"]); +// Settings v1 wrote that 2.0 has no way to show or change. Every one of them is +// still layered underneath a job in main.js, so a value left over from v1 wins +// over the card's own choice and there is no screen on which to see it, let +// alone correct it: an install carrying onConflict "unique" silently renamed +// every colliding output and never showed the prompt, whatever the card said. +// +// They are pruned rather than merely ignored, so the file on disk stops +// disagreeing with the app. The layering in main.js stays exactly as it is — +// it is the right shape for the day a preferences screen exists, and on that +// day these keys come back as things the user actually chose. +const LEGACY_SETTINGS_KEYS = ["outputRouting", "outputDir", "nameTemplate", "onConflict"]; -function isGifToStaticImage(sourceExt, targetExt) { - return sourceExt === "gif" && STATIC_IMAGE_EXTS.has(targetExt); +// Store keys whose code is gone: presets went with the orphaned channels in +// c20826f, pipelines with the editor in 09eaca3. +const LEGACY_STORE_KEYS = ["presets", "pipelines"]; + +// Bumped whenever this list grows. The migration in main.js runs once per +// version, so a value a future preferences screen writes is not eaten on the +// next launch. +const SETTINGS_SCHEMA_VERSION = 2; + +/** + * Drop the settings 2.0 cannot reach. Returns a new object; the input is left + * alone. Reports what it removed so the caller can say so in the log rather + * than deleting a user's data in silence. + * + * @param {object|null|undefined} settings + * @returns {{settings: object, removed: string[]}} + */ +function pruneLegacySettings(settings) { + const kept = {}; + const removed = []; + for (const [key, value] of Object.entries(settings ?? {})) { + if (LEGACY_SETTINGS_KEYS.includes(key)) removed.push(key); + else kept[key] = value; + } + return { settings: kept, removed }; } -module.exports = { APP_NAME, IPC, WINDOW, LOG, AUDIO_FORMATS, VIDEO_FORMATS, IMAGE_FORMATS, CONVERSION_MAP, SUPPORTED_EXTENSIONS, EXT_ALIASES, isGifToStaticImage }; +module.exports = { + APP_NAME, + IPC, + WINDOW, + CONFLICT_CHOICES, + SETTINGS_DEFAULTS, + LEGACY_SETTINGS_KEYS, + LEGACY_STORE_KEYS, + SETTINGS_SCHEMA_VERSION, + pruneLegacySettings, +}; diff --git a/src/core/display.js b/src/core/display.js new file mode 100644 index 0000000..92e2f79 --- /dev/null +++ b/src/core/display.js @@ -0,0 +1,841 @@ +// Display formatting and selection logic. +// +// Dual-loaded on purpose: required by tests in Node, and loaded as a plain +// - - - - -
-
-
- -

Diamond File Converter

-
-

• Created and maintained by Diamond Digital Development.

-

• Logo designed by TheFuturisticIdiot.

-

• This software is licensed under the Apache 2.0 license.

-
-

• If you enjoy this software, consider donating to support development!

-
- - -
-
-
- - - - - diff --git a/src/index.html b/src/index.html index ea7cd5d..682cbcc 100644 --- a/src/index.html +++ b/src/index.html @@ -6,86 +6,403 @@ Diamond File Converter + + - + + - + -
+ -

Diamond File Converter

+ +
+ + +
+ + -
-
- upload_file -

Drag & Drop Files Here

-

or

- -
-
- add_circle_outline -

-

drag and drop more files or

-
- - +
+ + +
+ + +
+ + +
+ + +
+ + + +
+ + + +
-
-
+ +
+
-
-

Convert to:

-
+ +
+
-
- + +
+ +
+ +
+
+
    + +
    + + +
    -
    -

    -
    -
    + +