aboutsummaryrefslogtreecommitdiff
path: root/lib/project/src
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-12 21:18:34 -0400
committerhistoria <historiavg@proton.me>2026-09-12 21:18:34 -0400
commit544134f0534d2e4e4fd7ab823d8a0da543fa80ef (patch)
treeef0f787b6377608b3814ac642804e8c778980ddf /lib/project/src
parent0cd53b67793e74e8be0201b90711cb51b7186d6e (diff)
downloadproducer-544134f0534d2e4e4fd7ab823d8a0da543fa80ef.tar.gz
fix: output wavs from gui go into correct directory
Diffstat (limited to 'lib/project/src')
-rw-r--r--lib/project/src/producer/cli.py41
-rw-r--r--lib/project/src/producer/config.py8
-rw-r--r--lib/project/src/producer/gui.py26
-rw-r--r--lib/project/src/producer/gui_jobs.py32
-rw-r--r--lib/project/src/producer/pipeline.py102
-rw-r--r--lib/project/src/producer/web/app.js35
-rw-r--r--lib/project/src/producer/web/index.html11
-rw-r--r--lib/project/src/producer/web/style.css36
8 files changed, 234 insertions, 57 deletions
diff --git a/lib/project/src/producer/cli.py b/lib/project/src/producer/cli.py
index bf94ed4..68cca74 100644
--- a/lib/project/src/producer/cli.py
+++ b/lib/project/src/producer/cli.py
@@ -14,8 +14,8 @@ from rich.console import Console
from . import __version__
from .audio import ffmpeg_run
-from .config import PROFILES, Settings, resolve, toml
-from .pipeline import check_target, process, report_for, targets_for
+from .config import PROFILES, Settings, profile_label, resolve, toml
+from .pipeline import check_target, process, targets_for
from .progress import Display
from .setup import data_dir, ensure_ffmpeg
@@ -48,7 +48,7 @@ def parser():
if name in ("process", "preview"):
cmd.add_argument("--output-dir", type=Path, default=None,
help="Directory for generated outputs (default: beside each input)")
- cmd.add_argument("--overwrite", action="store_true", help="Replace existing audio outputs, never input files (report logs are always replaced)")
+ cmd.add_argument("--overwrite", action="store_true", help="Replace existing audio outputs, never input files (report logs are timestamped, never replaced)")
if name == "process":
cmd.add_argument("-o", "--output", type=Path, help="Explicit WAV destination (one input only)")
if name == "preview":
@@ -135,7 +135,8 @@ def main():
raise ValueError("--output and --output-dir cannot be combined")
# Without an explicit destination, outputs are written beside
# each input; --output-dir keeps every result in one directory.
- outputs = [args.output or (args.output_dir or p.parent) / f"{p.stem}.{settings.profile}.wav"
+ # Settings that differ from the built-in profile are 'custom'.
+ outputs = [args.output or (args.output_dir or p.parent) / f"{p.stem}.{profile_label(settings)}.wav"
for p in args.inputs]
# Preflight the whole plan so a predictable conflict cannot
# fail a batch after earlier inputs have already been published.
@@ -151,11 +152,10 @@ def main():
if resolved & sources or any(
target.exists() and os.path.samefile(source, target) for target in targets):
raise ValueError("An output would overwrite an input recording")
- report = report_for(output)
for target in targets:
- # Report logs are replaced silently; only audio
- # outputs require --overwrite.
- check_target(source, target, args.overwrite or target == report)
+ # Report logs are timestamped in the shared logs
+ # directory; only audio outputs require --overwrite.
+ check_target(source, target, args.overwrite)
for source, output in zip(args.inputs, outputs):
report = process(source, output, settings, display.update, args.overwrite)
console.print(f"Written: {output} | {report['master_loudness']['input_i']} LUFS | {report['master_loudness']['input_tp']} dBTP", markup=False)
@@ -173,21 +173,26 @@ def main():
original = source.resolve()
output_dir = args.output_dir or source.parent
# Preflight every preview destination before extracting the excerpt.
+ # Settings that differ from the built-in profile are marked
+ # 'custom', but the profile name stays in every preview file so
+ # multiple profiles never collide.
claimed: set[Path] = set()
for profile in args.profiles:
- output = output_dir / f"{source.stem}.{profile}.preview.wav"
+ configured = settings_for(args, profile)
+ label = profile_label(configured)
+ token = profile if label == profile else f"{label}.{profile}"
+ output = output_dir / f"{source.stem}.{token}.preview.wav"
if output.resolve() == original:
raise ValueError("Preview output would overwrite the original")
- report = report_for(output)
- targets = [output, report]
+ targets = [output]
resolved = {target.resolve() for target in targets}
if len(resolved) < len(targets) or resolved & claimed:
raise ValueError("Preview outputs have duplicate names")
claimed |= resolved
for target in targets:
- # Report logs are replaced silently; only audio outputs
- # require --overwrite.
- check_target(original, target, args.overwrite or target == report)
+ # Report logs are timestamped in the shared logs
+ # directory; only audio outputs require --overwrite.
+ check_target(original, target, args.overwrite)
output_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".preview-", dir=output_dir) as tmp:
excerpt = Path(tmp) / "excerpt.wav"
@@ -195,10 +200,14 @@ def main():
"-map", "0:a:0", "-c:a", "pcm_f32le", str(excerpt)], "Extracting preview", args.duration, display.update)
for profile in args.profiles:
configured = settings_for(args, profile)
- # Equal loudness prevents a louder preset winning an unfair A/B.
+ # Name previews from the requested settings (the preview
+ # itself then forces equal-loudness normalization and
+ # limiting so a louder preset cannot win an unfair A/B).
+ label = profile_label(configured)
+ token = profile if label == profile else f"{label}.{profile}"
+ output = output_dir / f"{source.stem}.{token}.preview.wav"
configured.normalize = True
configured.limiter = True
- output = output_dir / f"{source.stem}.{profile}.preview.wav"
report = process(excerpt, output, configured, display.update, args.overwrite,
provenance={"original": str(original), "preview_start_seconds": args.start,
"requested_duration_seconds": args.duration,
diff --git a/lib/project/src/producer/config.py b/lib/project/src/producer/config.py
index 1d18054..853d043 100644
--- a/lib/project/src/producer/config.py
+++ b/lib/project/src/producer/config.py
@@ -124,6 +124,14 @@ def resolve(path: Path | None = None, profile: str | None = None,
return Settings(**merged)
+def profile_label(settings: Settings) -> str:
+ """Name to use for generated outputs: the built-in profile, or 'custom'
+ when any effective setting differs from that pure built-in profile."""
+ if asdict(settings) == asdict(resolve(profile=settings.profile)):
+ return settings.profile
+ return "custom"
+
+
def toml(settings: Settings) -> str:
lines = ["# Producer configuration. CLI flags override these values."]
for field in fields(settings):
diff --git a/lib/project/src/producer/gui.py b/lib/project/src/producer/gui.py
index 00ea229..1f22083 100644
--- a/lib/project/src/producer/gui.py
+++ b/lib/project/src/producer/gui.py
@@ -15,9 +15,10 @@ import socket
import tomllib
import uuid
-from .config import resolve, toml
+from .config import profile_label, resolve, toml
from .gui_jobs import Busy, Jobs, input_info
from .gui_schema import schema
+from .pipeline import targets_for
BODY_LIMIT = 65536
UNSAFE_NAME = re.compile(r"[^\w .+()-]", re.ASCII)
@@ -221,7 +222,8 @@ def create_app(port, *, config=None, profile=None, overrides=None, output_dir=No
@app.post("/api/jobs", status_code=202)
async def submit_job(request: Request):
body = object_body(await request.json(),
- {"kind", "upload_id", "input_id", "settings", "start", "duration", "output_dir"})
+ {"kind", "upload_id", "input_id", "settings", "start", "duration",
+ "output_dir", "overwrite"})
kind = body.get("kind")
if kind not in ("scan", "preview", "export"):
raise ValueError("kind must be scan, preview or export")
@@ -240,8 +242,28 @@ def create_app(port, *, config=None, profile=None, overrides=None, output_dir=No
snapshot.update(start=start, duration=duration)
if kind == "export":
snapshot["output_dir"] = str(output_directory(body.get("output_dir"), default_output))
+ overwrite = body.get("overwrite", False)
+ if type(overwrite) is not bool:
+ raise ValueError("overwrite must be a boolean")
+ snapshot["overwrite"] = overwrite
return jobs.submit(kind, snapshot)
+ @app.post("/api/export/check")
+ async def export_check(request: Request):
+ """Preflight for the GUI export: exact target paths and whether any
+ already exists, so the browser can ask before replacing audio."""
+ body = object_body(await request.json(), {"upload_id", "input_id", "settings", "output_dir"})
+ info = jobs.upload_info(body.get("upload_id"))
+ if body.get("input_id") != info["input_id"]:
+ raise ValueError("Input changed; select and scan it again")
+ settings = configured(body.get("settings", {}))
+ parent = output_directory(body.get("output_dir"), default_output)
+ stem = Path(info["name"]).stem or "master"
+ target = parent / f"{stem}.{profile_label(settings)}.wav"
+ targets = targets_for(target, settings)
+ return {"exists": any(target.exists() for target in targets),
+ "paths": [str(path) for path in targets]}
+
@app.get("/api/results/{result_id}/{kind}.wav")
def audio(result_id: str, kind: str):
return Response(jobs.media(result_id, kind), media_type="audio/wav")
diff --git a/lib/project/src/producer/gui_jobs.py b/lib/project/src/producer/gui_jobs.py
index d35a3d4..2db916f 100644
--- a/lib/project/src/producer/gui_jobs.py
+++ b/lib/project/src/producer/gui_jobs.py
@@ -19,7 +19,7 @@ import numpy as np
import soundfile as sf
from .audio import ffmpeg_run, loudness
-from .config import Settings
+from .config import Settings, profile_label
from .pipeline import process, targets_for
from .setup import ensure_ffmpeg
@@ -294,22 +294,20 @@ class Jobs:
parent = Path(snapshot["output_dir"])
if not parent.is_absolute() or not parent.is_dir():
raise ValueError("Choose an existing absolute output directory on this machine")
- # A private, new delivery folder prevents audio AND report clobbering.
- delivery = Path(tempfile.mkdtemp(prefix="producer-export-", dir=parent))
- try:
- target = delivery / "master.wav"
- settings = Settings(**snapshot["settings"])
- report = process(source, target, settings, progress,
- provenance={"gui_snapshot": snapshot})
- result = {"paths": [str(path) for path in targets_for(target, settings)],
- "settings": asdict(settings), "warnings": report["warnings"],
- "duration": report["master"]["duration_seconds"],
- "notice": "Pacing edits changed duration and were not auditioned in the sound comparison." if settings.trim_silence else "Full-file export completed."}
- except BaseException:
- # If cancellation occurs at publication, preserve any completed audio.
- if not (delivery / "master.wav").exists() and not (delivery / "master.mp3").exists():
- shutil.rmtree(delivery, ignore_errors=True)
- raise
+ # The audio file is written directly into the chosen
+ # output directory; the timestamped report log goes to
+ # the shared logs directory (PRODUCER_ROOT/logs).
+ settings = Settings(**snapshot["settings"])
+ stem = Path(snapshot["name"]).stem or "master"
+ target = parent / f"{stem}.{profile_label(settings)}.wav"
+ report = process(source, target, settings, progress,
+ overwrite=snapshot.get("overwrite", False),
+ provenance={"gui_snapshot": snapshot})
+ result = {"paths": [str(path) for path in targets_for(target, settings)]
+ + [report["report"]],
+ "settings": asdict(settings), "warnings": report["warnings"],
+ "duration": report["master"]["duration_seconds"],
+ "notice": "Pacing edits changed duration and were not auditioned in the sound comparison." if settings.trim_silence else "Full-file export completed."}
self._check_input(snapshot)
with self.condition:
if job["kind"] != "export":
diff --git a/lib/project/src/producer/pipeline.py b/lib/project/src/producer/pipeline.py
index c730016..d3371e3 100644
--- a/lib/project/src/producer/pipeline.py
+++ b/lib/project/src/producer/pipeline.py
@@ -1,10 +1,13 @@
"""Offline mono voice mastering with float intermediates and atomic outputs."""
from dataclasses import asdict
+from datetime import datetime
+import errno
import fcntl
import json
import math
import os
from pathlib import Path
+import shutil
import tempfile
from . import __version__
@@ -31,14 +34,40 @@ def filters(settings: Settings) -> list[str]:
return result or ["anull"]
+def logs_dir() -> Path:
+ """Every diagnostic report log lives in the shared logs directory next to
+ producer.sh (PRODUCER_ROOT, exported by the launcher), no matter where
+ audio outputs go."""
+ root = os.environ.get("PRODUCER_ROOT")
+ if not root:
+ home = os.environ.get("PRODUCER_HOME")
+ if home:
+ home = Path(home)
+ # Cover direct launches inside the contained runtime (its layout
+ # is <project root>/lib/runtime).
+ if home.name == "runtime" and home.parent.name == "lib":
+ root = str(home.parent.parent)
+ return (Path(root) if root else Path.cwd()) / "logs"
+
+
def report_for(destination: Path) -> Path:
- """Diagnostic reports live in a logs/ directory, out of the way of outputs."""
- return destination.parent / "logs" / destination.with_suffix(".report.json").name
+ """Timestamped report path in the shared logs directory. Called at
+ publication time; never overwrites or collides with an earlier report."""
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+ counter = 1
+ while True:
+ suffix = "" if counter == 1 else f"_{counter}"
+ path = logs_dir() / f"{destination.stem}.{stamp}{suffix}.report.json"
+ if not os.path.lexists(path):
+ return path
+ counter += 1
def targets_for(destination: Path, settings: Settings) -> list[Path]:
- """Every path a successful run publishes: master WAV, report, optional MP3."""
- targets = [destination, report_for(destination)]
+ """Every audio path a successful run publishes: master WAV, optional MP3.
+ The timestamped report path is computed at publication time and returned
+ by process()."""
+ targets = [destination]
if settings.mp3:
targets.append(destination.with_suffix(".mp3"))
return targets
@@ -56,12 +85,36 @@ def check_target(source: Path, target: Path, overwrite: bool) -> None:
def publish(source: Path, target: Path, overwrite: bool):
+ try:
+ if overwrite:
+ os.replace(source, target)
+ else:
+ # Link is atomic and fails if another process created the destination.
+ os.link(source, target)
+ source.unlink()
+ return
+ except FileExistsError:
+ raise
+ except OSError as error:
+ if error.errno != errno.EXDEV:
+ raise
+ # The staged file and the target live on different filesystems (audio
+ # outputs and the shared logs directory can be on different mounts):
+ # copy into the target's directory first, then publish atomically there.
+ staged = target.parent / f".{target.name}.tmp-{os.getpid()}"
+ shutil.copyfile(source, staged)
if overwrite:
- os.replace(source, target)
+ os.replace(staged, target)
else:
- # Link is atomic and fails if another process created the destination.
- os.link(source, target)
- source.unlink()
+ # Same filesystem now, so the link is atomic again and still fails
+ # if another process created the destination meanwhile.
+ try:
+ os.link(staged, target)
+ except FileExistsError:
+ staged.unlink(missing_ok=True)
+ raise
+ staged.unlink()
+ source.unlink(missing_ok=True)
def process(source: Path, destination: Path, settings: Settings, progress,
@@ -71,11 +124,10 @@ def process(source: Path, destination: Path, settings: Settings, progress,
raise ValueError(f"Input must be an existing WAV file: {source}")
if destination.suffix.lower() != ".wav":
raise ValueError("Master output must have a .wav extension")
- report_path = report_for(destination)
for target in targets_for(destination, settings):
- # Report logs are diagnostic: a rerun replaces them silently. Only
- # audio outputs require --overwrite.
- check_target(source, target, overwrite or target == report_path)
+ # Only audio outputs are checked here; the timestamped report path is
+ # uniquified and never collides.
+ check_target(source, target, overwrite)
ffmpeg = ensure_ffmpeg()
original = measure(source, progress, "Analyzing original")
if original["sample_peak_dbfs"] < -100:
@@ -89,7 +141,6 @@ def process(source: Path, destination: Path, settings: Settings, progress,
if max(abs(x) for x in original["dc_offset"]) > 0.01:
warnings.append("Significant input DC offset detected; enable highpass filtering to remove it.")
destination.parent.mkdir(parents=True, exist_ok=True)
- report_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".producer-", dir=destination.parent) as temporary:
work = Path(temporary)
prepared, cleaned, shaped = [work / name for name in ("prepared.wav", "cleaned.wav", "shaped.wav")]
@@ -239,17 +290,28 @@ def process(source: Path, destination: Path, settings: Settings, progress,
if provenance:
report["provenance"] = provenance
staged_report = work / "report.json"
- staged_report.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n")
# Publish only after all processing and verification succeeded. The
- # directory lock serializes publication so concurrent jobs targeting
- # the same output directory cannot interleave WAV/report/MP3 sets.
+ # directory locks serialize publication so concurrent jobs targeting
+ # the same output directory or logs directory cannot interleave
+ # WAV/report/MP3 sets. Lock ordering is output directory first.
+ logs = logs_dir()
+ logs.mkdir(parents=True, exist_ok=True)
publication_fd = os.open(destination.parent, os.O_RDONLY)
try:
fcntl.flock(publication_fd, fcntl.LOCK_EX)
- if settings.mp3:
- publish(mp3, destination.with_suffix(".mp3"), overwrite)
- publish(staged_report, report_path, True)
- publish(master, destination, overwrite)
+ logs_fd = os.open(logs, os.O_RDONLY)
+ try:
+ fcntl.flock(logs_fd, fcntl.LOCK_EX)
+ # Timestamped under the held lock, so the name is race-free.
+ report_path = report_for(destination)
+ report["report"] = str(report_path)
+ staged_report.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n")
+ if settings.mp3:
+ publish(mp3, destination.with_suffix(".mp3"), overwrite)
+ publish(staged_report, report_path, True)
+ publish(master, destination, overwrite)
+ finally:
+ os.close(logs_fd)
finally:
os.close(publication_fd)
return report
diff --git a/lib/project/src/producer/web/app.js b/lib/project/src/producer/web/app.js
index c0bfd8a..f53e054 100644
--- a/lib/project/src/producer/web/app.js
+++ b/lib/project/src/producer/web/app.js
@@ -501,6 +501,9 @@ async function poll() {
const active = [...state.jobs.values()].filter(job => !terminal(job));
$('jobs-status').textContent = active.length ? `${active.length} job(s) active or waiting. ${active.find(job => job.status === 'running')?.progress.stage || 'Waiting for worker'}.` : `${state.jobs.size} recent job(s) · nothing running.`;
} catch (e) {
+ // An interrupted connection may have missed job list updates, so any
+ // still-pending playback start is superseded until the retry succeeds.
+ pause();
$('connection').textContent = 'Connection interrupted. Retrying… Keep the Producer terminal running; jobs may still be active. Do not resubmit an export until you check Activity.';
error(e);
} finally { setTimeout(poll, 750); }
@@ -679,13 +682,43 @@ function download(blob) {
const url = URL.createObjectURL(blob), link = node('a'); link.href = url; link.download = 'producer-preset.toml';
document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 30000);
}
+function confirmOverwrite(paths) {
+ // Small promise-based modal: red Overwrite / Cancel. Resolves true only
+ // when the user explicitly confirms replacing the listed files.
+ return new Promise(resolve => {
+ $('overwrite-paths').replaceChildren(...paths.map(path => node('li', path)));
+ const dialog = $('overwrite-dialog');
+ const done = value => {
+ dialog.hidden = true;
+ $('overwrite-confirm').removeEventListener('click', onConfirm);
+ $('overwrite-cancel').removeEventListener('click', onCancel);
+ resolve(value);
+ };
+ const onConfirm = () => done(true);
+ const onCancel = () => done(false);
+ $('overwrite-confirm').addEventListener('click', onConfirm);
+ $('overwrite-cancel').addEventListener('click', onCancel);
+ dialog.hidden = false;
+ $('overwrite-cancel').focus();
+ });
+}
async function prepareExport() {
if (!state.input) throw new Error('Choose a recording first.');
const version = state.version, input = copy(state.input), settings = readSettings(), output_dir = $('output-dir').value.trim();
const data = await api('/api/resolve', {settings});
if (version !== state.version) throw new Error('Controls changed while starting the export. Try again.');
+ // Ask the server for the exact target paths before processing anything.
+ const check = await api('/api/export/check', {upload_id: input.upload_id, input_id: input.input_id, settings: copy(data.settings), output_dir});
+ if (version !== state.version) throw new Error('Controls changed while starting the export. Try again.');
+ const payload = {kind: 'export', upload_id: input.upload_id, input_id: input.input_id, settings: copy(data.settings), output_dir};
+ if (check.exists) {
+ const overwrite = await confirmOverwrite(check.paths);
+ if (version !== state.version) throw new Error('Controls changed while starting the export. Try again.');
+ if (!overwrite) return;
+ payload.overwrite = true;
+ }
// Export starts immediately; settings are frozen in the submitted job snapshot.
- await submit('export', {kind: 'export', upload_id: input.upload_id, input_id: input.input_id, settings: copy(data.settings), output_dir});
+ await submit('export', payload);
$('jobs-panel').scrollIntoView({block: 'nearest'});
}
function showExport(job) {
diff --git a/lib/project/src/producer/web/index.html b/lib/project/src/producer/web/index.html
index ef1247e..bb6d567 100644
--- a/lib/project/src/producer/web/index.html
+++ b/lib/project/src/producer/web/index.html
@@ -14,6 +14,15 @@
<main>
<div id="error" class="error" role="alert" hidden><span id="error-text"></span><button id="dismiss-error" type="button">Dismiss</button></div>
<div id="toast" class="toast" role="status" hidden><div><strong id="toast-title"></strong><ul id="toast-list"></ul></div></div>
+ <div id="overwrite-dialog" class="dialog" hidden role="dialog" aria-modal="true" aria-labelledby="overwrite-title">
+ <div class="dialog-box">
+ <h3 id="overwrite-title">Replace existing files?</h3>
+ <p class="small">An export with the current settings would replace:</p>
+ <ul id="overwrite-paths"></ul>
+ <p class="small">Original recordings are never touched.</p>
+ <div class="dialog-actions"><button id="overwrite-cancel" type="button">Cancel</button><button id="overwrite-confirm" type="button" class="danger">Overwrite</button></div>
+ </div>
+ </div>
<noscript>This tool needs JavaScript. Reload this page after starting Producer.</noscript>
<fieldset id="workspace" disabled>
<legend class="sr-only">Producer studio</legend>
@@ -63,7 +72,7 @@
<section id="export" class="panel">
<h2>Export</h2>
- <label class="output-label">Output directory<input id="output-dir" type="text" spellcheck="false"></label><p class="small">Absolute path on this machine; must already exist. Each export gets a fresh <code>producer-export-*</code> folder: <code>master.wav</code>, optional <code>master.mp3</code> and <code>logs/master.report.json</code>.</p>
+ <label class="output-label">Output directory<input id="output-dir" type="text" spellcheck="false"></label><p class="small">Absolute path on this machine; must already exist. The audio file (<code>&lt;name&gt;.&lt;profile&gt;.wav</code>, plus <code>&lt;name&gt;.&lt;profile&gt;.mp3</code> when MP3 export is on) is written directly into this directory. The timestamped diagnostic report goes to the <code>logs/</code> directory next to <code>producer.sh</code>. Settings that differ from the built-in profile are named <code>custom</code>.</p>
<div class="action-row"><button id="prepare-export" class="primary danger" type="button" disabled>Render and Export</button><span class="small">Processes the entire original, not just the selected passage. Originals are never overwritten.</span></div>
<div id="export-results" aria-label="Completed exports"></div>
</section>
diff --git a/lib/project/src/producer/web/style.css b/lib/project/src/producer/web/style.css
index 73ba09b..7633e1a 100644
--- a/lib/project/src/producer/web/style.css
+++ b/lib/project/src/producer/web/style.css
@@ -632,6 +632,42 @@ summary .small {
clip:rect(0,0,0,0);
white-space:nowrap
}
+.dialog {
+ position:fixed;
+ inset:0;
+ background:rgba(37, 56, 46, .45);
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ z-index:30;
+ padding:16px
+}
+.dialog-box {
+ background:#fff;
+ border:1px solid var(--line);
+ border-radius:10px;
+ padding:18px;
+ max-width:min(520px, 100%);
+ box-shadow:0 8px 24px rgba(37, 56, 46, .25);
+ overflow-wrap:anywhere
+}
+.dialog-box h3 {
+ margin:0 0 8px
+}
+.dialog-box ul {
+ margin:6px 0;
+ padding-left:20px;
+ font-size:.84rem
+}
+.dialog-box li {
+ font-family:ui-monospace, monospace
+}
+.dialog-actions {
+ display:flex;
+ justify-content:flex-end;
+ gap:10px;
+ margin-top:14px
+}
[hidden] {
display:none!important
}