diff --git a/.github/workflows/frontend-ui.yml b/.github/workflows/frontend-ui.yml
index ce7f61e..d69a2f5 100644
--- a/.github/workflows/frontend-ui.yml
+++ b/.github/workflows/frontend-ui.yml
@@ -8,6 +8,7 @@ on:
- "package.json"
- "tests/*.test.js"
- "Launch PDF Magic.command"
+ - "serve.py"
- ".github/workflows/frontend-ui.yml"
pull_request:
paths:
@@ -15,6 +16,7 @@ on:
- "package.json"
- "tests/*.test.js"
- "Launch PDF Magic.command"
+ - "serve.py"
- ".github/workflows/frontend-ui.yml"
jobs:
@@ -27,3 +29,5 @@ jobs:
node-version: "22"
- name: Run frontend unit tests and validate the Mac launcher
run: npm test
+ - name: Test local file upload server
+ run: python3 -m unittest discover -s tests -p 'test_local_server.py' -v
diff --git a/.gitignore b/.gitignore
index 7a22182..7d118f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
__pycache__/
.google-books-browser/
*-pages/
+.pdfmagic-uploads/
diff --git a/Launch PDF Magic.command b/Launch PDF Magic.command
index 6873310..da08a07 100755
--- a/Launch PDF Magic.command
+++ b/Launch PDF Magic.command
@@ -7,8 +7,8 @@ url="http://127.0.0.1:4173/"
is_toolbench_running() {
local page
- page="$(curl --silent --fail --max-time 2 "$url" 2>/dev/null)" || return 1
- [[ "$page" == *"
PDF Magic Toolbench"* ]]
+ page="$(curl --silent --fail --max-time 2 "${url}api/status" 2>/dev/null)" || return 1
+ [[ "$page" == *'"upload": true'* ]]
}
if is_toolbench_running; then
@@ -18,12 +18,13 @@ if is_toolbench_running; then
fi
if lsof -nP -iTCP:4173 -sTCP:LISTEN >/dev/null 2>&1; then
- echo "Port 4173 is already in use by another service; not opening an unrelated website."
+ echo "Port 4173 is occupied by an older static server or another application."
+ echo "Stop it with Control-C, then start PDF Magic again to enable file imports."
exit 1
fi
echo "Starting PDF Magic on $url"
-python3 -m http.server 4173 --bind 127.0.0.1 --directory web &
+python3 serve.py &
server_pid=$!
trap 'kill "$server_pid" 2>/dev/null || true' EXIT
diff --git a/README.md b/README.md
index 11819b9..f978930 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,19 @@ The dependency-free [web interface](web/index.html) keeps Google Books export,
OCR, local AI review, and compression in separate reusable modules. Each panel
validates its own inputs and builds the corresponding local CLI command.
+**Choose input files by clicking the drop zone or dragging them into it.**
+Supported inputs include PDF scans, JSON tagging reports, and JSONL AI reviews.
+The localhost Python web server streams an unmodified copy into
+`.pdfmagic-uploads/` and substitutes the staged path into the built command.
+No manual file path is needed for inputs. You can delete this ignored staging
+directory when the imports are no longer needed.
+
+The upload endpoint is bound to `127.0.0.1` and rejects cross-origin requests.
+The selected documents stay on your Mac. A static deployment (for example,
+Netlify) can display the toolbench but cannot provide this local upload
+endpoint. The interface still builds commands for Terminal; it does not
+execute PDF processing or Google Books captures itself.
+
```sh
npm run serve
```
@@ -16,9 +29,9 @@ npm run serve
Open `http://localhost:4173` in a browser. On macOS, **double-click
[`Launch PDF Magic.command`](Launch%20PDF%20Magic.command)** in Finder to start
the local server and automatically open the site in your default browser.
-Alternatively, run `npm run open` from the repository root. If PDF Magic
-is already serving port 4173, the launcher opens the existing instance
-rather than starting another. The launcher binds its new server to localhost;
+Alternatively, run `npm run open` from the repository root. If the upload-capable server is already listening on port 4173, the
+launcher opens that instance instead of starting another. If an older static
+server occupies the port, stop it with Control-C before relaunching. The launcher binds its new server to localhost;
leave its Terminal window open to keep the server running.
The interface follows the system appearance by default. Use the **Dark mode /
diff --git a/package.json b/package.json
index 12809db..25e7f22 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"test": "node --test tests/*.test.js",
- "serve": "python3 -m http.server 4173 --directory web",
+ "serve": "python3 serve.py",
"open": "bash './Launch PDF Magic.command'"
}
}
diff --git a/serve.py b/serve.py
new file mode 100644
index 0000000..af9f2d8
--- /dev/null
+++ b/serve.py
@@ -0,0 +1,110 @@
+#!/usr/bin/env python3
+"""Local-only PDF Magic web toolbench and streaming file-import endpoint."""
+
+import json
+import re
+import uuid
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import parse_qs, urlsplit
+
+ROOT = Path(__file__).resolve().parent
+WEB_DIR = ROOT / "web"
+UPLOAD_DIR = ROOT / ".pdfmagic-uploads"
+HOST = "127.0.0.1"
+PORT = 4173
+CHUNK_SIZE = 1024 * 1024
+ALLOWED_EXTENSIONS = {".pdf", ".json", ".jsonl"}
+
+
+def safe_filename(raw):
+ """Avoid traversal and preserve a legible name after a random prefix."""
+ basename = raw.replace("\\", "/").rsplit("/", 1)[-1]
+ ext = Path(basename).suffix.lower()
+ if ext not in ALLOWED_EXTENSIONS:
+ raise ValueError("Only PDF, JSON, and JSONL files can be imported.")
+ stem = re.sub(r"[^\w .()\-]", "_", basename[: -len(ext)]).strip(" .")[:100]
+ return (stem or "document") + ext
+
+
+class ToolbenchHandler(SimpleHTTPRequestHandler):
+ upload_dir = UPLOAD_DIR
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, directory=str(WEB_DIR), **kwargs)
+
+ def respond_json(self, status, value):
+ body = json.dumps(value).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("X-Content-Type-Options", "nosniff")
+ self.end_headers()
+ self.wfile.write(body)
+
+ def trusted_request(self):
+ host = self.headers.get("Host", "")
+ port = self.server.server_address[1]
+ return host in {f"localhost:{port}", f"127.0.0.1:{port}"}
+
+ def do_GET(self):
+ if urlsplit(self.path).path == "/api/status":
+ if not self.trusted_request():
+ return self.respond_json(403, {"error": "Untrusted host."})
+ return self.respond_json(200, {"upload": True})
+ return super().do_GET()
+
+ def do_POST(self):
+ if urlsplit(self.path).path != "/api/upload":
+ return self.respond_json(404, {"error": "Not found."})
+ port = self.server.server_address[1]
+ origin = self.headers.get("Origin", "")
+ if (not self.trusted_request() or origin not in {
+ f"http://localhost:{port}", f"http://127.0.0.1:{port}"
+ }):
+ return self.respond_json(403, {"error": "Local, same-origin uploads only."})
+ if self.headers.get("Content-Type", "").split(";")[0] != "application/octet-stream":
+ return self.respond_json(415, {"error": "Expected a raw file upload."})
+ try:
+ size = int(self.headers.get("Content-Length", ""))
+ except ValueError:
+ return self.respond_json(411, {"error": "Content-Length is required."})
+ if size <= 0:
+ return self.respond_json(400, {"error": "Cannot upload an empty file."})
+ filename = parse_qs(urlsplit(self.path).query).get("name", [""])[0]
+ try:
+ safe_name = safe_filename(filename)
+ except ValueError as error:
+ return self.respond_json(400, {"error": str(error)})
+
+ self.upload_dir.mkdir(parents=True, exist_ok=True)
+ destination = self.upload_dir / f"{uuid.uuid4().hex}-{safe_name}"
+ remaining = size
+ try:
+ with destination.open("xb") as output:
+ while remaining:
+ data = self.rfile.read(min(CHUNK_SIZE, remaining))
+ if not data:
+ raise ConnectionError("Upload interrupted.")
+ output.write(data)
+ remaining -= len(data)
+ except (OSError, ConnectionError):
+ destination.unlink(missing_ok=True)
+ return self.respond_json(400, {"error": "Upload failed or was interrupted."})
+ return self.respond_json(201, {"path": str(destination.resolve()), "name": safe_name})
+
+
+def main():
+ server = ThreadingHTTPServer((HOST, PORT), ToolbenchHandler)
+ print(f"PDF Magic running at http://{HOST}:{PORT}/", flush=True)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.server_close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/file-inputs.test.js b/tests/file-inputs.test.js
new file mode 100644
index 0000000..34355da
--- /dev/null
+++ b/tests/file-inputs.test.js
@@ -0,0 +1,36 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { ocr } from "../web/js/tools/ocr.js";
+import { aiReview } from "../web/js/tools/ai-review.js";
+import { compression } from "../web/js/tools/compression.js";
+
+test("source file fields are genuine file-picker targets", () => {
+ for (const [tool, name, accept] of [
+ [ocr, "input", ".pdf"],
+ [aiReview, "pdf", ".pdf"],
+ [aiReview, "report", ".json"],
+ [compression, "input", ".pdf"],
+ [compression, "review", ".jsonl"],
+ ]) {
+ assert.deepEqual(
+ { type: tool.fields.find(f => f.name === name)?.type,
+ accept: tool.fields.find(f => f.name === name)?.accept },
+ { type: "file", accept },
+ );
+ }
+});
+
+test("command builders accept staged paths with spaces and keep output editable", () => {
+ assert.equal(
+ ocr.build({ input: "/tmp/PDF Magic/scan.pdf", output: "enhanced.pdf" }),
+ "./ocr-scanned-pdf.sh '/tmp/PDF Magic/scan.pdf' enhanced.pdf",
+ );
+ assert.equal(
+ aiReview.build({ pdf: "/tmp/book.pdf", report: "/tmp/blocks.json", output: "review.jsonl" }),
+ "python3 ollama-review-ocr.py /tmp/book.pdf /tmp/blocks.json review.jsonl",
+ );
+ assert.equal(
+ compression.build({ input: "/tmp/book.pdf", output: "compressed.pdf", review: "/tmp/notes.jsonl", mode: "tagged" }),
+ "./compress-and-tag-ocr-pdf.sh /tmp/book.pdf compressed.pdf /tmp/notes.jsonl",
+ );
+});
diff --git a/tests/test_local_server.py b/tests/test_local_server.py
new file mode 100644
index 0000000..52e6b8d
--- /dev/null
+++ b/tests/test_local_server.py
@@ -0,0 +1,103 @@
+"""Test local PDF Magic uploads, origin checks, and directory isolation."""
+
+import http.client
+import json
+import tempfile
+import threading
+import unittest
+from http.server import ThreadingHTTPServer
+from pathlib import Path
+
+from serve import ToolbenchHandler, safe_filename
+
+
+class ServerTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ folder = Path(self.temp.name)
+
+ class TestHandler(ToolbenchHandler):
+ upload_dir = folder
+
+ def log_message(self, *_args):
+ pass
+
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), TestHandler)
+ self.port = self.server.server_address[1]
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+ self.thread.start()
+
+ def tearDown(self):
+ self.server.shutdown()
+ self.server.server_close()
+ self.thread.join()
+ self.temp.cleanup()
+
+ def request(self, method, path, data=None, origin=None, host=None):
+ client = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
+ headers = {"Host": host or f"127.0.0.1:{self.port}"}
+ if data is not None:
+ headers["Content-Type"] = "application/octet-stream"
+ if origin is not None:
+ headers["Origin"] = origin
+ client.request(method, path, body=data, headers=headers)
+ response = client.getresponse()
+ result = (response.status, json.loads(response.read()))
+ client.close()
+ return result
+
+ def test_status(self):
+ self.assertEqual(self.request("GET", "/api/status"), (200, {"upload": True}))
+
+ def test_upload_stages_bytes_without_changing_name_or_source(self):
+ payload = b"%PDF-1.7\n" + b"X" * 65550
+ status, body = self.request(
+ "POST", "/api/upload?name=My%20Book.pdf",
+ data=payload, origin=f"http://127.0.0.1:{self.port}",
+ )
+ self.assertEqual(status, 201)
+ self.assertEqual(body["name"], "My Book.pdf")
+ path = Path(body["path"])
+ self.assertEqual(path.parent, Path(self.temp.name))
+ self.assertEqual(path.read_bytes(), payload)
+
+ def test_uploads_never_overwrite_one_another(self):
+ kwargs = {"data": b"{}", "origin": f"http://127.0.0.1:{self.port}"}
+ first = self.request("POST", "/api/upload?name=a.json", **kwargs)
+ second = self.request("POST", "/api/upload?name=a.json", **kwargs)
+ self.assertNotEqual(first[1]["path"], second[1]["path"])
+
+ def test_cross_site_request_cannot_stage_files(self):
+ status, _ = self.request(
+ "POST", "/api/upload?name=a.pdf",
+ data=b"sample", origin="https://evil.example",
+ )
+ self.assertEqual(status, 403)
+ self.assertEqual(list(Path(self.temp.name).iterdir()), [])
+
+ def test_missing_origin_is_rejected(self):
+ status, _ = self.request("POST", "/api/upload?name=a.pdf", data=b"sample")
+ self.assertEqual(status, 403)
+
+ def test_dns_rebinding_host_is_rejected(self):
+ status, _ = self.request(
+ "POST", "/api/upload?name=a.pdf", data=b"sample",
+ origin=f"http://127.0.0.1:{self.port}", host="evil.example",
+ )
+ self.assertEqual(status, 403)
+
+ def test_bad_extensions_rejected(self):
+ status, _ = self.request(
+ "POST", "/api/upload?name=malicious.sh", data=b"sample",
+ origin=f"http://127.0.0.1:{self.port}",
+ )
+ self.assertEqual(status, 400)
+
+ def test_filename_cannot_escape_staging_directory(self):
+ self.assertEqual(safe_filename("../../unsafe.pdf"), "unsafe.pdf")
+ self.assertEqual(safe_filename(r"C:\\tmp\\scan.jsonl"), "scan.jsonl")
+ self.assertRaises(ValueError, safe_filename, "../script.py")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/web/js/components/tool-panel.js b/web/js/components/tool-panel.js
index c28df9a..e36fa24 100644
--- a/web/js/components/tool-panel.js
+++ b/web/js/components/tool-panel.js
@@ -6,6 +6,13 @@ function element(tag, className, text) {
}
function fieldControl(field) {
+ if (field.type === "file") {
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = field.accept;
+ return input;
+ }
+
if (field.type === "select") {
const select = document.createElement("select");
for (const option of field.options) {
@@ -40,7 +47,9 @@ export class ToolPanel extends HTMLElement {
const data = {};
for (const field of this._definition.fields) {
const control = form.elements.namedItem(field.name);
- data[field.name] = field.type === "checkbox" ? control.checked : control.value;
+ data[field.name] = field.type === "file"
+ ? (control.dataset.uploadedPath || "")
+ : field.type === "checkbox" ? control.checked : control.value;
}
return data;
}
@@ -62,13 +71,104 @@ export class ToolPanel extends HTMLElement {
const form = document.createElement("form");
const fields = element("div", "fields");
+ let pendingUploads = 0;
for (const field of definition.fields) {
const wrapper = element("div", `field${field.wide ? " wide" : ""}`);
const control = fieldControl(field);
control.id = `${definition.id}-${field.name}`;
control.name = field.name;
- if (field.type === "checkbox") {
+ if (field.type === "file") {
+ const label = element("label", "", field.label);
+ label.htmlFor = control.id;
+ if (field.required) label.append(element("span", "required", " *"));
+
+ const zone = element("div", "file-drop-zone");
+ const symbol = element("span", "file-drop-symbol", "⇧");
+ symbol.setAttribute("aria-hidden", "true");
+ const instruction = element("span", "file-drop-instruction",
+ `Drop ${field.accept === ".pdf" ? "a PDF" : field.accept.toUpperCase() + " file"} here or click to choose`);
+ const hint = element("span", "file-drop-hint", "A copy stays on this Mac.");
+ const feedback = element("span", "file-drop-status", "No file selected");
+ feedback.setAttribute("role", "status");
+ feedback.setAttribute("aria-live", "polite");
+ feedback.id = `${control.id}-feedback`;
+ control.setAttribute("aria-describedby", feedback.id);
+ zone.append(symbol, instruction, hint, feedback, control);
+ wrapper.append(label, zone);
+ if (field.help) wrapper.append(element("span", "help", field.help));
+
+ let generation = 0;
+ async function importFile(file) {
+ if (!file) return;
+ const current = ++generation;
+ delete control.dataset.uploadedPath;
+ zone.classList.remove("file-ready", "file-error");
+ if (!file.name.toLowerCase().endsWith(field.accept)) {
+ feedback.textContent = `Select a ${field.accept} file.`;
+ zone.classList.add("file-error");
+ return;
+ }
+
+ pendingUploads += 1;
+ build.disabled = true;
+ zone.classList.add("file-uploading");
+ feedback.textContent = `Importing ${file.name}…`;
+ try {
+ const response = await fetch(
+ `/api/upload?name=${encodeURIComponent(file.name)}`,
+ { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: file },
+ );
+ if (!response.ok) {
+ throw new Error([404, 405, 501].includes(response.status)
+ ? "Use the local server (npm run serve or npm run open) to import files."
+ : `Local file import failed (HTTP ${response.status}).`);
+ }
+ const result = await response.json();
+ if (!result.path || typeof result.path !== "string") {
+ throw new Error("The local server did not return a file path.");
+ }
+ if (current !== generation) return;
+ control.dataset.uploadedPath = result.path;
+ zone.classList.add("file-ready");
+ feedback.textContent = `Ready: ${file.name}`;
+ } catch (error) {
+ if (current !== generation) return;
+ zone.classList.add("file-error");
+ feedback.textContent = error.message || "The file could not be imported.";
+ } finally {
+ pendingUploads -= 1;
+ zone.classList.remove("file-uploading");
+ build.disabled = pendingUploads > 0;
+ }
+ }
+
+ control.addEventListener("change", () => importFile(control.files?.[0]));
+ zone.addEventListener("dragenter", (event) => {
+ event.preventDefault();
+ zone.classList.add("file-drag-over");
+ });
+ zone.addEventListener("dragover", (event) => {
+ event.preventDefault();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
+ zone.classList.add("file-drag-over");
+ });
+ zone.addEventListener("dragleave", (event) => {
+ if (!zone.contains(event.relatedTarget)) zone.classList.remove("file-drag-over");
+ });
+ zone.addEventListener("drop", (event) => {
+ event.preventDefault();
+ zone.classList.remove("file-drag-over");
+ const files = event.dataTransfer?.files;
+ if (!files?.length) return;
+ if (files.length !== 1) {
+ feedback.textContent = "Select one file at a time.";
+ zone.classList.add("file-error");
+ return;
+ }
+ importFile(files[0]);
+ });
+ } else if (field.type === "checkbox") {
const check = element("label", "check-field");
check.htmlFor = control.id;
check.append(control, document.createTextNode(field.label));
@@ -105,6 +205,7 @@ export class ToolPanel extends HTMLElement {
form.addEventListener("submit", (event) => {
event.preventDefault();
+ if (pendingUploads > 0) return;
status.classList.remove("error");
try {
output.textContent = definition.build(this.values(form));
diff --git a/web/js/tools/ai-review.js b/web/js/tools/ai-review.js
index 9afed0d..ccbdd1e 100644
--- a/web/js/tools/ai-review.js
+++ b/web/js/tools/ai-review.js
@@ -8,8 +8,8 @@ export const aiReview = {
title: "AI OCR review",
summary: "Review OCR blocks against page images with local Ollama models.",
fields: [
- { name: "pdf", label: "OCR PDF", required: true, placeholder: "scan-ocr.pdf" },
- { name: "report", label: "Tagging report", required: true, placeholder: "scan-ocr.tagging.json" },
+ { name: "pdf", label: "OCR PDF", type: "file", accept: ".pdf", required: true },
+ { name: "report", label: "Tagging report", type: "file", accept: ".json", required: true },
{ name: "output", label: "Review output", required: true, placeholder: "review-v1.jsonl", wide: true },
{ name: "pages", label: "Pages", placeholder: "1-10,14,20", help: "Leave blank to review every page." },
{ name: "dpi", label: "Render DPI", type: "number", min: 1, placeholder: "180" },
diff --git a/web/js/tools/compression.js b/web/js/tools/compression.js
index e5ceceb..cdd3e4d 100644
--- a/web/js/tools/compression.js
+++ b/web/js/tools/compression.js
@@ -19,9 +19,9 @@ export const compression = {
{ value: "compression-only", label: "Compression only" },
],
},
- { name: "input", label: "Input PDF", required: true, placeholder: "scan-ocr.pdf" },
+ { name: "input", label: "Input PDF", type: "file", accept: ".pdf", required: true },
{ name: "output", label: "Output PDF", required: true, placeholder: "scan-compact.pdf" },
- { name: "review", label: "AI review JSONL", placeholder: "review-v1.jsonl", help: "Used by the compress-and-tag workflow." },
+ { name: "review", label: "AI review JSONL", type: "file", accept: ".jsonl", help: "Optional input for the compress-and-tag workflow." },
{ name: "report", label: "Compression report", placeholder: "compression.json", help: "Used by compression-only mode." },
{ name: "description", label: "Description", placeholder: "Optional; for tagged PDF" },
{ name: "keywords", label: "Tags / keywords", placeholder: "Comma-separated; for tagged PDF" },
diff --git a/web/js/tools/ocr.js b/web/js/tools/ocr.js
index efdc779..6f3f164 100644
--- a/web/js/tools/ocr.js
+++ b/web/js/tools/ocr.js
@@ -8,7 +8,7 @@ export const ocr = {
title: "Searchable OCR",
summary: "Create a searchable, structured derivative while preserving the source scan.",
fields: [
- { name: "input", label: "Source PDF", required: true, placeholder: "scan.pdf", wide: true },
+ { name: "input", label: "Source PDF", type: "file", accept: ".pdf", required: true, wide: true },
{ name: "output", label: "Output PDF", placeholder: "scan-enhanced-ocr.pdf", wide: true, help: "Optional; the CLI derives a sibling filename when omitted." },
{ name: "title", label: "Title", placeholder: "Preserve original if blank" },
{ name: "author", label: "Author", placeholder: "Preserve or detect if blank" },
diff --git a/web/styles.css b/web/styles.css
index 126b758..6d56666 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -246,6 +246,45 @@ input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px v
.help { color: var(--muted); font-size: .73rem; line-height: 1.4; }
+/* Accessible drag-and-drop targets with the existing light/dark tokens. */
+.file-drop-zone {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: .35rem;
+ min-height: 10rem;
+ padding: 1.25rem .85rem;
+ border: 2px dashed var(--line);
+ border-radius: .9rem;
+ background: var(--field-bg);
+ text-align: center;
+ transition: border-color .15s, background-color .15s;
+}
+.file-drop-zone:hover, .file-drop-zone:focus-within, .file-drop-zone.file-drag-over {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+}
+.file-drop-zone:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
+.file-drop-zone.file-ready { border-color: var(--green); }
+.file-drop-zone.file-error { border-color: var(--error); }
+.file-drop-zone input[type="file"] {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ cursor: pointer;
+}
+.file-drop-symbol { color: var(--accent); font-size: 1.7rem; line-height: 1; }
+.file-drop-instruction { color: var(--ink); font-size: .88rem; font-weight: 750; }
+.file-drop-hint, .file-drop-status { color: var(--muted); font-size: .73rem; line-height: 1.4; }
+.file-drop-status { overflow-wrap: anywhere; font-weight: 650; }
+.file-ready .file-drop-status { color: var(--green); }
+.file-error .file-drop-status { color: var(--error); }
+
+
.check-field {
display: flex;
align-items: center;