Files
Tha.Les 9b655d5a07 Windows: leaner portable package and opt-in in-app updater (#421) (#423)
* feat(windows): leaner portable package and opt-in in-app updater (#421)

Issue #421 asked for Python embedded in a single EXE so updating would not
mean copying ~20k loose files over an existing install. A onefile EXE is not
viable for this stack (onefile modes re-extract the whole multi-GB payload on
every launch, and torch/onnxruntime fight frozen-import hooks), so this
addresses the root cause instead: ship less, and stop making users hand-copy a
full zip for a release that only changed app code.

Leaner package (make-portable.ps1):
- Stripping is now unconditional. The -StripVenv opt-in gate was a silent
  regression risk: nothing stopped a future workflow edit from shipping the
  unstripped venv with no error.
- Also strips stdlib base/Lib/test and per-package test/tests dirs.
- Deliberately does NOT strip .dist-info/RECORD. pip needs it to replace a
  package, and install_cuda_torch pip-installs into this venv on every NVIDIA
  machine at first run; removing it yields "Failed to uninstall ... missing
  RECORD file".
- Adds a post-strip import check so an over-aggressive strip fails the build
  rather than a release.

Updater (main.rs, catalog.js):
- New commands installed_runtime_id, download_app_update, apply_app_update.
- Opt-in: the check on launch is unchanged, but download and apply are each an
  explicit click. It never auto-applies and never interrupts a running job.
- Replaces StemDeck.exe and backend/ only. python/ is never touched, because an
  NVIDIA install rewrites it with CUDA torch at first run and torchDeviceSettled
  skips ensure_torch_device once the device is cuda, so swapping the directory
  would silently drop that machine to CPU with no recovery.
- The runtime id (uv.lock + interpreter major.minor) is a compatibility gate,
  not a download trigger: if a release changed the Python dependency set the
  updater stands down and points at the full download. Only 19 of the last 200
  commits touch uv.lock, so the fast path covers most releases.
- apply_app_update stages and validates everything before any destructive
  rename, stops the backend synchronously first (the existing stop_backend
  returns before the process dies, which would have made every update fail on
  Windows), and retries renames past transient AV/indexer handles.
- Known gap, documented in code: the two exe renames are not atomic. A hard
  crash in that window leaves StemDeck.exe.old needing a manual rename. Closing
  it needs a bootstrap launcher that is never itself replaced.

CI publishes -app.zip, its .sha256 and -runtime-version.json alongside the
unchanged full zips. Fresh installs are unaffected.

i18n: the 5 new strings are translated into all 7 language tables, not just
English. t() falls back to English silently, so an English-only key looks
correct in testing and ships untranslated to six locales.

Verified: Windows and Linux (WSL) both compile clean with no new clippy
warnings, 39 Rust tests pass on both, JS suites pass, ruff clean. Two new unit
tests pin the JSON contract between the PowerShell writer and the Rust reader.
Not yet verified: no end-to-end run against a real release.

* fix(updater): make the in-app update actually work, verified end to end (#421)

Built both packages on a real Windows box and drove the whole flow. Four bugs
that only surfaced by running it, none of which static checks could see.

1. Stale version after updating. app_version() read installed dist metadata,
   which lives in python/ -- the directory the updater deliberately never
   replaces. A self-updated install kept reporting the old version and would
   re-offer an update it had already applied, forever. It now prefers the app
   layer's static/version.json, which moves with backend/. Gitignored, so Docker
   and source checkouts still fall through to the hatch-vcs metadata.
   Proven: after a real update, python/ dist-info says 0.13.0 while /api/health
   reports 0.13.1.

2. The page CSP blocked the whole feature. The UI is served over http by the
   Python backend, so its connect-src applies: api.github.com is allowed,
   github.com and objects.githubusercontent.com are not, and that is where
   release assets live. Fetching the checksum and runtime id from JS was
   refused, so the pill would simply never appear. Those two reads moved into
   Rust (check_app_update), whose HTTP client is not bound by the page CSP, so
   the policy from #171 stays exactly as tight as it was.

3. plugin:event|listen refused by the Tauri ACL. App-defined commands are not
   ACL-gated but plugin commands are, and the capability does not cover the
   remote http origin the UI is served from. The progress bar is now
   indeterminate instead of granting a remote origin event permissions to put a
   percentage on a 5 MB download.

4. The post-strip import check re-bloated the package. Running Python
   regenerated 1,912 files / 39 MB of __pycache__ that the strip had just
   removed, cancelling nearly all of it: the net saving was 180 files. Swept
   once after the last interpreter run, and backend/ no longer ships a
   developer's local __pycache__ either.

Also: the *.old sweep now runs on every launch rather than only on a version
change. apply_app_update relaunches then exits, so on the first launch of the
new build Windows still holds StemDeck.exe.old open, the delete fails silently,
and gated on a change that already happened it would never retry. Observed for
real: 15.7 MB stranded. Verified swept on the next launch.

UI: "Update now" is an accent pill BESIDE Download, not a replacement, so the
zip stays one click away and is the escape hatch if an update fails.

Measured against the published v0.13.0 package: 18,143 -> 16,056 files
(-2,087, -11.5%) and 883 -> 850 MB. The real win for #421 is the update path
itself: 5 MB and 123 files instead of 284 MB and 16,056.

Verified on this machine: a real 6-stem Demucs separation through the stripped
package; the full notify -> Update now -> download -> restart -> relaunch cycle,
after which user data (job, 7 stems, 130 MB of models), portable.txt, cpu-only
and python/ were all untouched; and the safety gate correctly declining, with
no download attempted, when the release's runtime id differs.

Not covered: the NVIDIA package was not built, though the risk that motivated
the gate is structurally gone now that python/ is never swapped.

* fix: address code-quality review on the version-source change (#421)

Both findings from the automated review were fair.

Narrow the bare `except Exception: pass` in app_version() to
(OSError, ValueError, AttributeError). That is bandit B110, which this repo's
own security conventions call out. The three cover every real failure here --
absent or unreadable file, invalid JSON or bad encoding, and valid JSON that is
not an object so has no .get -- while letting an actual bug in the function
surface instead of silently degrading the reported version. Bandit now reports
no issues for the file.

Use one import style in test_health_api.py so app.main is no longer imported
both as `import app.main as main` and `from app.main import app` in the same
module. Also added a "[]" case: JSON that parses but is not an object, which is
the AttributeError branch the narrowed except now names explicitly.

* feat(updater): extend the in-app update to Linux (#421)

Linux ships the same shape as Windows -- executable, backend/ and python/ side
by side -- so the updater generalises rather than needing a second design. The
platform-specific parts are now three small seams: the archive format, the
executable name, and one new gate.

Rust:
- widen the updater's cfg gates from `windows` to `any(windows, linux)`, and
  replace extract_zip_archive with extract_update_archive, which uses zip on
  Windows and the existing extract_tar_archive on Linux
- APP_EXE_NAME so the swap and the leftover sweep stop hardcoding StemDeck.exe
- stop_backend_and_wait now sends SIGTERM and waits before escalating on unix,
  matching what stop_backend already does on window close
- new app_root_is_writable gate: packaging/linux/install.sh offers a global
  install into /opt/stemdeck, which is root-owned while the app runs as the
  user. check_app_update declines up front rather than failing part way through
  a swap. Windows portable installs are user-writable by construction, but the
  probe is cheap and honest on both.

tar rather than zip on Linux is deliberate: it preserves the executable bit. A
zip would land StemDeck without +x and the relaunch after an update would fail
with a permission error.

Packaging (scripts/linux/make-portable.sh):
- write python/runtime-version.json using the same uv.lock + interpreter
  major.minor formula as the Windows script, so the compatibility gate behaves
  identically on both
- bring the strip to parity: stdlib test/, per-package test/tests, a post-strip
  import check, and a final __pycache__ sweep after the last interpreter run
- PUBLISH_UPDATER_ASSETS=1 emits the slim app-layer tarball, its checksum and
  the runtime marker; wired into the CPU build in linux-release.yml since
  StemDeck and backend/ are identical between both variants

Frontend: updaterAssetNames() maps the platform to its asset names, and the
wiring is gated on that rather than on os === "windows".

macOS is deliberately still excluded, and the comments now say why rather than
just that it is: backend_dir() resolves the backend inside the downloaded
runtime pack rather than the .app, so its app layer is a different thing and
the existing runtime-pack updater already covers most of it.

Verified: both platforms compile clean with no new clippy warnings, 42 tests on
Windows and 43 on Linux (the extra one is the read-only-root gate, which is
meaningless on Windows). The app-layer archive was round-tripped on Linux to
confirm it contains exactly StemDeck + backend/, that python/ does not leak
into it, that the executable bit survives, and that replacing a running binary
works. Not yet run end to end against a real Linux release.

* fix(updater): see pre-releases, and compile the Rust in CI (#421)

Two gaps that would each have undermined the update flow on release day.

The update check polled /releases/latest, which GitHub defines as the most
recent NON-PRERELEASE, non-draft release. Ship a version with the pre-release
box ticked and it becomes invisible: no notification, no update button, on any
platform, with nothing in the logs to explain it. StemDeck has always published
even its alphas as normal releases (v0.8.0-alpha.17 has prerelease=false),
which is the only reason this has not bitten yet -- it was a trap waiting on
someone ticking a box. Now polls the releases list and takes the newest
non-draft, so it is correct either way. Drafts stay excluded: they are already
invisible unauthenticated, and a maintainer should not be offered a release
whose assets do not exist yet.

windows-check.yml and macos-check.yml now also run on pull requests that touch
desktop/src-tauri/**, not workflow_dispatch only. This PR added roughly 600
lines of mostly cfg-gated Rust across two commits and every CI check passed
without compiling a single line of it; the comment at the top of
windows-check.yml notes that exact gap already shipped a broken Windows build
in v0.11.1's first release attempt. Scoped by path so the self-hosted runners
see no extra load from the majority of PRs, which never go near src-tauri.

This also gets the macOS branch compiled for the first time. Local verification
covered Windows and Linux, so the cfg(not(any(windows, linux))) arm of the
three updater commands has never been near a compiler.

* test(e2e): match the releases-list shape the app now polls (#421)

The update-check stub returned a single release object, which was right for
/releases/latest. The app now polls the releases list so a pre-release is still
seen, so the fixture has to return an array or checkForUpdate bails and the
release card never appears.

Caught by frontend-e2e on the previous commit, which is the suite doing exactly
its job: the only assertion that covers this path is
report-failure.spec.mjs:98, and it went red immediately.

* feat(i18n): add French, and make the runtime id line-ending independent

French is a complete table, not a partial one: 435 keys, the same set German
and Portuguese carry (English's 443 minus the ten Polish-only .few/.many forms
and the bare upload.skippedFiles, plus singular forms for the three
playlist.skip.* families). French takes the one/other buckets, so plural()
needs no change.

Verified with the checks from .claude/rules/i18n.md: the drift check reports
clean, and separately there are zero {placeholder} mismatches and zero HTML tag
mismatches against English. The 27 strings identical to English are genuinely
identical in French (Piano, Solo, Transport, Position, LUFS, Standard, Port,
the brand names, CUDA (NVIDIA), MPS (Apple Silicon)).

Separately: the runtime id was being computed from the raw bytes of uv.lock, so
a Windows checkout with core.autocrlf=true hashed CRLF and Linux hashed LF, and
the same lockfile produced two different ids -- caught by building the Linux
package and seeing py3.12-d74d6ef80c5e9d1f where Windows had produced
py3.12-dbda45e38e1044cf. Each platform stayed self-consistent so the gate still
worked, but the id would shift spuriously if a runner's autocrlf ever changed,
silently declining app-only updates that were in fact compatible. Both scripts
now hash the content with newlines normalised; PowerShell, bash and a reference
Python implementation all agree on d74d6ef80c5e9d1f.

* chore: pin Unraid template to 0.14.0

Per .claude/rules/unraid-template-version.md this is an explicit decision each
time, not a default. Confirmed for this release.

The 0.14.0 GHCR image is published by docker-publish.yml when the release is
created, so the tag exists shortly after this lands.

---------

Co-authored-by: Thales <>
2026-08-23 21:29:15 +01:00

946 lines
67 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en" data-i18n-doctitle="doc.title">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>StemDeck — split any track into stems</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/variables.css" />
<link rel="stylesheet" href="/css/waves.css" />
<link rel="stylesheet" href="/css/daw.css" />
</head>
<body>
<div class="app daw" id="app">
<!-- ══ TOPBAR ══ -->
<div class="daw-topbar">
<!-- Brand -->
<div class="daw-brand">
<span class="daw-brand-name">
<span class="fg">Stem</span><span class="accent">Deck</span>
</span>
</div>
<span id="brandVersion" class="daw-version"></span>
<!-- Separator -->
<div class="daw-sep"></div>
<!-- Composer pill -->
<form id="job-form" class="daw-composer">
<!-- URL zone (JS uses .url-wrap for drag events) -->
<div class="daw-url-zone url-wrap">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--muted)" stroke-width="1.8" style="flex-shrink:0" aria-hidden="true">
<path d="M10 14a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1 M14 10a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/>
</svg>
<input
id="url"
type="url"
placeholder="Paste a YouTube or SoundCloud link, or drop an audio file…"
data-i18n-placeholder="topbar.urlPlaceholder"
spellcheck="false"
autocomplete="off"
/>
<!-- File pill (shown when file selected) -->
<div class="file-pill hidden" id="filePill">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
<span class="file-name" id="fileName"></span>
<span class="file-size" id="fileSize"></span>
<button class="file-clear" id="fileClear" type="button" aria-label="Remove file" data-i18n-aria-label="topbar.removeFile">×</button>
</div>
<button class="daw-upload-btn" type="button" title="Upload audio file" id="uploadFileBtn" aria-label="Upload audio file" data-i18n-title="topbar.uploadFile" data-i18n-aria-label="topbar.uploadFile">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M12 3v12 M7 8l5-5 5 5 M5 21h14"/>
</svg>
</button>
<input id="fileInput" type="file" multiple accept=".mp3,.wav,.flac,.mp4,.m4a,.ogg,.opus,audio/mpeg,audio/wav,audio/flac,video/mp4,audio/mp4,audio/ogg,audio/opus" style="display:none" aria-hidden="true" />
</div>
<!-- Divider -->
<div class="daw-composer-sep"></div>
<!-- Stem selection -->
<div class="daw-stem-section">
<span class="daw-extract-label" data-i18n="extract.label">Extract</span>
<div class="daw-stem-chips">
<button class="stem-choice stem-choice-all" type="button" id="stemAllBtn" aria-pressed="true" data-i18n="extract.all">All</button>
<button class="stem-choice" type="button" data-stem="vocals" aria-pressed="true" style="--color:var(--vocals)">
<span class="stem-dot"></span><span data-i18n="stem.vocals">Vocals</span>
</button>
<!-- On-demand lead/backing vocal split (#275). Shown only while
Vocals is selected above; toggling "Lead + Backing" auto-runs
the split once the import finishes (see main.js/job.js). -->
<div class="vocal-mode-toggle hidden" id="vocalModeToggle" role="group" aria-label="Vocals mode" data-i18n-aria-label="vocalMode.groupAria">
<button type="button" class="vocal-mode-btn" data-mode="all" aria-pressed="true" data-i18n="vocalMode.all">All</button>
<button type="button" class="vocal-mode-btn" data-mode="split" aria-pressed="false" data-i18n="vocalMode.split">Lead + Backing</button>
</div>
<button class="stem-choice" type="button" data-stem="drums" aria-pressed="true" style="--color:var(--drums)">
<span class="stem-dot"></span><span data-i18n="stem.drums">Drums</span>
</button>
<button class="stem-choice" type="button" data-stem="bass" aria-pressed="true" style="--color:var(--bass)">
<span class="stem-dot"></span><span data-i18n="stem.bass">Bass</span>
</button>
<button class="stem-choice" type="button" data-stem="guitar" aria-pressed="true" style="--color:var(--guitar)">
<span class="stem-dot"></span><span data-i18n="stem.guitar">Guitar</span>
</button>
<button class="stem-choice" type="button" data-stem="piano" aria-pressed="true" style="--color:var(--piano)">
<span class="stem-dot"></span><span data-i18n="stem.piano">Piano</span>
</button>
<button class="stem-choice" type="button" data-stem="other" aria-pressed="false" style="--color:var(--other)">
<span class="stem-dot"></span><span data-i18n="stem.other">Other</span>
</button>
</div>
</div>
<!-- Process button -->
<button id="submit" class="daw-process-btn" type="submit">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M13 2 3 14h7l-1 8 10-12h-7z"/>
</svg>
<span data-i18n="process.splitStems">Split stems</span>
</button>
</form>
<!-- Notification bell -->
<div class="daw-notif-wrap" style="position:relative;flex-shrink:0;">
<button class="daw-iconbtn daw-notif-btn" id="notifBtn" title="Notifications" type="button" aria-label="Notifications" aria-expanded="false" data-i18n-title="notif.bell" data-i18n-aria-label="notif.bell">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9 M13.7 21a2 2 0 0 1-3.4 0"/>
</svg>
<span class="daw-notif-badge hidden" id="notifBadge" aria-hidden="true"></span>
</button>
<div class="daw-notif-panel" role="menu" aria-label="Notifications" data-i18n-aria-label="notif.title">
<div class="daw-notif-header">
<span class="uplabel" data-i18n="notif.title">Notifications</span>
<button class="daw-notif-clear" id="notifClearAll" type="button" data-i18n="notif.clearAll">Clear all</button>
<button class="daw-notif-close" type="button" aria-label="Close notifications" data-i18n-aria-label="notif.close">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="daw-notif-list" id="notifList">
<div class="daw-notif-card daw-notif-release hidden" id="notifReleaseCard">
<div class="daw-notif-card-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z"/></svg>
</div>
<div class="daw-notif-card-body">
<div class="daw-notif-card-title" data-i18n="notif.newRelease">New release available</div>
<div class="daw-notif-card-desc" id="notifReleaseDesc"></div>
</div>
<button class="daw-notif-dismiss" id="notifReleaseDismiss" type="button" aria-label="Dismiss notification" data-i18n-aria-label="notif.dismiss">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<p class="daw-notif-empty" id="notifEmpty" data-i18n="notif.empty">No new notifications</p>
</div>
</div>
</div>
<!-- Hidden collapsed strip (JS compatibility) -->
<div class="hidden" id="appbarStrip" aria-hidden="true" style="display:none!important">
<div><div class="strip-sq-stems" id="appbarStripStems"></div></div>
</div>
</div>
<!-- ══ BODY ══ -->
<div class="daw-body">
<!-- ── Sidebar / Catalog ── -->
<aside class="sidebar" id="catalogPanel">
<!-- Rail -->
<nav class="sidebar-rail" aria-label="Library navigation" data-i18n-aria-label="nav.libraryNav">
<button class="rail-btn rail-collapse" id="sidebarCollapseBtn" type="button" title="Toggle library" aria-label="Toggle library" aria-expanded="true" data-i18n-title="nav.toggleLibrary" data-i18n-aria-label="nav.toggleLibrary">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<button class="rail-btn rail-library active" id="appMenuBtn" type="button" title="Library" aria-label="Library" aria-pressed="true" data-i18n-title="nav.library" data-i18n-aria-label="nav.library">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M4 4h16v16H4z M4 9h16"/>
</svg>
<span data-i18n="nav.library">Library</span>
</button>
<button class="rail-btn rail-favorites" type="button" title="Favorites" aria-label="Favorites" aria-pressed="false" data-i18n-title="nav.favorites" data-i18n-aria-label="nav.favorites">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
<span data-i18n="nav.favorites">Favorites</span>
</button>
<button class="rail-btn rail-trash" type="button" title="Trash" aria-label="Trash" aria-pressed="false" data-i18n-title="nav.trash" data-i18n-aria-label="nav.trash">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M3 6h18 M8 6V4h8v2 M19 6l-1 14H6L5 6"/>
</svg>
<span data-i18n="nav.trash">Trash</span>
</button>
<button class="rail-btn rail-queue hidden" type="button" title="Import queue" aria-label="Import queue" aria-pressed="false" data-i18n-title="nav.importQueue" data-i18n-aria-label="nav.importQueue">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M4 6h10 M4 12h10 M4 18h6"/>
<path d="M16 15l3 3 3-3 M19 18V9"/>
</svg>
<span data-i18n="nav.queue">Queue</span>
<span class="rail-badge hidden" id="queueBadge" aria-hidden="true"></span>
</button>
<div style="flex:1"></div>
<button class="rail-btn" id="settingsBtn" type="button" aria-label="Settings" aria-haspopup="dialog" data-i18n-aria-label="nav.settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v2 M12 20v2 M4 12H2 M22 12h-2 M5 5l1.5 1.5 M17.5 17.5 19 19 M5 19l1.5-1.5 M17.5 6.5 19 5"/>
</svg>
<span data-i18n="nav.settings">Settings</span>
</button>
<button class="rail-btn rail-recommend" id="friendsBtn" type="button" title="We Recommend" aria-label="We Recommend" aria-haspopup="dialog" data-i18n-title="nav.weRecommend" data-i18n-aria-label="nav.weRecommend">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>
</svg>
<span><span data-i18n="nav.weRecommendLine1">We</span><br><span data-i18n="nav.weRecommendLine2">Recommend</span></span>
</button>
<button class="rail-btn" id="aboutBtn" type="button" title="Help" aria-label="Help" data-i18n-title="nav.help" data-i18n-aria-label="nav.help">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="12" cy="12" r="9"/>
<path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 .8-1 1.7 M12 17h.01"/>
</svg>
<span data-i18n="nav.help">Help</span>
</button>
</nav>
<!-- Sidebar body -->
<div class="sidebar-body">
<!-- Search -->
<div class="daw-search" id="catalogToggle" role="button" tabindex="0" aria-label="Search library" data-i18n-aria-label="search.ariaLabel">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>
</svg>
<input id="catalogSearch" type="search" placeholder="Search or #tag…" data-i18n-placeholder="search.placeholder" autocomplete="off" spellcheck="false" />
<ul class="tag-suggest" id="tagSuggest" role="listbox" aria-label="Tag suggestions" data-i18n-aria-label="search.tagSuggestions"></ul>
<span class="search-kbd" aria-hidden="true">⌘K</span>
</div>
<!-- Clear bin (visible only in trash-view) -->
<div id="clearBinBar" class="clear-bin-bar">
<button id="clearBinBtn" class="clear-bin-btn" type="button">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M3 6h18 M8 6V4h8v2 M19 6l-1 14H6L5 6"/>
</svg>
<span data-i18n="trash.empty">Empty trash</span>
</button>
</div>
<!-- Catalog list (JS populates) -->
<div id="catalogList" class="daw-lib-list" role="list"></div>
<!-- Collapsed strip (JS compat) -->
<div id="catalogStrip" class="cat-collapsed-strip"></div>
</div>
</aside>
<!-- ── Main area ── -->
<div class="daw-main-col">
<div id="lanes" class="daw-main">
<!-- Job progress -->
<div id="error" class="error hidden" role="alert"></div>
<!-- Track header / info panel -->
<div id="transport" class="daw-track-header">
<!-- Row 1: track card + metadata cards -->
<div class="daw-info-row">
<div class="daw-meta-card" data-meta="key">
<span class="meta-card-label" data-i18n="meta.key">KEY</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-key"></span>
<div class="daw-camelot-ring hidden" id="summary-confidence"></div>
</div>
<span class="meta-card-sub" id="summary-scale"></span>
<span class="meta-card-sub hidden" id="summary-confidence-label"></span>
</div>
<div class="daw-meta-card" data-meta="bpm">
<span class="meta-card-label" data-i18n="meta.bpm">BPM</span>
<div class="meta-card-main">
<span class="meta-card-value accent" id="summary-bpm"></span>
</div>
</div>
<div class="daw-meta-card" data-meta="lufs">
<span class="meta-card-label" data-i18n="meta.lufs">LUFS</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-lufs"></span>
</div>
<span class="meta-card-sub" id="summary-peak"></span>
</div>
<div class="daw-meta-card" data-meta="duration">
<span class="meta-card-label" data-i18n="meta.duration">DURATION</span>
<div class="meta-card-main">
<span class="meta-card-value num" id="summary-duration"></span>
</div>
</div>
<div class="daw-meta-card" data-meta="scale">
<span class="meta-card-label" data-i18n="meta.scale">SCALE</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-scale-name"></span>
</div>
</div>
<div class="daw-meta-card" data-meta="dr">
<span class="meta-card-label" data-i18n="meta.dynamicRange">DYNAMIC RANGE</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-dr"></span>
</div>
<span class="meta-card-sub" id="summary-dr-label"></span>
</div>
<div class="daw-meta-card" data-meta="stability">
<span class="meta-card-label" data-i18n="meta.tempoStability">TEMPO STABILITY</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-stability"></span>
</div>
<span class="meta-card-sub" id="summary-stability-label"></span>
</div>
</div>
<!-- Row 2: stem presence cards -->
<div class="stem-presence-panel">
<div class="stem-card inactive" data-stem="vocals">
<span class="stem-card-label" data-i18n="presence.vocal">VOCAL PRESENCE</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="drums">
<span class="stem-card-label" data-i18n="presence.drum">DRUM INTENSITY</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="bass">
<span class="stem-card-label" data-i18n="presence.bass">BASS DEPTH</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="guitar">
<span class="stem-card-label" data-i18n="presence.guitar">GUITAR PRESENCE</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="piano">
<span class="stem-card-label" data-i18n="presence.piano">PIANO PRESENCE</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="other">
<span class="stem-card-label" data-i18n="presence.other">OTHER</span>
<span class="stem-card-pct"></span>
</div>
</div>
<!-- Hidden compat -->
<div style="display:none" aria-hidden="true"><div id="loudness-card"></div></div>
</div>
<!-- Sections bar -->
<div class="daw-section-ribbon">
<div class="daw-mixer-label daw-sections-header">
<span class="daw-label-title" data-i18n="sections.title">Sections</span>
<span class="sections-save-indicator hidden" id="sectionsSaveIndicator" aria-live="polite" aria-label="Saving sections" data-i18n-aria-label="sections.savingAria"></span>
<button class="sections-add-btn-label" id="sectionsAddBtn" type="button" title="Add section" aria-label="Add section" data-i18n-title="sections.addAria" data-i18n-aria-label="sections.addAria">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
<span data-i18n="sections.add">Add</span>
</button>
</div>
<div class="daw-sections-area" id="daw-sections"></div>
</div>
<!-- Waveform header / ruler -->
<div class="daw-wave-header">
<div class="daw-wave-label">
<span class="daw-label-title" data-i18n="mixer.title">Mixer</span>
<span class="uplabel daw-label-sub" data-i18n="mixer.hint">Drag fader · M/S</span>
</div>
<div class="daw-ruler-area">
<div class="lanes-ruler" data-position="top">
<div class="lanes-ruler-time" id="ruler-time">
<div class="playhead-marker" aria-hidden="true">
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
<polygon points="0,0 10,0 5,8" fill="var(--accent)"/>
</svg>
</div>
</div>
</div>
</div>
</div>
<!-- Content area: stems + waveform -->
<div class="daw-content">
<!-- Stems panel (left — M/S controls per stem) -->
<div class="daw-stems-panel" aria-label="Stems" data-i18n-aria-label="stemsPanel.ariaLabel">
<!-- Presence panel hidden but in DOM for JS -->
<div style="display:none" aria-hidden="true">
<section class="presence-panel surface-panel">
<div class="presence-ruler" id="presence-ruler">
<span></span><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b>
</div>
<div class="presence-grid">
<div class="presence-labels">
<span class="vocals" data-i18n="stem.vocals">Vocals</span><span class="drums" data-i18n="stem.drums">Drums</span>
<span class="bass" data-i18n="stem.bass">Bass</span><span class="guitar" data-i18n="stem.guitar">Guitar</span>
<span class="piano" data-i18n="stem.piano">Piano</span><span class="other" data-i18n="stem.others">Others</span>
</div>
<div class="presence-bars">
<i class="vocals" data-stem="vocals"></i>
<i class="drums" data-stem="drums"></i>
<i class="bass" data-stem="bass"></i>
<i class="guitar" data-stem="guitar"></i>
<i class="piano" data-stem="piano"></i>
<i class="other" data-stem="other"></i>
<div class="presence-playhead hidden" id="presence-playhead" aria-hidden="true"></div>
</div>
</div>
</section>
</div>
<!-- Stem rows with M/S -->
<div class="stem-list" aria-label="Waveform lane icons">
<span class="original" data-stem="original">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<span data-i18n="stem.original">Original</span>
</em>
<b class="stem-mute" data-stem="original" role="button" tabindex="0" aria-label="Mute Original" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="original" role="button" tabindex="0" aria-label="Solo Original" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="original" aria-label="Solo only Original"></button>
<i class="mini-meter"></i>
</span>
<span class="vocals" data-stem="vocals">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v3"/></svg>
<span data-i18n="stem.vocals">Vocals</span>
</em>
<b class="stem-mute" data-stem="vocals" role="button" tabindex="0" aria-label="Mute Vocals" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="vocals" role="button" tabindex="0" aria-label="Solo Vocals" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="vocals" aria-label="Solo only Vocals"></button>
<i class="mini-meter"></i>
</span>
<!-- On-demand lead/backing vocal split (#275): hidden by default
(no split has run yet); shown in place of the row above via
applyStemSelectionFilter in player.js once a job has one. -->
<span class="lead_vocals hidden" data-stem="lead_vocals">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v3"/></svg>
<span data-i18n="stem.lead_vocals">Lead Vocals</span>
</em>
<b class="stem-mute" data-stem="lead_vocals" role="button" tabindex="0" aria-label="Mute Lead Vocals" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="lead_vocals" role="button" tabindex="0" aria-label="Solo Lead Vocals" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="lead_vocals" aria-label="Solo only Lead Vocals"></button>
<i class="mini-meter"></i>
</span>
<span class="backing_vocals hidden" data-stem="backing_vocals">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M8 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3Z"/><path d="M16 6a3 3 0 0 1 3 3v3a3 3 0 0 1-3 3"/><path d="M5 12v1a6 6 0 0 0 6 6h1"/><path d="M11 19v2"/></svg>
<span data-i18n="stem.backing_vocals">Backing Vocals</span>
</em>
<b class="stem-mute" data-stem="backing_vocals" role="button" tabindex="0" aria-label="Mute Backing Vocals" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="backing_vocals" role="button" tabindex="0" aria-label="Solo Backing Vocals" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="backing_vocals" aria-label="Solo only Backing Vocals"></button>
<i class="mini-meter"></i>
</span>
<span class="drums" data-stem="drums">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M7 13.5a5 5 0 0 0 10 0"/><path d="M7 13.5h10"/><circle cx="9" cy="10" r="2.5"/><circle cx="15" cy="10" r="2.5"/></svg>
<span data-i18n="stem.drums">Drums</span>
</em>
<b class="stem-mute" data-stem="drums" role="button" tabindex="0" aria-label="Mute Drums" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="drums" role="button" tabindex="0" aria-label="Solo Drums" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="drums" aria-label="Solo only Drums"></button>
<i class="mini-meter"></i>
</span>
<span class="bass" data-stem="bass">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M16.5 3h4v5h-3"/><path d="M17.5 5.5 9.8 13.2"/><path d="M10 13c1.6 2.2 1.1 5.1-1.2 6.5-2.1 1.3-5 .5-6-1.6-.9-1.9-.1-4.1 1.8-5 .9-.4 1.8-.4 2.8-.1.1-1.1.6-2.1 1.6-2.6 1.2-.6 2.6-.1 3.2 1.1"/></svg>
<span data-i18n="stem.bass">Bass</span>
</em>
<b class="stem-mute" data-stem="bass" role="button" tabindex="0" aria-label="Mute Bass" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="bass" role="button" tabindex="0" aria-label="Solo Bass" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="bass" aria-label="Solo only Bass"></button>
<i class="mini-meter"></i>
</span>
<span class="guitar" data-stem="guitar">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M16 4.5 20 2l2 2-2.5 4"/><path d="M18.2 5.8 10.2 13.8"/><circle cx="7" cy="16.4" r="1.4"/></svg>
<span data-i18n="stem.guitar">Guitar</span>
</em>
<b class="stem-mute" data-stem="guitar" role="button" tabindex="0" aria-label="Mute Guitar" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="guitar" role="button" tabindex="0" aria-label="Solo Guitar" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="guitar" aria-label="Solo only Guitar"></button>
<i class="mini-meter"></i>
</span>
<span class="piano" data-stem="piano">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 5v14"/><path d="M12 5v14"/><path d="M17 5v14"/></svg>
<span data-i18n="stem.piano">Piano</span>
</em>
<b class="stem-mute" data-stem="piano" role="button" tabindex="0" aria-label="Mute Piano" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="piano" role="button" tabindex="0" aria-label="Solo Piano" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="piano" aria-label="Solo only Piano"></button>
<i class="mini-meter"></i>
</span>
<span class="other" data-stem="other">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M4 13v-2"/><path d="M8 17V7"/><path d="M12 21V3"/><path d="M16 17V7"/><path d="M20 13v-2"/></svg>
<span data-i18n="stem.others">Others</span>
</em>
<b class="stem-mute" data-stem="other" role="button" tabindex="0" aria-label="Mute Other" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="other" role="button" tabindex="0" aria-label="Solo Other" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="other" aria-label="Solo only Other"></button>
<i class="mini-meter"></i>
</span>
</div>
<!-- Mixer faders (JS populates) -->
<div id="mixer" class="mixer-column"></div>
</div>
<!-- Waveform panel -->
<div class="daw-wave-panel wave-editor surface-panel">
<div id="job" class="job hidden" role="status" aria-live="polite">
<div class="job-header">
<div id="job-title" class="title"></div>
<div id="job-stage" class="stage"></div>
<button id="job-cancel" class="cancel-btn hidden" type="button" aria-label="Cancel job" data-i18n-aria-label="job.cancelAria" data-i18n="job.cancel">Cancel</button>
</div>
<progress id="progress" max="100" value="0"></progress>
<div id="job-detail" class="job-detail" aria-live="polite"></div>
</div>
<div class="beatgrid-toolbar hidden" id="beatgrid-toolbar" role="toolbar" aria-label="Beat grid editor" data-i18n-aria-label="beatgrid.toolbarAria">
<span class="bg-tool-label" data-i18n="beatgrid.gridLabel">GRID</span>
<div class="bg-tools" role="radiogroup" aria-label="Tool" data-i18n-aria-label="beatgrid.toolAria">
<button class="bg-btn active" id="bg-tool-move" type="button" role="radio" aria-checked="true" title="Drag beats" data-i18n-title="beatgrid.moveTitle" data-i18n="beatgrid.move">Move</button>
<button class="bg-btn" id="bg-tool-insert" type="button" role="radio" aria-checked="false" title="Click to add a beat" data-i18n-title="beatgrid.addTitle" data-i18n="beatgrid.add">Add</button>
<button class="bg-btn" id="bg-tool-delete" type="button" role="radio" aria-checked="false" title="Click a beat to remove it" data-i18n-title="beatgrid.deleteTitle" data-i18n="beatgrid.delete">Delete</button>
<button class="bg-btn" id="bg-tool-bar" type="button" role="radio" aria-checked="false" title="Click a beat to mark it as a downbeat" data-i18n-title="beatgrid.barLineTitle" data-i18n="beatgrid.barLine">Bar line</button>
</div>
<label class="bg-check"><input type="checkbox" id="bg-ripple" checked> <span data-i18n="beatgrid.ripple">Ripple</span></label>
<label class="bg-check"><input type="checkbox" id="bg-snap" checked> <span data-i18n="beatgrid.snap">Snap</span></label>
<label class="bg-check bg-meter"><span data-i18n="beatgrid.bar">Bar</span>
<input type="number" id="bg-barlen" min="1" max="32" step="1" value="4" aria-label="Beats per bar in this region" data-i18n-aria-label="beatgrid.barAria">
</label>
<div class="bg-spacer"></div>
<button class="bg-btn" id="bg-undo" type="button" title="Undo (Cmd/Ctrl+Z)" disabled data-i18n-title="beatgrid.undoTitle" data-i18n="beatgrid.undo">Undo</button>
<button class="bg-btn" id="bg-redo" type="button" title="Redo (Cmd/Ctrl+Shift+Z)" disabled data-i18n-title="beatgrid.redoTitle" data-i18n="beatgrid.redo">Redo</button>
<button class="bg-btn bg-danger" id="bg-reset" type="button" title="Discard all edits and restore the detected grid" data-i18n-title="beatgrid.resetTitle" data-i18n="beatgrid.reset">Reset</button>
<button class="bg-btn bg-primary" id="bg-done" type="button" data-i18n="beatgrid.done">Done</button>
<span class="bg-hint" id="bg-hint" data-i18n="beatgrid.hint">Drag a beat to retime the region. Alt-drag moves one beat.</span>
</div>
<div class="wave-scroll" id="wave-scroll">
<div class="wave-canvas" id="wave-canvas">
<div class="waves-column">
<div id="waves-grid" class="waves-grid" aria-hidden="true"></div>
<div id="multitrack-container"></div>
<div id="loop-region" class="loop-region hidden" aria-hidden="true"></div>
<canvas id="beatgrid-canvas" class="beatgrid-canvas hidden" aria-hidden="true"></canvas>
</div>
<div class="wave-loading-overlay hidden" id="waveLoadingOverlay" aria-label="Loading waveform" data-i18n-aria-label="wave.loadingAria" aria-live="polite">
<div class="wave-loading-lines" aria-hidden="true">
<i></i><i></i><i></i><i></i><i></i><i></i>
</div>
<p id="waveLoadingPhrase" class="wave-loading-msg"></p>
</div>
</div>
</div>
</div>
</div><!-- /.daw-content -->
</div><!-- /#lanes -->
<!-- ══ FOOTER — sibling of #lanes inside .daw-main-col, not clipped ══ -->
<footer class="daw-footer">
<!-- Left column: what is loaded, and what leaves the app. Sits in the
gutter under the stems/mixer panel and carries its width, so it
reads as that column's footer rather than free-floating text. -->
<div class="footer-track">
<div class="footer-track-head">
<div class="daw-cover footer-cover" id="np-art">
<div class="np-art-placeholder" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
</div>
<img id="np-thumb" class="np-art-img" alt="" referrerpolicy="no-referrer" />
</div>
<div class="footer-track-info">
<div class="daw-track-title footer-track-title" id="title"></div>
</div>
</div>
<div class="daw-track-meta-line">
<span class="num" id="t-meta-duration"></span>
<span class="daw-track-sep" aria-hidden="true">·</span>
<span id="t-stems-chip">— stems</span>
<span class="daw-track-sep" aria-hidden="true">·</span>
<span id="track-source"></span>
<span class="daw-track-sep" aria-hidden="true">·</span>
<span id="track-quality"></span>
<span class="daw-track-sep" aria-hidden="true">·</span>
<span><span data-i18n="footer.extractedLabel">Extracted</span> <span id="track-extracted"></span></span>
</div>
<!-- Hidden spans for JS compat: not shown, but still written to
(t-time is superseded here by the Position group in the
controls row -- keeping it live would just duplicate that
readout next to the title). -->
<div style="display:none" aria-hidden="true">
<span id="t-time"></span><span id="t-bpm"></span><span id="t-key"></span><span id="track-artist"></span>
</div>
<div class="footer-row-actions">
<button class="daw-fav-btn" id="fav-btn" aria-label="Toggle favorite" data-i18n-aria-label="fav.toggleAria" aria-pressed="false">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
</button>
<div class="footer-chip-wrap" id="footer-export-wrap">
<button class="footer-chip footer-chip-primary" id="t-export-btn" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="t-export-panel">
<svg class="export-dl-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<svg class="export-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke-opacity="0.25"/><path d="M21 12a9 9 0 0 0-9-9"/>
</svg>
<span class="export-btn-label" id="t-export-label" data-i18n="export.mix">Export Mix</span>
<svg class="export-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<div class="footer-chip-panel chip-panel-export hidden" id="t-export-panel" role="menu" aria-label="Export options" data-i18n-aria-label="export.optionsAria">
<div class="export-format-toggle" role="radiogroup" aria-label="Export format" data-i18n-aria-label="export.formatAria">
<button class="export-fmt active" id="t-fmt-wav" type="button" role="radio" aria-checked="true">WAV</button>
<button class="export-fmt" id="t-fmt-mp3" type="button" role="radio" aria-checked="false">MP3</button>
<button class="export-fmt" id="t-fmt-flac" type="button" role="radio" aria-checked="false">FLAC</button>
<button class="export-fmt" id="t-fmt-ogg" type="button" role="radio" aria-checked="false">OGG</button>
<button class="export-fmt export-fmt-video" id="t-fmt-mp4" type="button" role="radio" aria-checked="false">MP4</button>
</div>
<label class="export-click-opt" id="t-export-click-wrap" title="Mix the click track into the exported file" data-i18n-title="export.includeClickTitle">
<input type="checkbox" id="t-export-click">
<span data-i18n="export.includeClick">Include click track</span>
</label>
<label class="export-click-opt" id="t-export-count-in-wrap" title="Prepend one bar of count-in click before the audio" data-i18n-title="export.addCountInTitle">
<input type="checkbox" id="t-export-count-in">
<span data-i18n="export.addCountIn">Add count-in (1 bar)</span>
</label>
<button class="chip-panel-item export-item" id="t-export-mix" type="button" role="menuitem">
<span class="chip-item-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><line x1="4" y1="9" x2="4" y2="15"/><line x1="9" y1="5" x2="9" y2="19"/><line x1="14" y1="8" x2="14" y2="16"/><line x1="19" y1="4" x2="19" y2="20"/></svg>
</span>
<span class="chip-item-text">
<span class="chip-item-title" data-i18n="export.mix">Export Mix</span>
<span class="chip-item-desc" data-i18n="export.mixDesc">Export the mixed audio</span>
</span>
<svg class="chip-item-check" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
</button>
<button class="chip-panel-item export-item" id="t-export-stems" type="button" role="menuitem">
<span class="chip-item-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><polygon points="12 2 22 8.5 12 15 2 8.5 12 2"/><polyline points="2 15.5 12 22 22 15.5"/></svg>
</span>
<span class="chip-item-text">
<span class="chip-item-title" data-i18n="export.allStems">Export All Stems</span>
<span class="chip-item-desc" data-i18n="export.allStemsDesc">All stems as a .zip</span>
</span>
</button>
<button class="chip-panel-item export-item" id="t-export-region" type="button" role="menuitem" aria-disabled="true">
<span class="chip-item-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>
</span>
<span class="chip-item-text">
<span class="chip-item-title" data-i18n="export.currentRegion">Export Current Region</span>
<span class="chip-item-desc" data-i18n="export.currentRegionDesc">Export selected region</span>
</span>
</button>
</div>
</div>
</div>
</div><!-- /.footer-track -->
<!-- Right column: everything time-related, aligned with the lanes. -->
<div class="footer-main">
<!-- Every playback and click control, in labelled clusters -->
<div class="footer-row-controls">
<div class="footer-group footer-group-transport">
<span class="footer-group-label" data-i18n="transport.group">Transport</span>
<div class="footer-group-body">
<button class="daw-iconbtn btn-transport" id="t-stop" type="button" title="Stop" aria-label="Stop" data-i18n-title="transport.stop" data-i18n-aria-label="transport.stop">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="6" width="12" height="12"/>
</svg>
</button>
<button class="daw-play-btn btn-transport" id="t-play" type="button" aria-label="Play / Pause" data-i18n-aria-label="transport.playPause">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true" style="margin-left:2px">
<path d="M6 4l14 8L6 20z"/>
</svg>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" class="pause-icon" aria-hidden="true" style="display:none">
<rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/>
</svg>
</button>
</div>
</div>
<span class="footer-divider" aria-hidden="true"></span>
<!-- Loop lives with the readout it acts on: the loop is a position,
not a transport mode (design 1b). -->
<div class="footer-group footer-group-position">
<span class="footer-group-label" data-i18n="position.group">Position</span>
<div class="footer-group-body">
<span class="num footer-elapsed" id="footer-time-elapsed">0:00</span>
<span class="footer-time-sep">/</span>
<span class="num footer-total" id="footer-time-total">0:00</span>
<button class="daw-iconbtn btn-transport loop" id="t-loop" type="button" title="Loop the selected position (L)" aria-label="Loop" data-i18n-title="position.loopTitle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M17 2l4 4-4 4 M3 12V8a2 2 0 0 1 2-2h16 M7 22l-4-4 4-4 M21 12v4a2 2 0 0 1-2 2H3"/>
</svg>
<span class="loop-btn-label" data-i18n="position.loopLabel">Loop position</span>
</button>
<div class="footer-loop-times" title="Exact loop start / end (mm:ss.mmm or seconds)" data-i18n-title="position.loopExactTitle">
<input type="text" id="t-loop-start" class="loop-time-input num" inputmode="decimal" spellcheck="false" autocomplete="off" aria-label="Loop start" data-i18n-aria-label="position.loopStartAria" value="00:00.000">
<span class="loop-times-sep" aria-hidden="true">-</span>
<input type="text" id="t-loop-end" class="loop-time-input num" inputmode="decimal" spellcheck="false" autocomplete="off" aria-label="Loop end" data-i18n-aria-label="position.loopEndAria" value="00:00.000">
</div>
</div>
</div>
<span class="footer-divider" aria-hidden="true"></span>
<div class="footer-group footer-group-speed">
<span class="footer-group-label" data-i18n="speed.group">Speed</span>
<div class="footer-group-body">
<div class="footer-seg speed-group" id="t-speed-wrap" role="radiogroup" aria-label="Playback speed" data-i18n-aria-label="speed.ariaLabel">
<button class="speed-btn" id="t-speed-025" type="button" role="radio" aria-checked="false" data-speed="0.25">0.25x</button>
<button class="speed-btn" id="t-speed-05" type="button" role="radio" aria-checked="false" data-speed="0.5">0.5x</button>
<button class="speed-btn active" id="t-speed-1" type="button" role="radio" aria-checked="true" data-speed="1">1x</button>
</div>
</div>
</div>
<span class="footer-divider" aria-hidden="true"></span>
<div class="footer-group footer-group-click" id="t-metro-wrap">
<span class="footer-group-label"><span data-i18n="click.group">Click track</span> <span class="alpha-badge" data-i18n="click.alpha">Alpha</span></span>
<div class="footer-group-body">
<button class="click-toggle-btn" id="t-metro" type="button" title="Click track (K)" aria-label="Click track" data-i18n-title="click.toggleTitle" data-i18n-aria-label="click.toggleAria" aria-pressed="false" disabled>
<svg class="click-metro-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M9 3h6l4 18H5z"/><path d="M15.5 9 8 15"/>
</svg>
<span data-i18n="click.on">ON</span>
</button>
<div class="footer-metro-panel hidden" id="t-metro-panel" role="group" aria-label="Click track options" data-i18n-aria-label="click.optionsAria">
<button class="click-toggle-btn" id="t-metro-countin" type="button" title="Count one bar of click before the song starts when you press play" data-i18n-title="click.countInTitle" aria-pressed="false">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M12 3a9 9 0 1 0 9 9"/><path d="M12 7v5l3 2"/>
</svg>
<span data-i18n="click.countIn">Count-in</span>
</button>
<span class="metro-select-wrap">
<select id="t-metro-bar" class="metro-select" aria-label="Accent every N beats" data-i18n-aria-label="click.barAccentAria">
<option value="-1" selected data-i18n="click.auto">Auto (detected)</option>
<option value="0" data-i18n="click.off">Off</option>
<option value="2">2/4</option>
<option value="3">3/4</option>
<option value="4">4/4</option>
<option value="6">6/8</option>
</select>
</span>
<button class="click-toggle-btn" id="t-metro-edit" type="button" title="Edit the beat grid" data-i18n-title="click.editTitle" aria-pressed="false">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M4 4v16" stroke-width="2.6"/><path d="M10 6v12"/><path d="M15 6v12"/><path d="M20 6v12"/>
</svg>
<span data-i18n="click.grid">Grid</span>
</button>
<div class="footer-seg metro-mult" role="radiogroup" aria-label="Click rate" data-i18n-aria-label="click.rateAria" title="Halve or double the click rate" data-i18n-title="click.rateTitle">
<button class="metro-mult-btn" id="t-metro-half" type="button" role="radio" aria-checked="false" title="Half time" data-i18n-title="click.half">/2</button>
<button class="metro-mult-btn active" id="t-metro-one" type="button" role="radio" aria-checked="true" title="Detected tempo" data-i18n-title="click.detected">1x</button>
<button class="metro-mult-btn" id="t-metro-double" type="button" role="radio" aria-checked="false" title="Double time" data-i18n-title="click.double">x2</button>
</div>
<div class="metro-row metro-vol-row">
<svg class="metro-vol-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4V5Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/>
</svg>
<input type="range" id="t-metro-vol" min="0" max="1" step="0.05" value="0.6" aria-label="Click volume" data-i18n-aria-label="click.volumeAria">
<span class="metro-val num" id="t-metro-vol-label">60%</span>
</div>
</div>
</div>
</div>
</div><!-- /.footer-row-controls -->
<!-- The timeline the clusters above act on -->
<div class="footer-wave-region">
<div class="footer-wave-panel">
<div class="footer-wave-ticks" id="footer-wave-ticks" aria-hidden="true"></div>
<div class="footer-wave-bar">
<canvas id="footer-waveform" aria-hidden="true"></canvas>
<div class="footer-scrub" id="footer-scrub" role="slider" aria-label="Seek" data-i18n-aria-label="footer.seekAria" tabindex="0">
<div class="footer-scrub-fill" id="footer-scrub-fill"></div>
</div>
</div>
</div>
<div class="metro-note" id="t-metro-note"></div>
</div>
</div><!-- /.footer-main -->
</footer>
</div><!-- /.daw-main-col -->
</div><!-- /.daw-body -->
</div><!-- /.daw -->
<!-- About dialog -->
<div class="about-backdrop hidden" id="aboutDialog" role="dialog" aria-modal="true" aria-labelledby="aboutTitle">
<div class="about-card">
<button class="about-close" id="aboutClose" type="button" aria-label="Close about dialog" data-i18n-aria-label="about.closeAria">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
<div class="about-logo" aria-hidden="true">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<rect x="2" y="10" width="3" height="8" rx="1.5" fill="currentColor" opacity=".5"/>
<rect x="7" y="6" width="3" height="16" rx="1.5" fill="currentColor" opacity=".7"/>
<rect x="12" y="2" width="4" height="24" rx="2" fill="currentColor"/>
<rect x="18" y="6" width="3" height="16" rx="1.5" fill="currentColor" opacity=".7"/>
<rect x="23" y="10" width="3" height="8" rx="1.5" fill="currentColor" opacity=".5"/>
</svg>
</div>
<h2 id="aboutTitle" data-i18n="about.title">StemDeck</h2>
<p class="about-tagline" data-i18n="about.tagline">Open source. No subscriptions. Built by musicians, for musicians.</p>
<span class="about-version-badge" id="aboutVersion"></span>
<div class="about-primary-links">
<a class="about-link about-link-primary" href="https://stemdeck.app" target="_blank" rel="noopener noreferrer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
<span data-i18n="about.website">Website</span>
</a>
<a class="about-link about-link-secondary" href="https://github.com/stemdeckapp/stemdeck" target="_blank" rel="noopener noreferrer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>
GitHub
</a>
</div>
<div class="about-divider"></div>
<div class="about-socials">
<a class="about-social-btn" href="https://discord.gg/JGk7FdZb9N" target="_blank" rel="noopener noreferrer" title="Discord" aria-label="Discord">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
</a>
<a class="about-social-btn" href="https://www.reddit.com/r/StemDeckApp/" target="_blank" rel="noopener noreferrer" title="Reddit" aria-label="Reddit">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/></svg>
</a>
<a class="about-social-btn" href="https://www.instagram.com/stemdeck" target="_blank" rel="noopener noreferrer" title="Instagram" aria-label="Instagram">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg>
</a>
<a class="about-social-btn" href="https://x.com/StemDeckApp" target="_blank" rel="noopener noreferrer" title="X" aria-label="X">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.747l7.73-8.835L1.254 2.25H8.08l4.253 5.622 5.911-5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</a>
</div>
</div>
</div>
<!-- Our Friends dialog -->
<div class="about-backdrop hidden" id="friendsDialog" role="dialog" aria-modal="true" aria-labelledby="friendsTitle">
<div class="about-card friends-card">
<button class="about-close" id="friendsClose" type="button" aria-label="Close friends dialog" data-i18n-aria-label="friends.closeAria">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
<div class="about-logo" aria-hidden="true">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>
</svg>
</div>
<h2 id="friendsTitle" data-i18n="friends.title">We Recommend</h2>
<p class="about-tagline" data-i18n="friends.tagline">Wonderful people doing beautiful work. Go meet them ❤️</p>
<div class="lib-friends-grid" id="friendsDialogGrid"></div>
</div>
</div>
<!-- Release notes dialog (opened from the "New release available" card) -->
<div class="about-backdrop hidden" id="releaseDialog" role="dialog" aria-modal="true" aria-labelledby="releaseTitle">
<div class="about-card release-card">
<button class="about-close" id="releaseClose" type="button" aria-label="Close release dialog" data-i18n-aria-label="release.closeAria">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
<div class="about-logo" aria-hidden="true">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z"/></svg>
</div>
<h2 id="releaseTitle" data-i18n="release.title">New release available</h2>
<span class="about-version-badge" id="releaseVersion"></span>
<div class="release-notes" id="releaseNotes"></div>
<!-- Server/Docker mode: updating means pulling a new image, not a file download. -->
<div class="release-docker hidden" id="releaseDocker">
<p class="release-docker-label" data-i18n="release.updateServer">Update your server</p>
<p class="release-docker-text" data-i18n="release.pullImage">Pull the new image and restart the container:</p>
<pre class="release-docker-cmd"><code id="releaseDockerCmd"></code></pre>
<p class="release-docker-note" data-i18n="release.unraidNote">On Unraid, update via the Community Applications template instead.</p>
</div>
<!-- Windows desktop only: download + apply in place instead of a browser download. -->
<div class="release-inapp hidden" id="releaseInapp">
<div class="release-inapp-progress hidden" id="releaseInappProgress">
<div class="release-inapp-progress-bar"><div class="release-inapp-progress-fill" id="releaseInappProgressFill"></div></div>
<p class="release-inapp-progress-text" id="releaseInappProgressText"></p>
</div>
<p class="release-inapp-error hidden" id="releaseInappError"></p>
</div>
<div class="about-primary-links">
<a class="about-link about-link-primary" id="releaseDownload" href="#" target="_blank" rel="noopener noreferrer" data-i18n="release.download">Download</a>
<button class="about-link about-link-accent hidden" id="releaseDownloadApp" type="button" data-i18n="release.updateNow">Update now</button>
<button class="about-link about-link-accent hidden" id="releaseApplyUpdate" type="button" data-i18n="release.restartUpdate">Restart to update</button>
<a class="about-link about-link-secondary" id="releaseAllLink" href="https://github.com/stemdeckapp/stemdeck/releases" target="_blank" rel="noopener noreferrer" data-i18n="release.allReleases">All releases</a>
</div>
</div>
</div>
<!-- Failure detail dialog (opened from a notification card). The report
link is a plain external anchor: the global handler in main.js routes
it through Tauri's open_url on desktop and a new tab in a browser. -->
<div class="about-backdrop hidden" id="failureDialog" role="dialog" aria-modal="true" aria-labelledby="failureTitle">
<div class="about-card failure-card">
<button class="about-close" id="failureClose" type="button" aria-label="Close failure dialog" data-i18n-aria-label="failure.closeAria">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
<div class="about-logo failure-logo" aria-hidden="true">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 9v4 M12 17h.01 M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/>
</svg>
</div>
<h2 id="failureTitle" data-i18n="failure.title">Something failed</h2>
<span class="about-version-badge failure-when" id="failureWhen"></span>
<p class="failure-message" id="failureMessage"></p>
<p class="failure-hint" data-i18n="failure.hint">These details go in the report. Your track title and source link are not included - add them yourself if they help. Clicking a button below copies this to your clipboard.</p>
<pre class="failure-tech"><code id="failureTech"></code></pre>
<button class="failure-logs-btn" id="failureIncludeLogs" type="button" data-i18n="failure.includeLogs">Include recent logs</button>
<div class="about-primary-links">
<a class="about-link about-link-primary" id="failureReport" target="_blank" rel="noopener noreferrer" data-i18n="failure.reportGithub">Report on GitHub</a>
<a class="about-link about-link-secondary" id="failureReportDiscord" target="_blank" rel="noopener noreferrer" data-i18n="failure.reportDiscord">Report on Discord</a>
</div>
</div>
</div>
<script type="module" src="/js/main.js"></script>
<script type="module" src="/js/ui-chrome.js"></script>
</body>
</html>