#!/usr/bin/env python3
"""jp-visual-triage: generate self-contained interactive triage HTML apps.

Two modes:
  single : one item at a time: KEEP / TRASH / UNSURE  (keyboard → / ← / ↓)
  pair   : two items side by side: KEEP-LEFT / KEEP-RIGHT / KEEP-BOTH / TRASH-BOTH / UNSURE
            (keyboard ← / → / ↑ / ↓ / u) : the ONLY correct place to judge duplicates

Works for any file type: images embed directly; everything else (PDF, PPTX,
Keynote, Word, ...) gets a Quick Look thumbnail via `qlmanage`.

Decisions auto-save to localStorage and Export to a JSON that Claude reads back
to (a) apply the decisions and (b) learn the feature→decision mapping.

CLI:
  python triage_gen.py --mode single --task NAME --out out.html --manifest items.json
  python triage_gen.py --mode pair   --task NAME --out out.html --manifest pairs.json

Manifest (single): {"items":[{"path":..., "name":..., "features":{...},
                                "guess":"keep|trash|unsure", "reason":...}, ...]}
Manifest (pair):   {"items":[{"a":{"path":...,"name":...}, "b":{"path":...,"name":...},
                              "features":{...}, "guess":"keep_left|keep_right|keep_both|trash_both|unsure",
                              "reason":...}, ...]}
"""
import os, sys, json, subprocess, hashlib, argparse, shutil, glob, re, io

# Only formats browsers render natively embed directly; everything else
# (HEIC, TIFF, BMP, PDF, PPTX, ...) is thumbnailed for cross-browser display.
WEBSAFE_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
RASTER_EXT = {".heic", ".heif", ".tiff", ".tif", ".bmp"}  # convertible via sips
VIDEO_EXT = {".mp4", ".m4v", ".mov", ".avi", ".mkv", ".webm", ".mpg", ".mpeg",
             ".wmv", ".flv", ".3gp", ".m2ts", ".mts"}
# WKWebView (AVFoundation) plays these inline; others get a Quick Look poster only.
VIDEO_PLAYABLE = {".mp4", ".m4v", ".mov", ".3gp"}

# ── Reader capability: the ONE source of truth for "can the in-app reader show this?" ──
# Swift's readerExts()/handoffExts() mirror these two sets; build_pagehawk_full.sh asserts the
# mirror still matches (`--caps`) so the lists cannot silently drift apart again. Adding an
# extension here and nowhere else is the intended workflow — the build tells you what else to fix.
#
# SPREADSHEET_EXT: sheets are HANDED OFF to the user's spreadsheet app, never rendered.
# Why (re-measured 2026-07-15, both halves of the original premise tested):
#   - "soffice is slow"  -> STALE. A cold xlsx->pdf convert is 0.8-1.5s. Not the reason.
#   - "a sheet-as-PDF is unreadable" -> TRUE, and it is the whole reason. LibreOffice paginates
#     a grid: a 40-column model became 10 pages, sliced into column bands, and the continuation
#     pages carry NO header row — so reading one row means hunting across pages with nothing to
#     anchor the columns. Narrow sheets (<=9 cols) do render fine on one page, but routing on
#     "how wide is it?" would make the app open the same file two different ways with no visible
#     reason, so the whole family hands off. A spreadsheet is not a paginated document.
SPREADSHEET_EXT = {".xls", ".xlsx", ".xlsm", ".ods", ".numbers"}
# Documents the reader CAN show: PDF direct, the rest via LibreOffice -> PDF.
#
# .txt/.md (added 0.63.7): plain text was the only EDITABLE_EXT type the reader could not open,
# so the whole editor was unreachable for it. Measured 2026-07-28/29: edit_probe() already
# returned editable:true for both, editorDocType() already mapped them to .plain, and the editor
# already carried plain-text-specific handling (the Aqua page appearance and the Calibri body
# default both name .txt in their comments) — every piece existed except the routing. Meanwhile a
# .txt opened through the review path FAILED: triage_app.py's DOC_EXT does not list it, so the
# run died with "no viewable files" in 0.05s and the window sat at "preparing…" forever, i.e. a
# .txt never opened at all. to_pdf() converts both in ~1.5s.
# NOTE the two lists are separate on purpose: DOC_EXT (triage_app.py) governs which files a
# FOLDER review collects and is unchanged, so a folder full of notes does not suddenly render
# every one of them as page images. This set governs single-document routing only.
READER_EXT = {".pdf", ".ppt", ".pptx", ".key", ".doc", ".docx", ".rtf", ".odp", ".odt", ".pages",
              ".txt", ".md"}

# GRID_EXT: delimited text PageHawk renders as a real GRID, never via LibreOffice->PDF.
# Why this is its own category (v0.55): a .csv is not a paginated document and not a spreadsheet
# app's file either — it is the most common data file on disk and every tool serves it badly
# (Numbers converts it and nags on save, Excel is a heavy launch, Quick Look truncates it, and
# a PDF render slices the columns into unreadable bands per SPREADSHEET_EXT above). Parsing it is
# trivial and lossless, so PageHawk shows the grid itself: instant, on-device, read-only, no
# conversion machinery. Read-only IS the design — a CSV editor is a spreadsheet, which is exactly
# what the family hands off.
GRID_EXT = {".csv", ".tsv", ".tab"}
GRID_MAX_ROWS = 50000     # a hard ceiling so a monster export can't wedge the window
GRID_MAX_COLS = 512

def is_spreadsheet(p):
    return os.path.splitext(p)[1].lower() in SPREADSHEET_EXT

def is_grid(p):
    return os.path.splitext(p)[1].lower() in GRID_EXT

def grid_json(path):
    """Parse a delimited text file into {cols, rows, truncated, delimiter, encoding}.
    Fails soft: an unparseable file returns an error string rather than raising, so the shell
    can say what happened instead of dropping the user in an empty shell."""
    import csv as _csv
    ext = os.path.splitext(path)[1].lower()
    raw, enc = None, None
    # Encoding ladder: real-world CSVs are utf-8, utf-8-sig (Excel's BOM), or cp1252 (Excel
    # Windows). latin-1 never fails, so it is the terminal fallback and keeps bytes readable.
    for e in ("utf-8-sig", "utf-8", "cp1252", "latin-1"):
        try:
            with open(path, "r", encoding=e, newline="") as f:
                raw = f.read(64 * 1024 * 1024)
            enc = e
            break
        except (UnicodeDecodeError, LookupError):
            continue
    if raw is None:
        return {"error": "This file could not be read as text."}
    if not raw.strip():
        return {"cols": [], "rows": [], "truncated": False, "delimiter": ",", "encoding": enc, "empty": True}
    if ext in (".tsv", ".tab"):
        delim = "\t"
    else:
        try:
            delim = _csv.Sniffer().sniff(raw[:16384], delimiters=",;\t|").delimiter
        except Exception:
            delim = ","
    rows, truncated = [], False
    try:
        for i, r in enumerate(_csv.reader(io.StringIO(raw), delimiter=delim)):
            if i >= GRID_MAX_ROWS:
                truncated = True
                break
            rows.append(r[:GRID_MAX_COLS])
    except Exception as e:
        return {"error": "This file isn't valid delimited text (%s)." % type(e).__name__}
    if not rows:
        return {"cols": [], "rows": [], "truncated": False, "delimiter": delim, "encoding": enc, "empty": True}
    width = max(len(r) for r in rows)
    # Header heuristic: row 1 is a header when every cell is non-empty text AND at least one
    # cell in row 2 parses as a number (i.e. the rows genuinely differ in kind). Otherwise the
    # file is headerless and gets A/B/C column names, like a spreadsheet would show.
    def numeric(s):
        try:
            float(s.replace(",", "").replace("$", "").strip()); return True
        except Exception:
            return False
    head = rows[0] + [""] * (width - len(rows[0]))
    has_header = bool(head) and all(c.strip() and not numeric(c) for c in head)
    if has_header and len(rows) > 1:
        cols, body = head, rows[1:]
    else:
        cols, body = [_col_name(i) for i in range(width)], rows
    body = [r + [""] * (width - len(r)) for r in body]
    # Per-column type, so the view can right-align numbers the way a spreadsheet does.
    types = []
    for c in range(width):
        vals = [body[r][c] for r in range(min(len(body), 200)) if body[r][c].strip()]
        types.append("num" if vals and all(numeric(v) for v in vals) else "text")
    return {"cols": cols, "rows": body, "types": types, "truncated": truncated,
            "delimiter": delim, "encoding": enc, "header": has_header}

def _col_name(i):
    s = ""
    i += 1
    while i:
        i, r = divmod(i - 1, 26)
        s = chr(65 + r) + s
    return s

# ── Editor Mode (v0.55): which documents may the native TextKit editor open for WRITING? ──
# The reader renders a CONVERTED PDF; editing works on the ORIGINAL file via NSAttributedString
# (.officeOpenXML / .rtf / plain). TextKit cannot represent tracked changes, comments,
# headers/footers, footnotes, text boxes, OLE objects, or SmartArt — saving such a document
# would silently strip them (a liability in legal review, the product's home vertical). So the
# probe inspects the real file and returns editable:false with PLAIN-ENGLISH reasons; the shell
# shows the Read|Edit switch with Edit disabled and says exactly why. Fail CLOSED: any parse
# error means "not editable" — refusing to edit is recoverable, eating a redline is not.
EDITABLE_EXT = {".docx", ".rtf", ".txt", ".md"}

def edit_probe(path):
    ext = os.path.splitext(path)[1].lower()
    if ext not in EDITABLE_EXT:
        return {"editable": False, "why": ["format"]}
    if ext in (".txt", ".md"):
        return {"editable": True, "why": []}
    try:
        if ext == ".rtf":
            with open(path, "rb") as f:
                head = f.read(4 * 1024 * 1024)
            # RTF revision marks are rare but real; \revised / \deleted mark tracked edits.
            if b"\\revised" in head or b"\\deleted" in head:
                return {"editable": False, "why": ["tracked changes"]}
            return {"editable": True, "why": []}
        import zipfile
        why = []
        with zipfile.ZipFile(path) as z:
            names = z.namelist()
            doc = z.read("word/document.xml") if "word/document.xml" in names else b""
            if b"<w:ins " in doc or b"<w:del " in doc or b"<w:ins>" in doc or b"<w:del>" in doc:
                why.append("tracked changes")
            if b"<w:commentRangeStart" in doc or any(n == "word/comments.xml" for n in names):
                why.append("comments")
            # header/footer FILES exist in many plain docs; only flag ones that carry text.
            for n in names:
                if re.match(r"word/(header|footer)\d*\.xml$", n) and b"<w:t" in z.read(n):
                    why.append("headers or footers"); break
            for part, label in (("word/footnotes.xml", "footnotes"), ("word/endnotes.xml", "endnotes")):
                # separators alone are 2 stock entries; real notes add more.
                if part in names and z.read(part).count(b"<w:footnote ") + z.read(part).count(b"<w:endnote ") > 2:
                    why.append(label)
            if b"<w:txbxContent" in doc:
                why.append("text boxes")
            if any(n.startswith("word/embeddings/") for n in names):
                why.append("embedded objects")
            if any(n.startswith("word/diagrams/") for n in names):
                why.append("SmartArt")
        return {"editable": not why, "why": why}
    except Exception:
        return {"editable": False, "why": ["unreadable"]}

def is_websafe(p):
    return os.path.splitext(p)[1].lower() in WEBSAFE_EXT

def is_image(p):
    return is_websafe(p) or os.path.splitext(p)[1].lower() in RASTER_EXT

def is_video(p):
    return os.path.splitext(p)[1].lower() in VIDEO_EXT

def is_playable_video(p):
    return os.path.splitext(p)[1].lower() in VIDEO_PLAYABLE

def furl(p):
    return "file://" + p.replace(" ", "%20").replace("#", "%23").replace("?", "%3F")


def _brand_name():
    """Resolve the running brand's display name (PageHawk / Winnow Photos / Gander
    Viewer / ...) from the bundled brand.json, same source of truth as brand.py.
    A bare `import brand` works in the shipped app (triage_gen.py and brand.py are
    bundled flat into the same Resources/engine dir); the dev tree keeps them one
    level apart (src/triage_gen.py vs src/app/brand.py), so fall back to adding
    src/app to sys.path before giving up to the "PageHawk" default."""
    try:
        import brand as _b
        return _b.name()
    except ImportError:
        _here = os.path.dirname(os.path.abspath(__file__))
        for _p in (_here, os.path.join(_here, "app")):
            if _p not in sys.path:
                sys.path.insert(0, _p)
        try:
            import brand as _b
            return _b.name()
        except Exception:
            return "PageHawk"
    except Exception:
        return "PageHawk"

# ── Content-addressed cache (never show a stale render) ───────────────────────────
# Every render (thumbnail, grid thumb, page image, perceptual hash) is cached under a
# key that includes the file's CONTENT SIGNATURE (size + nanosecond mtime), NOT just its
# path. So editing a file in place changes its key: the old render is ignored and a fresh
# one is produced — the viewer is always up to date — while an unchanged file still hits
# cache (fast reopen). Because the cached PATHS change on edit too, the WKWebView image
# cache cannot serve a stale copy either. This is the fix for "the viewer shows an old
# version of a file I just updated" (Jason 2026-06-17).
def _file_sig(path):
    """A short signature that changes whenever the file's bytes change."""
    try:
        st = os.stat(path)
        return "%x-%x" % (st.st_size, st.st_mtime_ns)
    except OSError:
        return "0"

def furl_bust(p):
    """file:// URL with a ?v=<sig> query so the web layer reloads an edited original
    (web-safe image / playable video) whose path is otherwise unchanged."""
    return furl(p) + "?v=" + _file_sig(p)

def _cache_name(path, prefix):
    """(stem, sibling_glob). stem = '<prefix>_<pathhash>__<sighash>', which busts on any
    edit; sibling_glob matches THIS file's other-signature renders so a fresh render can
    prune them (the cache stays one-render-per-file, never growing without bound)."""
    ph = hashlib.md5(("%s|%s" % (prefix, path)).encode()).hexdigest()[:12]
    sh = hashlib.md5(_file_sig(path).encode()).hexdigest()[:8]
    return "%s_%s__%s" % (prefix, ph, sh), "%s_%s__*" % (prefix, ph)

def _prune_stale(thumbdir, sibling_glob, keep_stem):
    """Remove older renders of one file (same prefix+path, a since-superseded signature)."""
    for old in glob.glob(os.path.join(thumbdir, sibling_glob)):
        if os.path.basename(old).startswith(keep_stem):
            continue
        try:
            shutil.rmtree(old, ignore_errors=True) if os.path.isdir(old) else os.remove(old)
        except OSError:
            pass

def thumbnail(path, thumbdir, size=1400):
    """Web-safe image -> itself. HEIC/TIFF/etc -> sips PNG. Anything else -> qlmanage."""
    if is_websafe(path):
        return furl(path)
    os.makedirs(thumbdir, exist_ok=True)
    stem, sib = _cache_name(path, "t")
    out = os.path.join(thumbdir, stem + ".png")
    if os.path.exists(out):
        return furl(out)
    _prune_stale(thumbdir, sib, stem)   # a prior version of this file may be cached
    ext = os.path.splitext(path)[1].lower()
    # raster images (HEIC etc.): sips is fast and reliable, cap the long edge
    if ext in RASTER_EXT:
        try:
            subprocess.run(["sips", "-s", "format", "png", "-Z", str(size), path, "--out", out],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60)
        except Exception:
            pass
    # everything else (PDF/PPTX/Keynote/Word/...) or sips failure: Quick Look
    if not os.path.exists(out):
        try:
            subprocess.run(["qlmanage", "-t", "-s", str(size), "-o", thumbdir, path],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60)
            cand = os.path.join(thumbdir, os.path.basename(path) + ".png")
            if os.path.exists(cand):
                os.replace(cand, out)
        except Exception:
            pass
    return furl(out) if os.path.exists(out) else ""

def _batch_ql(paths, thumbdir, size=1400, progress=None):
    """Pre-generate Quick Look thumbnails for many non-image files in BULK. One
    `qlmanage -t` call per chunk is far faster than one launch per file (Quick Look
    startup overhead dominates for piles of PDFs/docs/videos). Populates the SAME
    cache files thumbnail() looks for, so the per-item workers then hit cache.
    qlmanage names output by basename, so each chunk holds only unique basenames."""
    os.makedirs(thumbdir, exist_ok=True)
    from collections import defaultdict
    by_base = defaultdict(list)
    for p in paths:
        stem, _ = _cache_name(p, "t")   # same key thumbnail() looks for (content-addressed)
        out = os.path.join(thumbdir, stem + ".png")
        if not os.path.exists(out):
            by_base[os.path.basename(p)].append((p, out))
    if not by_base:
        return
    # round r = the r-th file of each basename -> within a round all basenames differ
    maxdup = max(len(v) for v in by_base.values())
    chunks, CH = [], 40
    for r in range(maxdup):
        grp = [v[r] for v in by_base.values() if len(v) > r]
        for i in range(0, len(grp), CH):
            chunks.append(grp[i:i + CH])
    total = sum(len(c) for c in chunks)
    done = 0
    tmp = os.path.join(thumbdir, ".qlbatch")
    def _harvest(chunk):
        for p, out in chunk:
            cand = os.path.join(tmp, os.path.basename(p) + ".png")
            if os.path.exists(cand) and not os.path.exists(out):
                try:
                    os.replace(cand, out)
                except OSError:
                    pass
    for chunk in chunks:
        shutil.rmtree(tmp, ignore_errors=True)
        os.makedirs(tmp, exist_ok=True)
        try:
            # Bounded batch: a single file OPEN in Excel/Word makes QuickLook hang,
            # and the whole chunk waited 240s and lost every thumbnail in it (the
            # data-room "won't load" hang, 2026-06-29). 60s cap, then isolate.
            subprocess.run(["qlmanage", "-t", "-s", str(size), "-o", tmp] + [p for p, _ in chunk],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60)
        except Exception:
            pass
        _harvest(chunk)
        # Per-file fallback for anything the batch didn't produce (a hung/locked file
        # killed the batch before it finished): render each alone with a short timeout
        # so one bad file costs ~6s, not the whole grid. The rest still get thumbnails;
        # a file that still fails just shows a clean fallback tile downstream.
        missing = [(p, out) for p, out in chunk if not os.path.exists(out)]
        if missing and len(missing) < len(chunk) + 1:
            for p, out in missing:
                try:
                    subprocess.run(["qlmanage", "-t", "-s", str(size), "-o", tmp, p],
                                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=6)
                except Exception:
                    pass
            _harvest(missing)
        done += len(chunk)
        if progress:
            try:
                progress(done, total)
            except Exception:
                pass
    shutil.rmtree(tmp, ignore_errors=True)


def prep_single(items, thumbdir):
    for i, it in enumerate(items):
        it["id"] = i
        it.setdefault("name", os.path.basename(it["path"]))
        it["url"] = thumbnail(it["path"], thumbdir)
        it["is_image"] = is_image(it["path"])
    return items

def prep_pair(items, thumbdir):
    for i, it in enumerate(items):
        it["id"] = i
        for side in ("a", "b"):
            s = it[side]
            s.setdefault("name", os.path.basename(s["path"]))
            s["url"] = thumbnail(s["path"], thumbdir)
            s["is_image"] = is_image(s["path"])
    return items


def _grid_thumb_path(path, thumbdir, size=512):
    """The deterministic cache path for a grid thumbnail (shared by grid_thumb and
    the QLThumbnailGenerator pre-pass so they agree on where the file lands)."""
    stem, _ = _cache_name(path, "g%d" % size)
    return os.path.join(thumbdir, stem + ".jpg")


def _reader_html_path():
    """Absolute path to the reader, so the library's preview pane can EMBED it.

    Resolves exactly the way the Swift shell's readerHTMLPath() does and in the same order, so
    the pane and a reader WINDOW can never end up running two different builds of the reader:
    the live skill override first, then the layout beside this file. Returns "" when neither
    exists, and the pane then keeps its own fast preview rather than framing a 404 — a blank
    frame that looks like a broken document is worse than the preview it replaced.
    """
    here = os.path.dirname(os.path.abspath(__file__))
    for c in (os.path.expanduser("~/.claude/skills/_pagehawk_use_bundled_engine/app/vendor/pdfjs/reader.html"),
              os.path.join(here, "vendor", "pdfjs", "reader.html"),        # shipped: engine/vendor/pdfjs/
              os.path.join(here, "app", "vendor", "pdfjs", "reader.html")): # dev tree: src/app/vendor/pdfjs/
        if os.path.isfile(c):
            return c
    return ""


def _hvnthumb_path():
    """Locate the compiled QLThumbnailGenerator helper, if present. Bundled next to
    the engine in the app; in the live skill it sits under app/viewer/. Absent =>
    the per-file sips/qlmanage path still works (the helper is a pure speed-up)."""
    here = os.path.dirname(os.path.abspath(__file__))
    for c in (os.path.join(here, "hvnthumb"),
              os.path.join(here, "app", "viewer", "hvnthumb")):
        if os.path.isfile(c) and os.access(c, os.X_OK):
            return c
    return None


def _qlthumb_batch(paths, thumbdir, size=512):
    """Pre-fill the grid-thumbnail cache via the QLThumbnailGenerator helper: ONE
    long-lived process for the whole batch that taps the OS Quick Look cache.
    Purely additive — only writes files not cached yet; grid_thumb falls back to
    sips/qlmanage for anything it skips/misses.

    DEFAULT OFF (opt-in via HVNIZ_USE_QLHELPER=1). Measured 2026-06-15: parallel
    `sips` beats QLThumbnailGenerator ~1.6-2x for cold-cache image folders, and the
    cited "5-10x" warm-cache win did NOT materialize even on a second pass (~0.7x).
    Kept available for the cases where it can help (heavily Finder-cached folders),
    but the proven sips + batched-qlmanage path stays the default so we never
    regress the common case (a folder of loose photos)."""
    if not os.environ.get("HVNIZ_USE_QLHELPER"):
        return
    helper = _hvnthumb_path()
    if not helper:
        return
    os.makedirs(thumbdir, exist_ok=True)
    lines = []
    for p in paths:
        out = _grid_thumb_path(p, thumbdir, size)
        if not os.path.exists(out):
            lines.append("%s\t%s\t%d" % (p, out, size))
    if not lines:
        return
    try:
        subprocess.run([helper], input="\n".join(lines), text=True,
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=900)
    except Exception:
        pass


def grid_thumb(path, thumbdir, size=512):
    """A SMALL cached jpeg thumbnail for the grid (so a folder of thousands of
    full-size photos stays light). The QL helper pre-pass may already have written
    it; else sips for images, Quick Look otherwise."""
    os.makedirs(thumbdir, exist_ok=True)
    out = _grid_thumb_path(path, thumbdir, size)
    if os.path.exists(out):
        return furl(out)
    stem, sib = _cache_name(path, "g%d" % size)
    _prune_stale(thumbdir, sib, stem)   # drop any older-version thumb for this file
    if is_image(path):
        try:
            subprocess.run(["sips", "-s", "format", "jpeg", "-Z", str(size), path, "--out", out],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30)
        except Exception:
            pass
    if os.path.exists(out):
        return furl(out)
    return thumbnail(path, thumbdir)  # non-image fallback (Quick Look)


def _parallel_map(paths, fn, workers=10, progress=None):
    """Run fn(path) over paths concurrently (subprocess work releases the GIL).
    Calls progress(done, total) periodically so the loading screen can show it."""
    from concurrent.futures import ThreadPoolExecutor, as_completed
    out = {}
    total = len(paths)
    if not total:
        return out
    with ThreadPoolExecutor(max_workers=min(workers, total)) as ex:
        futs = {ex.submit(fn, p): p for p in paths}
        done = 0
        if progress:
            try:
                progress(0, total)
            except Exception:
                pass
        for f in as_completed(futs):
            try:
                out[futs[f]] = f.result()
            except Exception:
                out[futs[f]] = ""
            done += 1
            if progress and (done % 20 == 0 or done == total):
                try:
                    progress(done, total)
                except Exception:
                    pass
    return out


def _progress_html(done, total, task):
    """Loading screen: the H-diamond "Trace" loader + live count + progress bar + Cancel."""
    pct = int(done * 100 / total) if total else 0
    _bn = _brand_name()
    return f"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>{_bn} · preparing…</title>
<style>html,body{{height:100%;margin:0}}body{{display:flex;align-items:center;justify-content:center;background:#F4EFE4;font-family:Cambria,'Big Caslon','Hoefler Text',Georgia,serif;color:#3A3F45}}
.c{{text-align:center;width:380px}}
.hd{{width:64px;height:64px;margin:0 auto 22px;border-radius:50%;border:6px solid #E7DCC6;border-top-color:#C8A872;animation:spin 1s linear infinite}}
@keyframes spin{{to{{transform:rotate(360deg)}}}}
.b{{font-size:29px;color:#2A2D30;letter-spacing:.01em}}.b b{{color:#B8860B;font-weight:600}}.bd{{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:#B8860B;margin-top:5px}}.sub{{font-size:14px;color:#6A6F75;margin-top:10px;font-family:'Inter',-apple-system,system-ui,sans-serif}}
.bar{{height:8px;background:#E7DCC6;border-radius:5px;margin:18px 0 0;overflow:hidden}}.fill{{height:100%;background:#C8A872;width:{pct}%;transition:width .3s}}
.cx{{margin-top:24px;font-size:13px;color:#8A4034;text-decoration:none;border:1px solid #E0C9C4;padding:8px 18px;border-radius:9px;display:inline-block;cursor:pointer;font-family:'Inter',-apple-system,system-ui,sans-serif}}.cx:hover{{background:#F6ECE8}}
@media(prefers-reduced-motion:reduce){{.hd{{animation:none}}}}
@media (prefers-color-scheme:dark){{body{{background:#1b1b1d;color:#C9C4BB}}.hd{{border-color:rgba(255,255,255,.14);border-top-color:#C8A872}}.b{{color:#F0EBE1}}.b b,.bd{{color:#D8BC8C}}.sub{{color:#9A9690}}.bar{{background:rgba(255,255,255,.12)}}.cx{{color:#E0A99C;border-color:rgba(255,255,255,.16)}}}}</style></head>
<body><div class="c">
<div class="hd"></div>
<div class="b">{_bn}</div>
<div class="sub">Rendering {done:,} of {total:,} files…</div>
<div class="bar"><div class="fill"></div></div>
<a class="cx" href="hvniz://cancel">Cancel</a></div>
<script>setTimeout(function(){{var u=location.href.split('?')[0];location.replace(u+'?_='+Date.now())}},1100)</script></body></html>"""


def _categorize(path):
    ext = os.path.splitext(path)[1].lower()
    name = os.path.basename(path).lower()
    if ext in WEBSAFE_EXT or ext in RASTER_EXT:
        return "Screenshots" if ("screenshot" in name or "screen shot" in name) else "Photos"
    if ext in VIDEO_EXT:
        return "Videos"
    if ext == ".pdf":
        return "PDFs"
    if ext in {".pptx", ".ppt", ".key", ".keynote"}:
        return "Decks"
    if ext in {".doc", ".docx", ".pages"}:
        return "Documents"
    if ext in SPREADSHEET_EXT:
        return "Spreadsheets"
    return "Other"


def _img_dims(path):
    """Pixel dimensions from the file HEADER (pure stdlib — the engine runs on the
    system python3, which has no Pillow). Handles PNG / JPEG / GIF; "" otherwise."""
    import struct
    try:
        with open(path, "rb") as f:
            head = f.read(24)
            if len(head) < 24:
                return ""
            if head[:8] == b"\x89PNG\r\n\x1a\n" and head[12:16] == b"IHDR":
                w, h = struct.unpack(">II", head[16:24])
                return f"{w}x{h}"
            if head[:6] in (b"GIF87a", b"GIF89a"):
                w, h = struct.unpack("<HH", head[6:10])
                return f"{w}x{h}"
            if head[:2] == b"\xff\xd8":  # JPEG: walk the markers to a SOF segment
                f.seek(2)
                b = f.read(1)
                while b:
                    while b and b != b"\xff":
                        b = f.read(1)
                    while b == b"\xff":
                        b = f.read(1)
                    if not b:
                        break
                    m = b[0]
                    if 0xC0 <= m <= 0xCF and m not in (0xC4, 0xC8, 0xCC):
                        f.read(3)
                        h, w = struct.unpack(">HH", f.read(4))
                        return f"{w}x{h}"
                    seg = f.read(2)
                    if len(seg) < 2:
                        break
                    f.seek(struct.unpack(">H", seg)[0] - 2, 1)
                    b = f.read(1)
    except Exception:
        pass
    return ""


# --- near-duplicate / burst detection (pure stdlib; the engine has no Pillow) ---
# dHash: sips resamples the (already-small) thumbnail to a 9x8 BMP, we read the
# raw pixels and compare horizontal neighbours -> a 64-bit fingerprint. Photos in
# a burst / near-duplicates land within a small Hamming distance of each other.
_POP = bytes(bin(i).count("1") for i in range(256))

def _hamming(a, b):
    return bin(a ^ b).count("1")

def _read_bmp_gray(path):
    """Rows of luma values from an uncompressed 24/32-bit BMP (sips output)."""
    import struct
    with open(path, "rb") as f:
        d = f.read()
    if d[:2] != b"BM":
        raise ValueError("not a BMP")
    pixoff = struct.unpack("<I", d[10:14])[0]
    width = struct.unpack("<i", d[18:22])[0]
    height = struct.unpack("<i", d[22:26])[0]
    bpp = struct.unpack("<H", d[28:30])[0]
    bypp = max(1, bpp // 8)
    if bypp < 3:
        raise ValueError("unexpected bpp %d" % bpp)
    rowsize = ((bpp * width + 31) // 32) * 4
    rows = []
    for y in range(abs(height)):
        off = pixoff + y * rowsize
        line = d[off:off + rowsize]
        gr = []
        for x in range(width):
            b, g, r = line[x * bypp], line[x * bypp + 1], line[x * bypp + 2]
            gr.append((r * 299 + g * 587 + b * 114) // 1000)
        rows.append(gr)
    if height > 0:           # BMP scanlines are stored bottom-up
        rows.reverse()
    return rows

def _phash(img_local, thumbdir):
    """64-bit dHash of a local image (usually the small jpeg thumb). None on failure."""
    if not img_local:
        return None
    os.makedirs(thumbdir, exist_ok=True)
    stem, sib = _cache_name(img_local, "ph")
    bmp = os.path.join(thumbdir, stem + ".ph.bmp")
    if not os.path.exists(bmp):
        _prune_stale(thumbdir, sib, stem)
        try:
            subprocess.run(["sips", "-z", "8", "9", "-s", "format", "bmp", img_local, "--out", bmp],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20)
        except Exception:
            return None
    try:
        rows = _read_bmp_gray(bmp)
        if len(rows) < 8 or len(rows[0]) < 9:
            return None
        bits = 0
        for y in range(8):
            r = rows[y]
            for x in range(8):
                bits = (bits << 1) | (1 if r[x] < r[x + 1] else 0)
        return bits
    except Exception:
        return None

def _cluster_bursts(items, threshold=12):
    """Union near-duplicate photos by dHash Hamming distance. Each multi-member
    cluster gets an id (it['cluster']) and one suggested keeper (it['best']=True,
    the largest-area then largest-file frame). Exact O(n^2) up to 2000 images;
    multi-probe bucketing above that to stay fast on big piles."""
    idx = [i for i, it in enumerate(items) if it.get("phash") is not None]
    parent = list(range(len(items)))
    def find(a):
        while parent[a] != a:
            parent[a] = parent[parent[a]]; a = parent[a]
        return a
    def union(a, b):
        ra, rb = find(a), find(b)
        if ra != rb:
            parent[rb] = ra
    def cmp_within(grp):
        for ii in range(len(grp)):
            a = grp[ii]; ha = items[a]["phash"]
            for jj in range(ii + 1, len(grp)):
                b = grp[jj]
                if _hamming(ha, items[b]["phash"]) <= threshold:
                    union(a, b)
    if len(idx) <= 2000:
        cmp_within(idx)
    else:
        from collections import defaultdict
        for shift in (0, 16, 32, 48):       # 4 probe windows of 16 bits
            buckets = defaultdict(list)
            for a in idx:
                buckets[(items[a]["phash"] >> shift) & 0xFFFF].append(a)
            for grp in buckets.values():
                if len(grp) > 1:
                    cmp_within(grp)
    from collections import defaultdict
    groups = defaultdict(list)
    for a in idx:
        groups[find(a)].append(a)
    def area(m):
        try:
            w, h = (items[m].get("dims") or "0x0").split("x"); return int(w) * int(h)
        except Exception:
            return 0
    def fsize(m):
        try:
            return os.path.getsize(items[m]["path"])
        except Exception:
            return 0
    cid = 0
    for members in groups.values():
        if len(members) < 2:
            continue
        cid += 1
        best_m = max(members, key=lambda m: (area(m), fsize(m)))
        for m in members:
            items[m]["cluster"] = cid
            items[m]["best"] = (m == best_m)
            if m != best_m and not items[m].get("suggest"):
                items[m]["suggest"] = "trash"   # non-best burst frames: set-aside hint
    for it in items:
        it.setdefault("cluster", None)
        it.setdefault("best", False)
    return cid


# --- EXIF capture date + GPS for the lightbox (pure stdlib; JPEG only) ---
def _exif(path):
    """{'date': 'YYYY-MM-DD HH:MM', 'gps': 'lat,lon'} from a JPEG's EXIF, or {}."""
    import struct
    try:
        if os.path.splitext(path)[1].lower() not in (".jpg", ".jpeg"):
            return {}
        with open(path, "rb") as f:
            if f.read(2) != b"\xff\xd8":
                return {}
            while True:
                mk = f.read(2)
                if len(mk) < 2 or mk[0] != 0xFF:
                    return {}
                if mk[1] in (0xD9, 0xDA):       # EOI / start of scan: no more headers
                    return {}
                ln = struct.unpack(">H", f.read(2))[0]
                seg = f.read(ln - 2)
                if mk[1] == 0xE1 and seg[:6] == b"Exif\x00\x00":
                    return _parse_tiff(seg[6:])
    except Exception:
        return {}

def _parse_tiff(tiff):
    import struct
    bo = "<" if tiff[:2] == b"II" else ">"
    out = {}
    def read_ifd(off):
        entries = {}
        if off <= 0 or off + 2 > len(tiff):
            return entries
        cnt = struct.unpack(bo + "H", tiff[off:off + 2])[0]
        p = off + 2
        for _ in range(cnt):
            if p + 12 > len(tiff):
                break
            tag, typ, num = struct.unpack(bo + "HHI", tiff[p:p + 8])
            entries[tag] = (typ, num, tiff[p + 8:p + 12])
            p += 12
        return entries
    def ascii_val(entry):
        typ, num, vo = entry
        if num <= 4:
            raw = vo[:num]
        else:
            o = struct.unpack(bo + "I", vo)[0]; raw = tiff[o:o + num]
        return raw.split(b"\x00")[0].decode("ascii", "ignore")
    try:
        ifd0 = struct.unpack(bo + "I", tiff[4:8])[0]
        e0 = read_ifd(ifd0)
        ex = read_ifd(struct.unpack(bo + "I", e0[0x8769][2])[0]) if 0x8769 in e0 else {}
        dto = ex.get(0x9003) or ex.get(0x9004) or e0.get(0x0132)
        if dto:
            r = ascii_val(dto)                 # "YYYY:MM:DD HH:MM:SS"
            if len(r) >= 16 and r[4] == ":":
                out["date"] = r[:4] + "-" + r[5:7] + "-" + r[8:10] + " " + r[11:16]
        if 0x8825 in e0:
            ge = read_ifd(struct.unpack(bo + "I", e0[0x8825][2])[0])
            lat = _gps(tiff, bo, ge, 0x0002, 0x0001)
            lon = _gps(tiff, bo, ge, 0x0004, 0x0003)
            if lat is not None and lon is not None:
                out["gps"] = "%.5f,%.5f" % (lat, lon)
    except Exception:
        pass
    return out

def _gps(tiff, bo, ge, coord_tag, ref_tag):
    import struct
    c = ge.get(coord_tag)
    if not c or c[1] != 3:
        return None
    off = struct.unpack(bo + "I", c[2])[0]
    deg = 0.0
    for i, unit in enumerate((1.0, 60.0, 3600.0)):
        num, den = struct.unpack(bo + "II", tiff[off + i * 8:off + i * 8 + 8])
        deg += (num / den if den else 0) / unit
    r = ge.get(ref_tag)
    if r and r[2][:1] in (b"S", b"W"):
        deg = -deg
    return deg


def _sips_dims(path):
    """Pixel dimensions via sips, for image formats _img_dims can't header-parse
    (HEIC/TIFF/BMP). One cheap header read, no full decode."""
    try:
        r = subprocess.run(["sips", "-g", "pixelWidth", "-g", "pixelHeight", path],
                           stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=15)
        w = h = None
        for line in r.stdout.decode("utf-8", "ignore").splitlines():
            s = line.strip()
            if s.startswith("pixelWidth:"):
                w = s.split(":", 1)[1].strip()
            elif s.startswith("pixelHeight:"):
                h = s.split(":", 1)[1].strip()
        if w and h:
            return "%sx%s" % (w, h)
    except Exception:
        pass
    return ""


def _grid_item(path, thumbdir, size):
    """One worker: small thumbnail + pixel dims + dHash fingerprint + EXIF."""
    turl = grid_thumb(path, thumbdir, size)
    dims = _img_dims(path) or (_sips_dims(path) if is_image(path) else "")
    if not dims and turl:
        dims = _img_dims(_local(turl))   # the Quick Look thumb's own size = correct aspect for video/PDF/etc.
    ph = _phash(_local(turl), thumbdir) if (turl and is_image(path)) else None
    return (turl, dims, ph, _exif(path))


def prep_grid(items, thumbdir, size=512, progress=None):
    """Fast grid prep: SMALL thumbnails in PARALLEL + category + dims + burst groups."""
    paths = [it["path"] for it in items]
    # 1) Optional QLThumbnailGenerator pre-pass (opt-in, default off — see
    # _qlthumb_batch: sips is faster for the common image-folder case). No-op unless
    # HVNIZ_USE_QLHELPER=1, in which case it pre-fills the cache for the whole batch.
    _qlthumb_batch(paths, thumbdir, size)
    # 2) Bulk-render Quick Look thumbnails for non-image files (one qlmanage call per
    # chunk, not one per file) — the proven win for mixed-format folders. Images go
    # through parallel sips in the per-item workers below.
    nonimg = [p for p in paths if not is_image(p) and not os.path.exists(_grid_thumb_path(p, thumbdir, size))]
    if nonimg:
        _batch_ql(nonimg, thumbdir, progress=progress)
    res = _parallel_map(paths, lambda p: _grid_item(p, thumbdir, size), workers=18, progress=progress)
    for i, it in enumerate(items):
        it["id"] = i
        it.setdefault("name", os.path.basename(it["path"]))
        try:
            _st = os.stat(it["path"]); it["size"] = _st.st_size; it["mtime"] = _st.st_mtime
        except OSError:
            pass
        tu, dims, ph, ex = res.get(it["path"], ("", "", None, {}))
        it["thumb"] = tu
        it["preview"] = bool(tu)   # could we render a recognizable preview? (else hidden by default)
        it["dims"] = dims
        it["phash"] = ph
        if ex:
            it["exif"] = ex
        # full-size for the click-to-enlarge lightbox: the original for web-safe
        # images (sharp, no extra render, loaded one at a time on demand); the grid
        # thumb otherwise (HEIC/PDF/etc. already converted).
        if is_video(it["path"]):
            it["video"] = True
            it["playable"] = is_playable_video(it["path"])
            # playable videos point at the ORIGINAL (so the lightbox <video> can play it);
            # non-playable formats fall back to the Quick Look poster.
            it["full"] = furl_bust(it["path"]) if it["playable"] else it["thumb"]
        else:
            it["full"] = furl_bust(it["path"]) if is_websafe(it["path"]) else it["thumb"]
        it.setdefault("category", _categorize(it["path"]))
    # Set-aside suggestions are a HINT, never auto-applied. Only flag document/screenshot
    # "noise" when this is a PHOTO pile (real photos outnumber documents); in a folder
    # OF documents those files ARE the content, so suggest nothing there. Burst near-
    # duplicates are suggested separately (in _cluster_bursts) regardless of pile type.
    _n_photo = sum(1 for it in items if it.get("category") == "Photos")
    _n_doc = sum(1 for it in items if it.get("category") in ("PDFs", "Documents", "Spreadsheets", "Decks"))
    _photo_pile = _n_photo > _n_doc
    for it in items:
        it["suggest"] = "trash" if (_photo_pile and it["category"] in ("Screenshots", "Documents", "Spreadsheets")) else ""
    _cluster_bursts(items)
    # phash was only needed to build clusters; drop it from the payload sent to the page
    for it in items:
        it.pop("phash", None)
    return items

CSS = """
*{box-sizing:border-box} body{margin:0;font-family:var(--font);background:var(--bg);background-attachment:fixed;color:var(--fg);height:100vh;overflow:hidden}
#top{display:flex;align-items:center;gap:14px;padding:11px 22px;background:var(--top-bg);border-bottom:1px solid var(--border)}
#brand{font-family:var(--font-head);font-size:17px;font-weight:600;color:var(--fg);letter-spacing:.3px;white-space:nowrap}
#brand .accent{color:var(--accent-dark)}
#bar{flex:1;height:6px;background:var(--track);border-radius:4px;overflow:hidden}#fill{height:100%;background:var(--accent);width:0;transition:width .25s}
.tally{font-size:12px;color:var(--mut);white-space:nowrap}.tally b{color:var(--fg)}
#stage{display:flex;align-items:center;justify-content:center;height:calc(100vh - 156px);gap:20px;padding:0 16px}
.pane{display:flex;flex-direction:column;align-items:center;max-width:48vw}
.panelbl{font-family:var(--font-head);font-style:italic;color:var(--accent-dark);font-size:14px;margin-bottom:8px}
img.shot{max-width:46vw;max-height:calc(100vh - 250px);object-fit:contain;border-radius:8px;box-shadow:var(--shadow);background:var(--imgbg)}
#stage.single img.shot{max-width:86vw}
.cap{font-size:12px;color:var(--mut);margin-top:8px;text-align:center;word-break:break-all;max-width:46vw}
.cap .nm{color:var(--fg);font-weight:600}
#flash{position:fixed;top:66px;left:50%;transform:translateX(-50%);font-size:13px;padding:6px 15px;border-radius:20px;opacity:0;transition:opacity .15s;font-weight:600;z-index:5;color:#fff}
#meta{padding:8px 16px;font-size:12px;color:var(--mut);text-align:center}
.tag{display:inline-block;background:var(--chip);color:var(--mut);border-radius:4px;padding:2px 9px;margin:0 3px}
#controls{display:flex;gap:10px;justify-content:center;padding:10px;flex-wrap:wrap}
button.act{border:0;border-radius:9px;padding:11px 18px;font-size:13px;font-weight:600;color:#fff;cursor:pointer;letter-spacing:.2px;transition:filter .1s,transform .05s;box-shadow:0 2px 12px rgba(0,0,0,.30);min-width:108px}
button.act:hover{filter:brightness(1.08)}button.act:active{transform:translateY(1px)}
#themetog,#layouttog,#recachetog{background:transparent;border:1px solid var(--border);color:var(--fg);border-radius:8px;padding:6px 10px;font-size:14px;cursor:pointer;line-height:1}
#themetog:hover,#layouttog:hover,#recachetog:hover{border-color:var(--accent)}
.b-trash{background:var(--trash)}.b-keep{background:var(--keep)}.b-both{background:var(--both)}.b-unsure{background:var(--unsure);color:#1a1a1a}.b-undo{background:var(--neutral)}.b-exp{background:var(--accent);color:var(--accent-fg)}
#hint{font-size:12px;color:var(--mut);text-align:center;padding-bottom:10px}
kbd{background:var(--chip);color:var(--fg);border-radius:4px;padding:1px 6px;border:1px solid var(--border);font-family:var(--font)}
#done{display:none;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:14px;text-align:center}
#done h1{font-family:var(--font-head);font-weight:600;font-size:2.6em;color:var(--fg);margin:0}
.noimg{width:40vw;height:50vh;display:flex;align-items:center;justify-content:center;background:var(--chip);border-radius:8px;color:var(--mut);font-size:14px;padding:20px;text-align:center}
"""

# ---- Brand + motion assets (2026-06-16): the H-diamond mark (the heart of the HVN
# identity), the favicon, and the shared masthead-mark + short "Rise" entrance CSS.
# The six-scheme color palette lives in THEMES; this is the brand chrome around it.
# BRAND_CSS is injected via each builder's `palette` string so all four modes share
# it without re-typing. ----
# Masthead mark = the app's OWN document logo (Stack / Compare / Lens — the same marks
# as the app-icon set), adapted small + scheme-adaptive: page outlines take the scheme
# foreground via currentColor (ink on light schemes, ivory on dark); the accent stays gold
# (var(--accent-dark)). The mark FOLLOWS the icon chosen in Settings → App icon (config
# "app_icon", e.g. "stack_slate_light"), so there is one logo control, not a separate one.
MARKS = {
    "stack":   ('<rect x="54" y="60" width="74" height="100" rx="9" fill="none" stroke="currentColor" stroke-width="8" opacity=".42" transform="rotate(-9 91 110)"/>'
                '<rect x="74" y="46" width="74" height="104" rx="9" fill="none" stroke="currentColor" stroke-width="9"/>'
                '<line x1="90" y1="74" x2="132" y2="74" stroke="currentColor" stroke-width="7" stroke-linecap="round"/>'
                '<line x1="90" y1="96" x2="132" y2="96" stroke="currentColor" stroke-width="7" stroke-linecap="round"/>'
                '<line x1="90" y1="118" x2="118" y2="118" stroke="#C8A872" stroke-width="7" stroke-linecap="round"/>'),
    "compare": ('<rect x="38" y="54" width="58" height="96" rx="8" fill="none" stroke="currentColor" stroke-width="8"/>'
                '<rect x="104" y="54" width="58" height="96" rx="8" fill="none" stroke="#C8A872" stroke-width="8"/>'
                '<line x1="50" y1="76" x2="84" y2="76" stroke="currentColor" stroke-width="6" stroke-linecap="round"/>'
                '<line x1="50" y1="94" x2="84" y2="94" stroke="currentColor" stroke-width="6" stroke-linecap="round"/>'
                '<line x1="116" y1="76" x2="150" y2="76" stroke="#C8A872" stroke-width="6" stroke-linecap="round"/>'
                '<line x1="116" y1="94" x2="150" y2="94" stroke="#C8A872" stroke-width="6" stroke-linecap="round"/>'),
    "lens":    ('<rect x="42" y="44" width="80" height="108" rx="9" fill="none" stroke="currentColor" stroke-width="8.5"/>'
                '<line x1="58" y1="70" x2="106" y2="70" stroke="currentColor" stroke-width="6.5" stroke-linecap="round"/>'
                '<line x1="58" y1="90" x2="96" y2="90" stroke="currentColor" stroke-width="6.5" stroke-linecap="round"/>'
                '<circle cx="122" cy="120" r="30" fill="none" stroke="#C8A872" stroke-width="9"/>'
                '<line x1="144" y1="142" x2="166" y2="164" stroke="#C8A872" stroke-width="11" stroke-linecap="round"/>'),
}
def _make_mark(logo_id):
    inner = MARKS.get(logo_id, MARKS["stack"])
    return ('<span class="hmark"><svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">'
            + inner + '</svg></span>')
def _stored_logo():
    try:
        p = os.path.expanduser("~/Library/Application Support/PageHawk/config.json")
        icon = str(json.load(open(p)).get("app_icon", "stack_slate_light"))
        mark = icon.split("_")[0]   # "stack_slate_light" -> "stack"
        return mark if mark in MARKS else "stack"
    except Exception:
        return "stack"
# Default at import (the CLI / first render); build_tool refreshes it from the live
# config each run so changing the app icon updates the masthead mark on the next review.
BRAND_MARK = _make_mark(_stored_logo())
# favicon: the Stack document mark (page + lines, one gold) on warm-black, as a data URI
FAVICON = ("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2064%2064'%3E"
           "%3Crect%20width='64'%20height='64'%20rx='15'%20fill='%2315171A'/%3E"
           "%3Crect%20x='21'%20y='15'%20width='22'%20height='33'%20rx='4'%20fill='none'%20stroke='%23F6F1E7'%20stroke-width='3'/%3E"
           "%3Cline%20x1='27'%20y1='25'%20x2='37'%20y2='25'%20stroke='%23F6F1E7'%20stroke-width='2.5'%20stroke-linecap='round'/%3E"
           "%3Cline%20x1='27'%20y1='32'%20x2='37'%20y2='32'%20stroke='%23F6F1E7'%20stroke-width='2.5'%20stroke-linecap='round'/%3E"
           "%3Cline%20x1='27'%20y1='39'%20x2='33'%20y2='39'%20stroke='%23C8A872'%20stroke-width='2.5'%20stroke-linecap='round'/%3E%3C/svg%3E")
# masthead mark style + the short "Rise" entrance (reduced-motion safe). Lives in the
# first <style> block (palette) so it precedes — and composes with — each mode's CSS.
BRAND_CSS = ("#top .hmark{width:21px;height:21px;flex:0 0 auto;color:var(--fg);display:inline-flex;align-items:center}"
             "#top .hmark svg{width:100%;height:100%;display:block}"
             "@keyframes hvnrise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}"
             "#top{animation:hvnrise .4s cubic-bezier(.22,.7,.3,1) both}"
             "#stage,#grid,#controls,#hint,#meta{animation:hvnrise .46s cubic-bezier(.22,.7,.3,1) .06s both}"
             "@media(prefers-reduced-motion:reduce){#top,#stage,#grid,#controls,#hint,#meta{animation:none}}")
_FONTS = '<link rel="preconnect" href="https://fonts.googleapis.com"><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"><link rel="icon" href="' + FAVICON + '">'
_HEAD = "--font:'Inter',-apple-system,system-ui,sans-serif;--font-head:Cambria,'Big Caslon','Hoefler Text',Georgia,serif;"
# Refined action colors (Jason 2026-06-07): clean, legible, distinct from the
# brand gold accent. keep=emerald, trash=warm red, unsure=amber-orange.
# The canonical HVN six-scheme color system (hvn-color-schemes.md): the same set the
# Digital vCard, Voice Capture, and the JP Dev toolbar use. Order = the `t`-key cycle.
# Light-family (light/ivory/white) and dark-family (slate/charcoal/dark) keep their
# own action-color sets so Keep/Trash/Unsure stay legible on either surface.
THEMES = {
    # LIGHT = HVN "Heritage" paper (the default; Jason's standing pick)
    "light": _HEAD + "--bg:#FBFAF7;--imgbg:#efece6;--fg:#0E0E10;--mut:#6A6A6B;--top-bg:#FFFFFFcc;--border:#E6E3DC;"
        "--track:#ECE9E2;--chip:#F2EFE8;--accent:#C8A872;--accent-fg:#1a1a1a;--accent-dark:#B8860B;"
        "--shadow:0 6px 32px rgba(40,40,40,.13);--keep:#1F9D57;--trash:#5E6A7C;--both:#B8860B;--unsure:#C77F1A;--neutral:#cbc7be;",
    # IVORY GOLD = the HVN-signature daytime look (warm paper)
    "ivory": _HEAD + "--bg:#F4EFE4;--imgbg:#EFE7D5;--fg:#2A2D30;--mut:#8A8270;--top-bg:#FBF7EEcc;--border:#E7DEC9;"
        "--track:#E7DEC9;--chip:#EFE7D5;--accent:#C8A872;--accent-fg:#1a1a1a;--accent-dark:#B8860B;"
        "--shadow:0 10px 36px rgba(120,96,48,.16);--keep:#1F9D57;--trash:#5E6A7C;--both:#B8860B;--unsure:#C77F1A;--neutral:#d8cdb6;",
    # WHITE = clean neutral daylight
    "white": _HEAD + "--bg:#F7F7F8;--imgbg:#eceef0;--fg:#16181A;--mut:#8A8F96;--top-bg:#FFFFFFcc;--border:#E6E8EB;"
        "--track:#EEF0F1;--chip:#F2F3F5;--accent:#C8A872;--accent-fg:#1a1a1a;--accent-dark:#B8860B;"
        "--shadow:0 8px 30px rgba(20,22,26,.12);--keep:#1F9D57;--trash:#5E6A7C;--both:#B8860B;--unsure:#C77F1A;--neutral:#d3d6da;",
    # SLATE = cool blue-gray dark
    "slate": _HEAD + "--bg:radial-gradient(120% 100% at 50% -20%,#222A33 0%,#1A2027 55%,#141A20);--imgbg:#0e1318;"
        "--fg:#E8ECF2;--mut:#93A0AE;--top-bg:#222A3399;--border:#2b3947;--track:#2b3947;--chip:#232c36;"
        "--accent:#C8A872;--accent-fg:#1a1a1a;--accent-dark:#D8BC8C;--shadow:0 14px 55px rgba(0,0,0,.6);"
        "--keep:#23A867;--trash:#6E7A8C;--both:#C8A872;--unsure:#E0922E;--neutral:#33404e;",
    # CHARCOAL = warm neutral dark
    "charcoal": _HEAD + "--bg:radial-gradient(120% 100% at 50% -20%,#33373B 0%,#26292C 55%,#1E2123);--imgbg:#15171a;"
        "--fg:#F3EAD6;--mut:#A6A08F;--top-bg:#33373B99;--border:#3a3e42;--track:#3a3e42;--chip:#2f3338;"
        "--accent:#C8A872;--accent-fg:#1a1a1a;--accent-dark:#D8BC8C;--shadow:0 16px 55px rgba(0,0,0,.62);"
        "--keep:#23A867;--trash:#6E7A8C;--both:#C8A872;--unsure:#E0922E;--neutral:#3c3c40;",
    # DARK = HVN "Visionary" near-black (photos pop)
    "dark": _HEAD + "--bg:radial-gradient(120% 100% at 50% -20%,#2A2628 0%,#1A1A1C 55%);--imgbg:#0c0c0d;"
        "--fg:#F4F3F0;--mut:#9a978f;--top-bg:#16161899;--border:#2d2a2b;--track:#2d2a2b;--chip:#262329;"
        "--accent:#C8A872;--accent-fg:#1a1a1a;--accent-dark:#D8BC8C;--shadow:0 12px 55px rgba(0,0,0,.72);"
        "--keep:#23A867;--trash:#6E7A8C;--both:#C8A872;--unsure:#E0922E;--neutral:#3c3c40;",
}
# Default theme persists in the skill: Jason's most-recent chosen theme is stored
# in preferred_theme.txt next to this script and becomes the default for new tools.
def _stored_theme():
    try:
        t = open(os.path.join(os.path.dirname(__file__), "preferred_theme.txt")).read().strip()
        return t if t in THEMES else "light"
    except OSError:
        return "light"
DEFAULT_THEME = _stored_theme()
def _stored_layout():
    try:
        t = open(os.path.join(os.path.dirname(__file__), "preferred_layout.txt")).read().strip()
        return t if t in ("cols", "rows") else "cols"
    except OSError:
        return "cols"
DEFAULT_LAYOUT = _stored_layout()

JS = r"""
const DATA = __DATA__;
const MODE = DATA.mode;
const KEY = "vtriage:"+DATA.task;
let i=0, decisions={};
try{decisions=JSON.parse(localStorage.getItem(KEY)||"{}");}catch(e){}
const items=DATA.items;
function firstUndecided(){for(let k=0;k<items.length;k++){if(!decisions[items[k].id])return k;}return items.length;}
i=firstUndecided();
function fmt(b){return b>1048576?(b/1048576).toFixed(1)+" MB":Math.round(b/1024)+" KB";}
function imgHTML(side){ return side.url ? `<img class="shot" src="${side.url}">` : `<div class="noimg">No preview<br>${side.name}</div>`; }

function render(){
  if(i>=items.length){showDone();return;}
  const it=items[i];
  const stage=document.getElementById("stage");
  if(MODE==="single"){
    stage.className="single"; stage.innerHTML=`<div class="pane">${imgHTML(it)}<div class="cap"><span class="nm">${it.name}</span></div></div>`;
    if(i+1<items.length && items[i+1].url){const p=new Image();p.src=items[i+1].url;}
    const f=it.features||{};
    document.getElementById("meta").innerHTML = Object.entries(f).map(([k,v])=>`<span class="tag">${k}: ${v}</span>`).join("");
  } else {
    stage.className=""; stage.innerHTML=`<div class="pane"><div class="panelbl">LEFT (←)</div>${imgHTML(it.a)}<div class="cap"><span class="nm">${it.a.name}</span></div></div>
      <div class="pane"><div class="panelbl">RIGHT (→)</div>${imgHTML(it.b)}<div class="cap"><span class="nm">${it.b.name}</span></div></div>`;
    const f=it.features||{};
    document.getElementById("meta").innerHTML = Object.entries(f).map(([k,v])=>`<span class="tag">${k}: ${v}</span>`).join("");
  }
  document.getElementById("pos").textContent=(i+1)+" / "+items.length;
  updateTally();
}
function record(dec){
  if(i>=items.length)return; const it=items[i];
  decisions[it.id]={id:it.id, decision:dec, my_guess:it.guess||null, ts:Date.now(),
    item: MODE==="single"? {path:it.path,name:it.name,features:it.features||{}}
                          : {a:it.a.path,b:it.b.path,features:it.features||{}}};
  localStorage.setItem(KEY,JSON.stringify(decisions));
  const f=document.getElementById("flash");
  const match=it.guess? (dec===it.guess):null;
  f.textContent=dec.toUpperCase().replace("_"," ")+(it.guess?("  ·  guess: "+it.guess+(match?" ✓":" ✗")):"");
  f.style.background=match===null?"rgba(59,130,246,.85)":(match?"rgba(34,197,94,.85)":"rgba(239,68,68,.85)");
  f.style.opacity=1;setTimeout(()=>f.style.opacity=0,330);
  i++;render();
}
function undo(){let j=i-1;while(j>=0&&!decisions[items[j].id])j--;if(j<0)return;
  delete decisions[items[j].id];localStorage.setItem(KEY,JSON.stringify(decisions));i=j;
  document.getElementById("done").style.display="none";document.getElementById("stage").style.display="flex";
  document.getElementById("meta").style.display="block";document.getElementById("controls").style.display="flex";render();}
function updateTally(){let c={},tot=0,agree=0;for(const it of items){const d=decisions[it.id];if(!d)continue;tot++;
  c[d.decision]=(c[d.decision]||0)+1;if(it.guess&&d.decision===it.guess)agree++;}
  document.getElementById("tcount").textContent=Object.entries(c).map(([k,v])=>k+":"+v).join("  ")||"-";
  document.getElementById("ta").textContent=tot?Math.round(agree/tot*100)+"%":"-";
  document.getElementById("fill").style.width=(tot/items.length*100)+"%";}
function showDone(){document.getElementById("stage").style.display="none";document.getElementById("meta").style.display="none";
  document.getElementById("controls").style.display="none";const dn=document.getElementById("done");dn.style.display="flex";
  document.getElementById("donesum").textContent="Reviewed "+Object.keys(decisions).length+" of "+items.length;updateTally();}
function exportD(){const out={task:DATA.task,mode:MODE,exported:new Date().toISOString(),total:items.length,
  theme:document.documentElement.dataset.theme,
  reviewed:Object.keys(decisions).length,decisions:Object.values(decisions)};
  const b=new Blob([JSON.stringify(out,null,2)],{type:"application/json"});const a=document.createElement("a");
  a.href=URL.createObjectURL(b);a.download="triage-decisions-"+DATA.task+".json";a.click();}
var SCHEMES=['light','ivory','white','slate','charcoal','dark'];
function toggleTheme(){location.href='hvniz://appearance?toggle';}   // the ◐ button = app-wide light/dark toggle
window.__setScheme=function(s){if(!s)return;document.documentElement.setAttribute('data-theme',s);try{localStorage.setItem('vtriage-theme',s)}catch(e){}};
window.addEventListener('message',function(e){if(e.data&&e.data.type==='jpdev-scheme')window.__setScheme(e.data.scheme);});
// Follow the OS light/dark (set via the app's Appearance pref / system) unless a scheme is pinned.
try{matchMedia('(prefers-color-scheme:dark)').addEventListener('change',function(e){if(!localStorage.getItem('vtriage-theme'))document.documentElement.setAttribute('data-theme',e.matches?'dark':'light');});}catch(_e){}
document.addEventListener("keydown",e=>{
  const K=e.key;
  if(K==="t"||K==="T"){toggleTheme();e.preventDefault();return;}
  if(MODE==="single"){
    if(K==="ArrowRight")record("keep");else if(K==="ArrowLeft")record("trash");
    else if(K==="ArrowDown")record("unsure");else return;
  } else {
    if(K==="ArrowLeft")record("keep_left");else if(K==="ArrowRight")record("keep_right");
    else if(K==="ArrowUp")record("keep_both");else if(K==="ArrowDown")record("trash_both");
    else if(K==="u"||K==="U")record("unsure");else if(K!=="z"&&K!=="Z"&&K!=="e"&&K!=="E")return;
  }
  if(K==="z"||K==="Z")undo(); if(K==="e"||K==="E")exportD();
  e.preventDefault();
});
render();
"""

def html_doc(task, mode, items, theme="dark"):
    data = json.dumps({"task": task, "mode": mode, "items": items})
    if theme not in THEMES:
        theme = DEFAULT_THEME
    palette = "".join(f"html[data-theme='{k}']{{{v}}}" for k, v in THEMES.items()) + BRAND_CSS
    if mode == "single":
        controls = ('<button class="act b-trash" onclick="record(\'trash\')">❌ Trash (←)</button>'
                    '<button class="act b-keep" onclick="record(\'keep\')">✅ Keep (→)</button>'
                    '<button class="act b-unsure" onclick="record(\'unsure\')">🟡 Unsure (↓)</button>')
        hint = '<kbd>←</kbd> trash &nbsp; <kbd>→</kbd> keep &nbsp; <kbd>↓</kbd> unsure &nbsp; <kbd>z</kbd> undo &nbsp; <kbd>e</kbd> export'
    else:
        controls = ('<button class="act b-keep" onclick="record(\'keep_left\')">⬅️ Keep left</button>'
                    '<button class="act b-keep" onclick="record(\'keep_right\')">Keep right ➡️</button>'
                    '<button class="act b-both" onclick="record(\'keep_both\')">↑ Keep both (different)</button>'
                    '<button class="act b-trash" onclick="record(\'trash_both\')">↓ Trash both</button>'
                    '<button class="act b-unsure" onclick="record(\'unsure\')">🟡 Unsure (u)</button>')
        hint = '<kbd>←</kbd> keep left &nbsp; <kbd>→</kbd> keep right &nbsp; <kbd>↑</kbd> keep both &nbsp; <kbd>↓</kbd> trash both &nbsp; <kbd>u</kbd> unsure &nbsp; <kbd>z</kbd> undo &nbsp; <kbd>e</kbd> export'
    return f"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>{task}</title>
{_FONTS}<style>{palette}</style><style>{CSS}</style>
<script>document.documentElement.dataset.theme=new URLSearchParams(location.search).get('scheme')||localStorage.getItem('vtriage-theme')||'{theme}';</script></head><body>
<div id="top">{BRAND_MARK}<div id="brand">{_brand_name()}</div><div class="tally" id="pos">0 / 0</div><div id="bar"><div id="fill"></div></div>
<div class="tally" id="tcount">-</div><div class="tally">agree <b id="ta">-</b></div>
<button id="recachetog" onclick="location.href='hvniz://recache'" title="Refresh &amp; recache — re-read every file from disk (use after you edit a file)">↻</button><button id="themetog" onclick="toggleTheme()" title="Cycle scheme (t)">◐</button>
<button class="act b-exp" onclick="exportD()">Export ⤓</button></div>
<div id="flash"></div><div id="stage"></div><div id="meta"></div>
<div id="controls">{controls}</div><div id="hint">{hint} &nbsp;·&nbsp; auto-saves; Export sends decisions to Claude</div>
<div id="done"><h1>Done 🎉</h1><p id="donesum"></p><button class="act b-exp" onclick="exportD()">Export decisions ⤓</button></div>
<script>{JS.replace("__DATA__", data)}</script></body></html>"""

# ---------------------------------------------------------------------------
# GRID (single-mode default for piles, esp. photos): a fast, lazy-loaded grid of
# SMALL thumbnails with category filters, bulk actions, keep/trash/unsure + keys.
# Scales to thousands (small thumbs + lazy images + parallel generation).
# ---------------------------------------------------------------------------
GRID_CSS = """
*{box-sizing:border-box} body{margin:0;font-family:var(--font);background:var(--bg);color:var(--fg);height:100vh;display:flex;flex-direction:column;overflow:hidden}
#top{flex:0 0 auto;position:relative;z-index:20;display:flex;align-items:center;gap:11px;padding:5px 16px;background:var(--top-bg);border-bottom:1px solid var(--border);flex-wrap:nowrap;min-height:34px}
/* Brand mark + wordmark lifted to the horizontal center of the toolbar (Jason: "logo towards the middle top").
   pointer-events:none so it never blocks the controls that sit on either side of it. It is absolutely
   positioned (z-index:1), so it PAINTS OVER the static toolbar children. That is fine while the center is
   empty, but the selection action bar (#selbar) grows from the left cluster toward the center and would then
   render UNDER the wordmark — the "logo superimposed on the menu bar" collision. Fix: fade the decorative
   wordmark out whenever a selection is active (#top.hassel), since the center is no longer free. */
#top .brandc{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex;align-items:center;gap:8px;pointer-events:none;z-index:1;white-space:nowrap;transition:opacity .18s ease}
/* Hidden when the toolbar center is not free: during an active selection (#selbar), OR when the
   left/right control clusters encroach on the center at narrow widths / long brand names — measured
   by fitBrand() via a ResizeObserver on the .sp spacer, which toggles .brandclip. Default is visible,
   so if the measuring JS ever fails the wordmark still shows (occasional overlap) rather than vanishing. */
#top.hassel .brandc,#top.brandclip .brandc{opacity:0}
#brand{font-family:var(--font-head);font-size:16px}#brand .accent{color:var(--accent-dark)}
.tal{font-size:12.5px;color:var(--mut)}.tal b{color:var(--fg)}.tal .k{color:#4FA86B}.tal .t{color:#7E8A9C}.tal .u{color:#D9A441}
.szl{font-size:11.5px;color:var(--mut);display:inline-flex;align-items:center;gap:6px;white-space:nowrap}.szr{width:88px;vertical-align:middle;accent-color:var(--accent);cursor:pointer}
.chips{display:flex;gap:6px;flex-wrap:wrap}
.chip{font-size:12px;padding:4px 11px;border:1px solid var(--border);border-radius:14px;cursor:pointer;color:var(--mut);background:var(--chip);white-space:nowrap}
.chip.on{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}
.sp{flex:1}
/* ── Toolbar responsiveness (Jason, 2026-08-04: "many bar headers crunched in incorrect ways —
   work on responsive design better here"). Before this the file had SIX media queries and not one
   of them was about width: they were all reduced-motion, colour-scheme and resolution. So the
   toolbar had no narrow-width behavior at all, and at a small window it did the three ugly things
   in his screenshot at once — the file count broke across two lines, the kind chips wrapped into a
   SECOND ROW that doubled the bar's height, and "☰ List ▾" split mid-label.

   The strategy is: the bar is ONE ROW at every width, and things that cannot fit scroll or leave,
   in a deliberate order of importance rather than by whatever happens to overflow first. */
.tal{white-space:nowrap;flex:0 0 auto}
.vbtn,#topview{white-space:nowrap;flex:0 0 auto}
/* Compare is a first-class library verb, always on the bar. Jason, 2026-08-04: "if there are only
   two items, this should just be a compare button that immediately flips it into compare mode.
   The compare button basically would ask me to select which items in the library to, but if there
   are only two, it would just automatically compare those two. That is the better path than this
   pop-up." So: no dialog anywhere. Two documents land in the library, and the button either
   compares them outright or arms itself and waits for two clicks. */
.cmpb{white-space:nowrap;flex:0 0 auto}
/* Document-set switch (Together / Separately), the library side of the reader's #setgrp.
   Hidden unless the host says this window IS a set of 2+ documents opened in one gesture, so an
   ordinary folder library never grows a control. Jason 2026-08-27. */
#setsw{display:none;align-items:center;flex:0 0 auto;border:1px solid var(--line,#d8d4cc);
  border-radius:9px;overflow:hidden;margin:0 4px}
#setsw.on{display:inline-flex}
#setsw button{border:none;border-radius:0;height:26px;min-width:64px;padding:0 11px;
  font-size:12px;font-weight:600;background:transparent;color:inherit;white-space:nowrap;cursor:pointer}
#setsw button.on{background:var(--accent-dark,#8a6d3b);color:#fff}
/* READY = "there are exactly two documents here, and this button will compare them." When that is
   true it is the primary action of the whole window, so it is filled rather than merely outlined:
   Jason asked to "make it very easy to quickly compare them", and an outline among four other
   outlined buttons is not easy, it is findable. */
.cmpb.ready{border-color:var(--accent);background:var(--accent);color:#1B1A17;font-weight:600}
.cmpb.ready:hover{filter:brightness(1.06)}
/* ARMED = waiting for two picks. Deliberately NOT the filled treatment: filled means "press me",
   and while armed the next thing to press is a document, not this button. */
.cmpb.armed{border-color:var(--accent);background:transparent;color:var(--accent);font-weight:600;
  box-shadow:0 0 0 3px rgba(200,168,114,.18)}
/* Chips scroll sideways instead of wrapping. A second row of chips is what actually made the
   header "crunched": it changes the bar's HEIGHT, which moves everything below it. */
.chips{flex-wrap:nowrap;overflow-x:auto;min-width:0;scrollbar-width:none;-ms-overflow-style:none}
.chips::-webkit-scrollbar{display:none}
/* The selection bar is the widest thing that can appear, and it appears suddenly. Let it shrink
   and scroll rather than shove the rest of the toolbar off the end. */
#seltop{min-width:0;overflow:hidden}
#selbar{max-width:100%;overflow-x:auto;scrollbar-width:none}
#selbar::-webkit-scrollbar{display:none}
@media (max-width:1180px){
  #top{gap:8px;padding:5px 12px}
  .szr{width:64px}
}
@media (max-width:1000px){
  /* Thumbnail SIZE is the first thing to go: it only affects the grid, it is a nicety rather than
     navigation, and the View menu beside it still reaches every layout. */
  #topsize{display:none}
}
@media (max-width:820px){
  #top{gap:6px;padding:5px 10px}
}
/* DELIBERATELY NOT DONE: collapsing the View button to a bare glyph at narrow widths. The first
   attempt did `.vbtn{font-size:0}` + a `::before` glyph, and it shipped a DOUBLED icon — the
   button already carries a ☰ in its label, `button.b{font-size:12px}` out-specifies a bare
   `.vbtn`, so the text never shrank and the pseudo-element simply added a second one. Worth
   recording rather than silently reverting: the measurement (one row, 41px, no overflow) PASSED
   the whole time it was broken, because a doubled glyph is not a layout fault. Only opening the
   capture caught it. The row fits at 390 with the word intact, so the label stays. */
button.b{font-family:var(--font);font-size:12px;padding:6px 12px;border:1px solid var(--border);border-radius:8px;background:var(--chip);color:var(--fg);cursor:pointer}
button.b:hover{border-color:var(--accent)}
#grid{flex:1 1 auto;min-height:0;overflow:auto;padding:11px 14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--tw,180px),1fr));grid-auto-rows:1px;row-gap:0;column-gap:11px;align-content:start}
/* P3 (Jason 2026-07-13): Compact browse density — a one-tap tightening of grid/list spacing
   for more on screen. Orthogonal to --tw (tile size); this is the spacing lever only. */
body.dense #grid{padding:7px 9px;column-gap:7px}
body.dense .burststrip{padding:7px 9px;column-gap:7px}
body.dense .lrow{padding:4px 9px;font-size:12px}
body.dense #ghead,body.dense #gbody{font-size:12px}
body.dense #ghead .lrow,body.dense #gbody .lrow{padding:3px 9px}
#grid.listmode{display:flex;gap:0;overflow:hidden}
#listtbl{flex:1 1 56%;min-width:0;overflow:auto}
#listprev{flex:1 1 44%;min-width:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;background:var(--chip);border:1px solid var(--border);border-radius:12px;padding:14px}
#listprev img{max-width:100%;max-height:calc(100% - 30px);object-fit:contain;border-radius:6px;box-shadow:0 3px 16px rgba(0,0,0,.2)}
#listprev .lpcap{font-size:12px;color:var(--mut);text-align:center;word-break:break-word}
#listprev .lpempty{color:var(--mut);font-size:13px;text-align:center;max-width:64%}
.lrow{display:grid;grid-template-columns:minmax(0,2.4fr) minmax(0,1.4fr) 72px 80px 116px;gap:10px;padding:7px 10px;border-bottom:1px solid var(--border);font-size:12.5px;cursor:pointer;align-items:center}
.lrow .lname{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--fg)}
.lrow .lc{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--mut)}
.lrow .lr{text-align:right}
.lrow.lhdr{position:sticky;top:0;background:var(--top-bg);font-weight:600;color:var(--mut);cursor:default;z-index:1}
/* ── Finder-grade selection (spec 09 Part 3) ────────────────────────────────
   The bar, measured off macOS list view: a selected row is a FULL-BLEED filled bar with
   every column's text reversed out of it, contiguous rows merge into one solid block, and
   the fill drops to grey when focus is elsewhere. The old treatment was a 22% wash that
   left the muted columns grey, which is why "five selected" was not legible at a glance.

   The fill colour is a TOKEN on purpose. `--selfill` is PageHawk's own gold, per
   every-surface-is-branded; `body.selblue` swaps in the macOS system selection blue that
   spec 09 Part 3 originally asked for.

   DECIDED BY JASON, 2026-08-05, shown both rendered side by side: **GOLD**. That is the shipping
   default below and it is not an open question — spec 09 Part 3's "system accent" line is
   superseded, and a future session finding that line must not re-open this. `body.selblue` stays
   only because it is what made the choice showable, and it is what the verifier renders as the
   losing candidate. */
:root{--selfill:var(--accent);--selfg:var(--accent-fg);
      /* Unemphasized = macOS unemphasizedSelectedContentBackgroundColor. Derived from --fg over
         --chip so it lands as a light grey on the three light themes and a lifted grey on the
         three dark ones, without touching the six theme strings. It mixes over --chip and NOT
         over --bg for two reasons: --bg is a radial-gradient on every dark theme (color-mix
         would be invalid and the rule would drop), and an OPAQUE fill is what lets the contrast
         gate measure a real ratio instead of compositing a guess. */
      --selfill-un:color-mix(in srgb,var(--fg) 15%,var(--chip))}
body.selblue{--selfill:#0A5DC2;--selfg:#FFFFFF}
/* Focus elsewhere: grey fill, and the text goes back to its normal colours (Finder does not
   invert an unemphasized row). This is the state everybody skips, and the 4th QA frame. */
body.nosfoc{--selfill:var(--selfill-un);--selfg:var(--fg)}

/* One selector group: a plain click sets .sel (preview/anchor) AND .selp (selection member),
   so both must read identically or a single-selected row looks different from one of five. */
.lrow.sel,.lrow.selp,.colfilerow.sel,.colfilerow.selp,.colrow.sel{background:var(--selfill)}
.lrow.sel .lc,.lrow.sel .lname,.lrow.sel>.lcell,
.lrow.selp .lc,.lrow.selp .lname,.lrow.selp>.lcell,
.colfilerow.sel .cnm,.colfilerow.sel .cct,.colfilerow.sel .cchev,
.colfilerow.selp .cnm,.colfilerow.selp .cct,.colfilerow.selp .cchev,
.colrow.sel .cnm,.colrow.sel .cct,.colrow.sel .cchev{color:var(--selfg)}
/* Every column inverts, including the muted ones. A selected row whose date and kind stay
   grey looks broken; that was measured correction 4. */
.lrow.sel>.lcell.lc-name,.lrow.selp>.lcell.lc-name{color:var(--selfg)}
/* The unemphasized row deliberately does NOT keep --mut on its secondary columns, and this was
   measured rather than chosen: --mut sits at about 5:1 on the page background, so putting it on
   the grey fill drops it to roughly 3.5:1 and fails AA. Legibility is the floor and column
   hierarchy is not (contrast-legibility-guardrail.md), so every column in a selected row uses
   --selfg in both states. */

/* Contiguous runs merge into ONE block: no hairline between neighbours, and the corner radius
   belongs to the run's OUTER corners, not to each row inside it. Five adjacent selections must
   read as one bar, which is what makes "I have five" legible without counting.

   The run ends are marked by `selrun-t` / `selrun-b`, computed in syncSel(). A `:has(+ .lrow.selp)`
   version was written first and briefly believed broken; that was WRONG and is recorded here so
   nobody re-derives it. `element.matches('.lrow.selp:has(+ .lrow.selp)')` returned TRUE the whole
   time. What actually misled the harness was `.lrow`'s own `transition` on background and
   border-color: an offscreen WKWebView never advances a transition, so the measurement read the
   previous state's values. The computed classes are kept anyway, on their own merits: the run ends
   become explicit state a test can assert directly, rather than something inferred from a selector
   whose dynamic invalidation nobody here has actually measured. */
.lrow.selp,.colfilerow.selp{border-radius:0;border-bottom-color:transparent}
.lrow.selp.selrun-t,.colfilerow.selp.selrun-t{border-top-left-radius:6px;border-top-right-radius:6px}
.lrow.selp.selrun-b,.colfilerow.selp.selrun-b{border-bottom-left-radius:6px;border-bottom-right-radius:6px}

#fsstrip .fsth.selp{outline:3px solid var(--selfill);outline-offset:-1px}
.lrow:not(.lhdr):not(.sel):not(.selp):hover{background:rgba(150,150,150,.13)}
/* faint vertical column separators so the column positions are readable (Jason: "very light lines") */
.lrow>.lcell:not(:last-child){border-right:1px solid var(--border)}
/* ...and they disappear inside a selected row, because Finder's filled bar is unbroken. */
.lrow.sel>.lcell:not(:last-child),.lrow.selp>.lcell:not(:last-child){border-right-color:transparent}
#listdiv{flex:0 0 7px;align-self:stretch;margin:0 7px;cursor:col-resize;border-radius:4px;background:var(--border);opacity:.5;transition:opacity .1s,background .1s}
#listdiv:hover{opacity:1;background:var(--accent)}
.lrow.lhdr .lsort{cursor:pointer;user-select:none}
.lrow.lhdr .lsort:hover{color:var(--fg)}
.lgrp{display:flex;align-items:center;gap:8px;padding:6px 10px;font-size:12px;font-weight:600;color:var(--fg);background:var(--chip);border-bottom:1px solid var(--border);cursor:pointer}
.lgrp .lgcaret{display:inline-block;transition:transform .12s;color:var(--mut);font-size:10px}
.lgrp:not(.collapsed) .lgcaret{transform:rotate(90deg)}
.lgrp .lgname{flex:1}
.lgrp .lgct{font-size:11px;color:var(--mut)}
.tile{position:relative;cursor:pointer;grid-row-end:span 240}
.tile .tinner{position:relative;display:block;background:var(--chip);border:3px solid transparent;border-radius:11px;overflow:hidden}
.tile.best .tinner{border-color:#C8A872}
.tile.keep .tinner{border-color:#4FA86B}.tile.trash .tinner{border-color:#7A8699;opacity:.5}.tile.unsure .tinner{border-color:#D9A441}
.tile.foc .tinner{box-shadow:0 0 0 3px var(--accent)}
.tile img{width:100%;height:auto;display:block;background:var(--imgbg)}
.noimg2{display:flex;align-items:center;justify-content:center;color:var(--mut);font-size:11px;background:var(--imgbg);min-height:120px}
/* Uniform document tiles: photos keep their true aspect (masonry), but PDFs/Decks/Docs/Sheets get a
   fixed letter-portrait thumb + a clamped 2-line caption, so the grid lays out even (no ragged sizes/gaps). */
.tile.docu .tinner img{aspect-ratio:17/22;object-fit:cover;object-position:top center;height:auto}
.tile.docu .noimg2{aspect-ratio:17/22;min-height:0}
.tile.docu .cap .fn{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:2.85em}
.cap{font-size:10.5px;padding:5px 8px 7px;color:var(--mut);word-break:break-word;line-height:1.5}
.catb{font-size:8.5px;font-weight:bold;letter-spacing:.3px;padding:1px 5px;border-radius:4px;text-transform:uppercase;background:#5F5E5A;color:#fff}
.catb.photos{background:#3B6D45}.catb.screenshots{background:#1F5FA5}.catb.pdfs{background:#7A5E1F}.catb.documents{background:#7A5E1F}.catb.decks{background:#6A3E6E}.catb.spreadsheets{background:#2E6B56}.catb.videos{background:#3F5694}
.cap .dim{font-size:9px;color:var(--mut);margin-left:5px}.cap .fn{display:block;margin-top:3px}
.acts{position:absolute;top:7px;right:7px;display:flex;gap:4px;opacity:0;transition:opacity .1s}
.tile:hover .acts,.tile.foc .acts{opacity:1}
.acts button{width:27px;height:27px;border:0;border-radius:7px;font-size:13px;cursor:pointer;color:#fff;background:rgba(20,20,22,.72)}
.acts .ak:hover{background:#4FA86B}.acts .at:hover{background:#6E7A8C}.acts .au:hover{background:#D9A441}
.dot{position:absolute;top:7px;left:7px;width:15px;height:15px;border-radius:50%;display:none;border:2px solid #fff}
.tile.keep .dot{display:block;background:#4FA86B}.tile.trash .dot{display:block;background:#7A8699}.tile.unsure .dot{display:block;background:#D9A441}
.sgtag{position:absolute;bottom:30px;right:7px;font-size:9px;padding:1px 6px;border-radius:5px;background:rgba(122,134,153,.92);color:#fff;letter-spacing:.3px}
.tile.keep .sgtag,.tile.trash .sgtag,.tile.unsure .sgtag{display:none}
#hint{flex:0 0 auto;font-size:10.5px;color:var(--mut);padding:5px 16px;border-top:1px solid var(--border);text-align:center}
#hint kbd{font-size:9.5px;padding:0 4px;background:var(--chip);border:1px solid var(--border);border-radius:3px;color:var(--fg)}
#gridpath{flex:0 0 auto;display:flex;align-items:center;gap:2px;padding:4px 14px 6px;overflow-x:auto;white-space:nowrap;font-size:10px;border-top:1px solid var(--border);scrollbar-width:none}
#gridpath::-webkit-scrollbar{display:none}
#gridpath .crumb{color:var(--mut);cursor:pointer;padding:1px 5px;border-radius:4px}
#gridpath .crumb:hover{background:var(--chip);color:var(--fg)}
#gridpath .crumb.file{color:var(--accent)}
#gridpath .csep{color:var(--mut);opacity:.55;flex:0 0 auto}
#gridpath:empty{display:none}
#grid.fsmode{display:flex;flex-direction:column;overflow:hidden;padding:0}
#fsmain{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:16px 16px 8px;position:relative}
#fsmain img{max-width:100%;max-height:calc(100% - 26px);object-fit:contain;border-radius:6px;background:var(--imgbg);box-shadow:0 6px 28px rgba(0,0,0,.22)}
#fsmain .fscap{font-size:12px;color:var(--mut);text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:92%}
#fsmain .fsempty{color:var(--mut);font-size:13px}
#fsstrip{flex:0 0 auto;display:flex;gap:8px;overflow-x:auto;padding:10px 14px;border-top:1px solid var(--border);scrollbar-width:thin}
#fsstrip .fsth{flex:0 0 auto;width:96px;height:72px;border-radius:7px;overflow:hidden;border:2px solid transparent;cursor:pointer;background:var(--imgbg);display:flex;align-items:center;justify-content:center}
#fsstrip .fsth img{width:100%;height:100%;object-fit:cover;display:block}
#fsstrip .fsth.sel{border-color:var(--accent)}
#fsstrip .fsth:hover{border-color:var(--border)}
#fsstrip .fsth .fsnoimg{font-size:8.5px;color:var(--mut);text-transform:uppercase;letter-spacing:.3px;text-align:center;padding:2px}
#grid.colmode{display:flex;gap:0;overflow:hidden}
.colpane{flex:0 0 auto;overflow:auto;border-right:1px solid var(--border);min-width:0}
.colpane.colf{flex:0 0 190px}
.colpane.colfiles{flex:0 0 290px}
.colpane.colprev{flex:1 1 auto;border-right:none;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:16px}
.colpane.colprev img{max-width:100%;max-height:calc(100% - 26px);object-fit:contain;border-radius:6px;box-shadow:0 4px 20px rgba(0,0,0,.2)}
.colpane.colprev .lpcap{font-size:12px;color:var(--mut);text-align:center}
.colrow{display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:12.5px;cursor:pointer;border-bottom:1px solid var(--border);white-space:nowrap;overflow:hidden}
.colrow .cnm{flex:1;overflow:hidden;text-overflow:ellipsis}
.colrow .cct{font-size:11px;color:var(--mut)}
.colrow .cchev{color:var(--mut);font-size:13px}
/* Folder column rows share the selection tokens (see the Finder-grade block above) so a
   selected folder and a selected file cannot drift apart visually. */
.colrow.sel{background:var(--selfill)}.colrow.sel .cnm,.colrow.sel .cct,.colrow.sel .cchev{color:var(--selfg)}
.colrow:not(.sel):hover{background:rgba(150,150,150,.13)}
.colfilerow .cic{width:30px;height:22px;flex:0 0 auto;display:flex;align-items:center;justify-content:center;color:var(--mut)}
.colfilerow .cic img{width:30px;height:22px;object-fit:cover;border-radius:3px;display:block}
.vseg.vseg4{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.vseg.vseg4 button{flex:none;font-size:12px;padding:8px 6px;display:inline-flex;align-items:center;justify-content:center;gap:6px;white-space:nowrap;line-height:1}
#lb{position:fixed;inset:0;background:rgba(16,16,18,.95);display:none;flex-direction:column;z-index:50}
#lb.on{display:flex}
#lbtop{flex:0 0 auto;display:flex;align-items:center;gap:14px;padding:11px 18px;color:#EDE7DB;font-size:13px}
#lbtop .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#lbtop .ct{font-size:12px;color:#9a958c}
.st{font-size:11.5px;padding:2px 9px;border-radius:7px;display:none}
.st.keep{display:inline;background:#2E6B45;color:#CFEBD9}.st.trash{display:inline;background:#3C4654;color:#CDD5E0}.st.unsure{display:inline;background:#7A5E1F;color:#F0DEB0}
#lbb{display:flex;gap:8px}#lbb button{font-size:12px;padding:6px 13px;border:1px solid #44444a;border-radius:8px;background:#2a2a2e;color:#EDE7DB;cursor:pointer}
#lbb .ak:hover{background:#2E6B45}#lbb .at:hover{background:#3C4654}#lbb .au:hover{background:#7A5E1F}
#lbx{cursor:pointer;color:#EDE7DB;font-size:19px;line-height:1;padding:0 4px}
#lbpic-wrap{flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;gap:12px;padding:0 18px 12px;overflow:hidden}
#lbextra{display:contents}
#lbpic,#lbextra .lbpg2{max-width:calc((100% - (var(--ppv,1) - 1)*12px)/var(--ppv,1));max-height:100%;object-fit:contain;border-radius:6px;background:#000}
.lbjump{width:58px;height:24px;border:1px solid rgba(255,255,255,.2);border-radius:6px;background:rgba(255,255,255,.08);color:#EDE7DB;font-family:var(--font);font-size:11.5px;text-align:center;padding:0 4px;margin-left:8px}
#lbpg.clk{cursor:pointer;border-radius:6px;padding:1px 7px}
#lbpg.clk::after{content:" ▾";opacity:.6;font-size:9px}
#lbpg.clk:hover{background:rgba(255,255,255,.12)}
#lbpg.clk:focus-visible{outline:2px solid rgba(216,188,140,.7);outline-offset:1px}
#lbppv{display:none;align-items:center;gap:5px;margin-left:8px;color:#cfc8bb}
#lbppv svg{flex:0 0 auto}
#lbppv select{background:rgba(255,255,255,.08);color:#EDE7DB;border:1px solid rgba(255,255,255,.2);border-radius:5px;font-size:11px;padding:1px 5px;cursor:pointer}
#lbvid{max-width:100%;max-height:100%;border-radius:6px;background:#000}
.vbadge{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:36px;height:36px;border-radius:50%;background:rgba(20,20,22,.6);color:#fff;display:flex;align-items:center;justify-content:center;font-size:14px;padding-left:3px;pointer-events:none;z-index:1;box-shadow:0 1px 6px rgba(0,0,0,.35)}
#lbhint{flex:0 0 auto;text-align:center;color:#8d887f;font-size:11px;padding:7px}
#lbex{font-size:12px;color:#b9b3a7;white-space:nowrap}#lbex .gps{color:#D8BC8C}
#lbtop #lbfold{font-size:12px;color:#D8BC8C;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:32%}
#lbpath{flex:0 0 auto;padding:3px 16px 5px;display:flex;align-items:center;gap:2px;overflow-x:auto;white-space:nowrap;font-size:10px;scrollbar-width:none}
#lbpath::-webkit-scrollbar{display:none}
#lbpath .crumb{color:#9a948a;cursor:pointer;padding:1px 5px;border-radius:4px}
#lbpath .crumb:hover{background:rgba(255,255,255,.12);color:#fff}
#lbpath .crumb.file{color:#D8BC8C}
#lbpath .csep{color:#6a655c;flex:0 0 auto}
#lbpath:empty{display:none}
#lbload{display:none;position:absolute;inset:0;align-items:center;justify-content:center;pointer-events:none;padding:0 18px 12px}
#lbload.on{display:flex}@keyframes lbspin{to{transform:rotate(360deg)}}
/* Document-page skeleton — shown ONLY where no thumbnail is available yet (a page is
   rendering). Award research: a content-shaped skeleton beats a spinner, but it must never
   cover existing content. A page-shaped card with faux text lines + a calm warm shimmer
   sweep; transform-only (GPU-cheap), reduced-motion aware. */
.docskel{position:relative;box-sizing:border-box;aspect-ratio:8.5/11;height:min(94%,80vh);max-width:92%;border-radius:8px;overflow:hidden;padding:11% 12% 0;
  --skel-base:color-mix(in srgb,var(--imgbg),var(--mut) 26%);
  --skel-bd:color-mix(in srgb,var(--border),transparent 35%);
  --skel-line:color-mix(in srgb,var(--mut),transparent 58%);
  --skel-sheen:color-mix(in srgb,#fff,transparent 74%);
  background:var(--skel-base);border:.5px solid var(--skel-bd);box-shadow:0 4px 22px rgba(0,0,0,.20),inset 0 .5px 0 rgba(255,255,255,.45)}
.docskel .skl{display:block;height:9px;border-radius:4px;background:var(--skel-line);margin:0 0 5.6%}
.docskel .skl.h{height:16px;width:52%;margin-bottom:9.5%}
.docskel .skl.s{width:86%}.docskel .skl.m{width:96%}.docskel .skl.q{width:40%}
.docskel::after{content:"";position:absolute;inset:0;background:linear-gradient(100deg,transparent 32%,var(--skel-sheen) 50%,transparent 68%);transform:translateX(-100%);animation:skelsweep 1.5s cubic-bezier(.4,0,.2,1) infinite;will-change:transform}
@keyframes skelsweep{to{transform:translateX(100%)}}
@media(prefers-reduced-motion:reduce){.docskel::after{animation:none}.docskel{animation:skelpulse 1.9s ease-in-out infinite}}
@keyframes skelpulse{0%,100%{opacity:1}50%{opacity:.6}}
.ctx{position:fixed;z-index:60;background:var(--top-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--border);border-radius:10px;padding:5px;min-width:184px;box-shadow:0 10px 34px rgba(0,0,0,.34)}
.ctx .ci{font-size:12.5px;padding:7px 12px;border-radius:6px;cursor:pointer;color:var(--fg);white-space:nowrap}
.ctx .ci:hover{background:var(--accent);color:var(--accent-fg)}
.cap .foldb{font-size:8.5px;font-weight:bold;letter-spacing:.2px;padding:1px 5px;border-radius:4px;background:var(--chip);color:var(--mut);border:1px solid var(--border);margin-left:5px}
.foldersec .bursthd{position:sticky;top:0;z-index:3;background:var(--top-bg);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
.vdrop{position:relative;display:inline-block}
.vmenu{position:absolute;right:0;top:calc(100% + 6px);z-index:40;display:none;min-width:228px;background:var(--top-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border:1px solid var(--border);border-radius:11px;padding:7px;box-shadow:0 12px 34px rgba(0,0,0,.34)}
.vmenu.on{display:block}
.vrow{display:flex;align-items:center;gap:8px;font-size:12.5px;padding:7px 9px;border-radius:7px;cursor:pointer;color:var(--fg)}
.vrow:hover{background:var(--chip)}.vrow .vck{visibility:hidden;color:var(--accent-dark);font-weight:bold;width:12px}.vrow.on .vck{visibility:visible}
.vsep{height:1px;background:var(--border);margin:6px 2px}
.vlbl{font-size:10.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);padding:4px 9px 5px}
.vseg{display:flex;gap:4px;padding:0 6px 4px}.vseg button{flex:1;font-family:var(--font);font-size:11.5px;padding:5px 0;border:1px solid var(--border);border-radius:7px;background:var(--chip);color:var(--fg);cursor:pointer}
.vseg button.on{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}
.cfm{position:fixed;inset:0;z-index:80;background:rgba(16,16,18,.45);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);display:flex;align-items:center;justify-content:center}
.cfmbox{width:440px;max-width:90vw;background:var(--chip);border:1px solid var(--border);border-radius:16px;padding:26px 26px 20px;box-shadow:0 24px 70px rgba(0,0,0,.5)}
.cfmh{font-family:var(--font-head);font-size:22px;color:var(--fg);margin-bottom:10px}
.cfmm{font-size:14px;line-height:1.6;color:var(--mut)}.cfmm b{color:var(--fg)}
.cfmbtns{display:flex;justify-content:flex-end;gap:10px;margin-top:24px}
.cfmbtns button{font-family:var(--font);font-size:13px;font-weight:600;padding:10px 20px;border-radius:10px;cursor:pointer;border:1px solid var(--border)}
.cfmcancel{background:var(--chip);color:var(--fg)}.cfmcancel:hover{border-color:var(--accent)}
.cfmok{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}.cfmok:hover{filter:brightness(1.07)}
.tile .bbadge{position:absolute;bottom:30px;left:7px;display:none;font-size:9px;font-weight:bold;letter-spacing:.3px;padding:1px 6px;border-radius:5px;background:#C8A872;color:#1a1a1a;z-index:2}
.tile.best .bbadge{display:block}
#grid.bursts{display:block;padding:8px 14px}
.burstsec{border:1px solid var(--border);border-radius:12px;margin:0 0 14px;background:var(--top-bg)}
.bursthd{display:flex;align-items:center;gap:10px;padding:8px 13px;border-bottom:1px solid var(--border);font-size:12.5px;flex-wrap:wrap}
.bursthd .bn{font-weight:600;color:var(--fg)}.bursthd .bsug{color:var(--accent-dark);font-size:11px}.bursthd .sp{flex:1}
.burststrip{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--tw,180px),1fr));grid-auto-rows:1px;row-gap:0;column-gap:11px;padding:11px 13px}
button.keepbest{font-family:var(--font);font-size:11.5px;padding:5px 12px;border-radius:8px;background:var(--accent);color:var(--accent-fg);border:1px solid var(--accent);cursor:pointer;font-weight:600}
button.keepbest:hover{filter:brightness(1.07)}
/* The "ask once" card (#openchoice) lived here until 2026-08-30, when Jason retired the question
   itself (REQ-20260830-050): sets open together by default; the escape is the multi-select
   right-click's "Open in Separate Tabs". Its token lesson survives it: this stylesheet's tokens
   are NOT reader.html's (`--bar`/`--line` do not exist here, and `--shadow` here is a COMPLETE
   shadow value), and one-side-hardcoded-one-side-themed pairs measure as white-on-white in dark
   (contrast-legibility-guardrail.md). */
/* ── Powerful inline preview pane (shared by List / Gallery / Columns) ───────────────
   A real document viewer in the side pane: page X of Y, page nav, zoom/fit/rotate,
   jump-to-page, pages-per-view, a page-thumbnail rail, and quick actions. Reuses the
   lightbox page-render backend (hvniz://pages) so it works on any doc the app renders. */
#listprev,.colpane.colprev,#fsmain{align-items:stretch !important;justify-content:flex-start !important;padding:0 !important;gap:0 !important;position:relative}
.pvwrap{display:flex;flex-direction:column;width:100%;height:100%;min-height:0;min-width:0}
/* EDITOR MODE (ballot option B): the pane runs the real reader in a frame. The frame takes the
   whole pane below a slim bar, and RESPONSIVE is the point — the reader is already fluid, so it
   simply gets whatever width the pane has and lays its own chrome out for it. min-height:0 on the
   flex parent is what actually lets the frame shrink; without it the pane grows past its column
   and the document scrolls off the bottom instead of fitting. */
.pvbar-embed{flex:0 0 auto}
.pvembname{font-size:11.5px;color:var(--mut);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;padding-left:4px}
.pvmain-embed{flex:1;min-height:0;min-width:0;display:flex}
.pvreader{flex:1;width:100%;height:100%;border:0;display:block;background:var(--bg)}
.pvbar{flex:0 0 auto;display:flex;align-items:center;gap:6px;padding:7px 9px;border-bottom:1px solid var(--border);background:var(--top-bg);flex-wrap:wrap;font-size:11.5px;color:var(--mut)}
.pvbar .pvpg{font-weight:600;color:var(--fg);white-space:nowrap;font-variant-numeric:tabular-nums}
.pvbar .pvpg.clk{cursor:pointer;border-radius:6px;padding:1px 7px}
.pvbar .pvpg.clk::after{content:" ▾";color:var(--mut);font-size:9px;font-weight:400}
.pvbar .pvpg.clk:hover{background:var(--chip)}
.pvbar .pvpg.clk:focus-visible{outline:2px solid var(--ring);outline-offset:1px}
.pvbar .pvsp{flex:1 1 auto}
.pvb{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;padding:0 7px;border:1px solid var(--border);border-radius:7px;background:var(--chip);color:var(--fg);cursor:pointer;font-size:12.5px;font-family:var(--font);line-height:1;user-select:none}
.pvb:hover{border-color:var(--accent)}
.pvb.on{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}
.pvb:disabled{opacity:.32;cursor:default;border-color:var(--border)}
/* ── Customizable toolbar: display modes + customize mode (mirrors colCfg) ── */
.pvbar[data-disp="text"] .pvb.pvbl{font-size:0}
.pvbar[data-disp="text"] .pvb.pvbl::after{content:attr(data-label);font-size:12px;font-weight:500}
.pvbar[data-disp="both"] .pvb.pvbl::after{content:attr(data-label);font-size:11px;margin-left:5px;opacity:.92}
.pvbar .pvhidden,#top .pvhidden{display:none!important}
.pvbar.pvcustomizing,#top.pvcustomizing{background:color-mix(in srgb,var(--accent) 10%,var(--top-bg));outline:1.5px dashed var(--accent);outline-offset:-2px;padding-bottom:22px}
.pvcustomizing .pvitem{position:relative;overflow:visible;cursor:grab;border-style:dashed;border-color:color-mix(in srgb,var(--accent) 55%,var(--border));-webkit-user-drag:none;-webkit-user-select:none;user-select:none;touch-action:none}
.pvcustomizing .pvitem.pvdrag{opacity:.55;cursor:grabbing;box-shadow:0 4px 14px rgba(28,24,18,.3);z-index:9}
.pvbtray .pvchip{-webkit-user-drag:none;-webkit-user-select:none;user-select:none;touch-action:none}
.pvbar.pvcustomizing .pvsp.pvitem{flex:0 0 42px;min-width:42px;border:1px dashed color-mix(in srgb,var(--accent) 45%,var(--border));border-radius:7px;background:color-mix(in srgb,var(--accent) 8%,transparent)}
.pvbar.pvcustomizing .pvpg.pvitem{border:1px dashed color-mix(in srgb,var(--accent) 45%,var(--border));border-radius:7px}
/* identify-label under each item: only when the mode itself shows no text (icon mode), plus the blank spacer always */
.pvbar.pvcustomizing[data-disp="icon"] .pvitem::before,
.pvbar.pvcustomizing .pvsp.pvitem::before,
#top.pvcustomizing .pvitem::before{content:attr(data-label);position:absolute;top:100%;left:50%;transform:translateX(-50%);margin-top:2px;font-size:9px;line-height:1;color:var(--mut);white-space:nowrap;pointer-events:none;font-weight:500;letter-spacing:.2px}
.pvbar .pvdropind{width:2px;align-self:stretch;background:var(--accent);border-radius:2px;margin:0 -1px;box-shadow:0 0 6px var(--accent);pointer-events:none}
.pvbar .rmx{position:absolute;top:-6px;right:-6px;width:15px;height:15px;border-radius:50%;background:#4C8DEF;color:#fff;font-size:10px;line-height:15px;text-align:center;cursor:pointer;z-index:6;box-shadow:0 1px 3px rgba(28,24,18,.28)}
.pvbar .rmx:hover{background:#3B79E0}
.pvbtray{display:flex;flex-wrap:wrap;gap:7px;align-items:center;padding:10px 11px;background:color-mix(in srgb,var(--accent) 7%,var(--top-bg));border-bottom:1px solid var(--border)}
.pvbtray.pvdragover{background:color-mix(in srgb,var(--accent) 16%,var(--top-bg));outline:1.5px dashed var(--accent);outline-offset:-4px}
.pvbtray .pvtlbl{font-size:10.5px;font-weight:600;color:var(--mut);text-transform:uppercase;letter-spacing:.4px;width:100%}
.pvbtray .pvchip{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 9px;border:1px dashed color-mix(in srgb,var(--accent) 50%,var(--border));border-radius:7px;background:var(--chip);color:var(--fg);font-size:11.5px;cursor:grab}
.pvbtray .pvchip .cg{font-size:12.5px;color:var(--mut)}
.pvbtray .pvtempty{font-size:11.5px;color:var(--mut);font-style:italic}
.pvbedit{display:flex;align-items:center;gap:10px;padding:9px 11px;background:var(--top-bg);border-bottom:1px solid var(--border);flex-wrap:wrap}
.pvbedit .pvel{flex:1;min-width:160px;font-size:11px;color:var(--mut);line-height:1.4}
.pvbedit .pveb{border:1px solid var(--border);background:var(--chip);color:var(--fg);border-radius:7px;padding:6px 12px;font-size:11.5px;cursor:pointer;font-family:var(--font)}
.pvbedit .pveb:hover{border-color:var(--accent)}
.pvbedit .pveb.pri{background:var(--accent);color:var(--accent-fg);border-color:var(--accent);font-weight:600}
.pvbedit .pvseg{display:inline-flex;border:1px solid var(--border);border-radius:7px;overflow:hidden}
.pvbedit .pvseg button{border:0;background:var(--chip);color:var(--fg);padding:6px 10px;font-size:11px;cursor:pointer;font-family:var(--font)}
.pvbedit .pvseg button.on{background:var(--accent);color:var(--accent-fg)}
.pvbmenu{position:fixed;z-index:70;min-width:190px;background:var(--top-bg);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.35);padding:5px;font-family:var(--font)}
.pvbmenu button{display:flex;align-items:center;gap:9px;width:100%;text-align:left;border:0;background:transparent;color:var(--fg);font-size:12px;padding:7px 10px;border-radius:6px;cursor:pointer}
.pvbmenu button:hover{background:var(--chip)}
.pvbmenu .mck{margin-left:auto;color:var(--accent)}
.pvbmenu .msep{height:1px;background:var(--border);margin:4px 6px}
.pvjump{width:58px;height:26px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--fg);font-family:var(--font);font-size:12px;text-align:center;padding:0 4px;cursor:text}
.pvjump::-webkit-inner-spin-button{display:none}
.pvppv{height:26px;border:1px solid var(--border);border-radius:7px;background:var(--chip);color:var(--fg);font-family:var(--font);font-size:11.5px;padding:0 4px;cursor:pointer}
.pvstage{flex:1 1 auto;min-height:0;min-width:0;display:flex;align-items:center;justify-content:center;gap:10px;overflow:hidden;background:var(--imgbg);position:relative}
.pvstage.fitw{align-items:flex-start;overflow:auto}
.pvstage img{max-width:100%;max-height:100%;object-fit:contain;border-radius:5px;box-shadow:0 3px 16px rgba(0,0,0,.22);background:#fff;display:block;transition:transform .04s linear}
.pvstage.fitw img{max-width:100%;max-height:none;width:100%}
.pvstage .pvpg2{max-width:calc((100% - (var(--pvn,1) - 1)*10px)/var(--pvn,1))}
.pvstage .pvspin{position:absolute;left:50%;top:50%;width:26px;height:26px;margin:-13px 0 0 -13px;border-radius:50%;border:3px solid rgba(150,150,150,.25);border-top-color:var(--accent);animation:lbspin .8s linear infinite;display:none}
.pvstage.loading .pvspin{display:block}
/* Reading themes (Day / Night / Sepia) applied to the rendered page images only */
.pvstage.night img,.pvstage.night .pvscrollpg{filter:invert(1) hue-rotate(180deg)}
.pvstage.sepia img,.pvstage.sepia .pvscrollpg{filter:sepia(.55) brightness(.96) contrast(.96)}
#lbpic-wrap.night #lbpic,#lbpic-wrap.night .lbpg2{filter:invert(1) hue-rotate(180deg)}
#lbpic-wrap.sepia #lbpic,#lbpic-wrap.sepia .lbpg2{filter:sepia(.55) brightness(.96) contrast(.96)}
/* Continuous-scroll mode: all pages stacked in a scroller (like Preview's continuous view) */
.pvstage.hasscroll{align-items:stretch;justify-content:stretch;overflow:hidden;gap:0}
.pvscroll{position:relative;width:100%;height:100%;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:10px;padding:10px;scrollbar-width:thin}
.pvscrollpg{width:calc(var(--pvz,1)*100%);max-width:none;max-height:none;height:auto;flex:0 0 auto;border-radius:5px;box-shadow:0 3px 16px rgba(0,0,0,.22);background:#fff;object-fit:initial}
/* Info / metadata popover ("Show More") */
.pvinfo{position:absolute;left:10px;top:10px;z-index:3;background:var(--top-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--border);border-radius:9px;padding:9px 12px;font-size:11.5px;box-shadow:0 10px 30px rgba(0,0,0,.4);max-width:84%;display:none}
.pvstage .pvinfo.on{display:block}
.pvinfo .pir{display:flex;gap:10px;padding:2px 0}.pvinfo .pik{color:var(--mut);min-width:78px;flex:0 0 auto}.pvinfo .piv{color:var(--fg);word-break:break-word}
.pvempty{margin:auto;color:var(--mut);font-size:13px;text-align:center;max-width:70%;padding:20px}
.pvcap{flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:6px 10px;border-top:1px solid var(--border);font-size:11px;color:var(--mut);background:var(--top-bg);min-width:0}
.pvcap .pvnm{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--fg)}
.pvcap .pvmeta{flex:0 0 auto;color:var(--mut);white-space:nowrap}
/* Reading progress + resume: an ambient gold bar between the page and the
   caption, a quiet % in the caption, and a transient "Resumed · Start over" chip on a restored doc. */
.pvprog{flex:0 0 3px;height:3px;background:var(--track);position:relative;display:none;overflow:hidden}
.pvprog .pvprogfill{height:100%;width:0;background:var(--accent);transition:width .2s ease}
.pvcap .pvpct{flex:0 0 auto;color:var(--accent-dark);font-variant-numeric:tabular-nums;font-weight:600;white-space:nowrap;display:none}
.pvresume{position:absolute;left:50%;top:10px;transform:translateX(-50%);z-index:5;display:none;align-items:center;gap:9px;
  background:var(--top-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--border);
  border-radius:999px;padding:5px 6px 5px 13px;font-size:11.5px;color:var(--fg);box-shadow:0 8px 26px rgba(0,0,0,.32);opacity:0;transition:opacity .25s}
.pvresume.on{display:inline-flex;opacity:1}
.pvresume .pvrz{border:1px solid var(--border);background:var(--chip);color:var(--fg);border-radius:999px;padding:3px 11px;cursor:pointer;font-family:var(--font);font-size:11px}
.pvresume .pvrz:hover{border-color:var(--accent)}
/* Focus / Zen mode: the live preview pane fills the window and covers the app chrome, so only the
   document is in view. The toolbar + caption dim and lift on hover; a quiet hint shows the exit. */
body.pv-focus{overflow:hidden}
/* #grid keeps an identity transform from its intro animation (animation fill:both) plus a
   view-transition-name; either makes the pane's position:fixed resolve to the content area, leaving the
   sidebar + top bar showing. The animation outranks !important, so clear animation too — that drops the
   residual transform and lets the fixed pane escape to the full viewport. */
body.pv-focus #grid{animation:none !important;transform:none !important;view-transition-name:none !important}
.pvfocused{position:fixed !important;inset:0 !important;z-index:45 !important;margin:0 !important;border:none !important;border-radius:0 !important;padding:0 !important;background:var(--imgbg) !important;box-shadow:none !important}
.pvfocused .pvbar{background:transparent;border-bottom-color:transparent;opacity:.5;transition:opacity .2s}
.pvfocused .pvbar:hover{opacity:1;background:var(--top-bg)}
.pvfocused .pvcap{background:transparent;border-top-color:transparent;opacity:.5;transition:opacity .2s}
.pvfocused .pvcap:hover{opacity:1}
.pvfocushint{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:6;font-size:11px;color:var(--mut);
  background:var(--top-bg);border:1px solid var(--border);border-radius:999px;padding:4px 13px;opacity:.85;pointer-events:none;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
.pvfocushint b{color:var(--fg);font-weight:600}
/* Presentation mode: a chromeless, dark, one-page-at-a-time slide deck of the current document.
   Reuses focus mode's #grid viewport-escape fix (clear the intro animation's residual transform so
   the fixed pane resolves to the whole viewport, not the content area), then strips ALL chrome and
   goes dark. Click or →/↓/Space advances; ←/↑ goes back; Esc / ⇧P exits. */
body.pv-present{overflow:hidden}
body.pv-present #grid{animation:none !important;transform:none !important;view-transition-name:none !important}
.pvpresented{position:fixed !important;inset:0 !important;z-index:60 !important;margin:0 !important;border:none !important;border-radius:0 !important;padding:0 !important;background:#0a0a0b !important;box-shadow:none !important}
.pvpresented .pvbar,.pvpresented .pvcap,.pvpresented .pvrailwrap,.pvpresented .pvprog{display:none !important}
.pvpresented .pvstage{background:#0a0a0b !important;cursor:pointer}
.pvpreshint{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);z-index:6;font-size:11px;color:rgba(255,255,255,.62);
  background:rgba(20,20,22,.6);border:1px solid rgba(255,255,255,.14);border-radius:999px;padding:5px 14px;opacity:.9;pointer-events:none;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
.pvpreshint b{color:#fff;font-weight:600}
/* Document marks (a–z): clickable chips in the caption + a transient toast for set/jump feedback. */
.pvcap .pvmarks{flex:0 0 auto;display:none;align-items:center;gap:5px;white-space:nowrap}
.pvcap .pvmarks .pvmklbl{font-size:9.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut)}
.pvcap .pvmk{display:inline-flex;align-items:center;gap:3px;border:1px solid var(--border);background:var(--chip);color:var(--fg);border-radius:6px;padding:1px 6px;cursor:pointer;font-family:var(--font);font-size:10.5px;line-height:1.6}
.pvcap .pvmk:hover{border-color:var(--accent)}
.pvcap .pvmk .pvmkp{color:var(--accent-dark);font-variant-numeric:tabular-nums;font-weight:600}
.pvtoast{position:absolute;left:50%;top:10px;transform:translateX(-50%);z-index:6;display:none;
  background:var(--top-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--border);
  border-radius:999px;padding:5px 14px;font-size:11.5px;color:var(--fg);box-shadow:0 8px 26px rgba(0,0,0,.32);opacity:0;transition:opacity .2s}
.pvtoast.on{display:inline-block;opacity:1}
/* Page-overview grid (contact sheet): a full grid of every page over the reading surface. */
.pvov{position:absolute;inset:0;z-index:7;background:var(--bg);flex-direction:column;overflow:hidden}
.pvovhd{flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--border);font-size:12px;color:var(--mut)}
.pvovgrid{flex:1 1 auto;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(94px,1fr));gap:12px;padding:14px;align-content:start;scrollbar-width:thin}
.pvovc{position:relative;cursor:pointer;border:2px solid transparent;border-radius:6px;overflow:hidden;background:#fff;aspect-ratio:8.5/11;box-shadow:0 2px 10px rgba(0,0,0,.18)}
.pvovc img{width:100%;height:100%;object-fit:cover;display:block}
.pvovc:hover{border-color:var(--accent)}
.pvovc.cur{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent)}
.pvovc .pvovn{position:absolute;bottom:0;left:0;right:0;text-align:center;font-size:10px;background:rgba(20,20,22,.62);color:#fff;line-height:1.7;font-variant-numeric:tabular-nums}
/* Configurable page-thumbnail rail: dockable (bottom / right), resizable (drag the grip),
   collapsible + closable (✕), reopenable (the ▦ toolbar button). All persisted. */
.pvmain{flex:1 1 auto;min-height:0;min-width:0;display:flex;flex-direction:column}
.pvmain.railright{flex-direction:row}
.pvstagewrap{flex:1 1 auto;min-height:0;min-width:0;display:flex;flex-direction:column;position:relative}
/* when the PDF preview holds the arrow keys (click it), ring it so it's clear the arrows now page the PDF */
body.pvkfocus .pvstagewrap{box-shadow:inset 0 0 0 2px var(--accent);border-radius:8px}
body.pvkfocus .pvstagewrap::after{content:"Arrows page this PDF · Esc back to files";position:absolute;left:50%;bottom:8px;transform:translateX(-50%);font-size:10.5px;color:var(--accent-fg);background:var(--accent);padding:2px 9px;border-radius:10px;opacity:.9;pointer-events:none;z-index:5;white-space:nowrap}
.pvrailwrap{flex:0 0 auto;position:relative;display:flex;flex-direction:column;background:var(--bg);border-top:1px solid var(--border)}
.pvmain.railright .pvrailwrap{flex-direction:row;border-top:none;border-left:1px solid var(--border)}
.pvrailwrap.hidden{display:none}
.pvrailgrip{flex:0 0 7px;cursor:row-resize;background:var(--border);opacity:.4;transition:opacity .1s,background .1s}
.pvrailgrip:hover{opacity:1;background:var(--accent)}
.pvmain.railright .pvrailgrip{cursor:col-resize}
.pvrailctl{position:absolute;top:4px;right:6px;z-index:4;display:flex;gap:3px}
.pvmain.railright .pvrailctl{top:9px;right:4px}
.pvrailctl button{width:20px;height:20px;border:1px solid var(--border);border-radius:5px;background:var(--top-bg);color:var(--mut);cursor:pointer;font-size:11px;line-height:1;padding:0;display:flex;align-items:center;justify-content:center}
.pvrailctl button:hover{border-color:var(--accent);color:var(--fg)}
.pvrail{flex:1 1 auto;display:flex;gap:6px;overflow-x:auto;overflow-y:hidden;padding:7px 9px;scrollbar-width:thin;align-items:center}
.pvmain.railright .pvrail{flex-direction:column;overflow-x:hidden;overflow-y:auto;align-items:center}
.pvrail .pvth{flex:0 0 auto;width:46px;height:60px;border-radius:4px;overflow:hidden;border:2px solid transparent;cursor:pointer;background:#fff;position:relative}
.pvrail .pvth img{width:100%;height:100%;object-fit:cover;display:block}
.pvrail .pvth.sel{border-color:var(--accent)}
.pvrail .pvth .pvthn{position:absolute;bottom:0;left:0;right:0;font-size:8px;text-align:center;background:rgba(20,20,22,.6);color:#fff;line-height:1.4}
/* ── Customizable list columns ─────────────────────────────────────────────────────
   The list header/rows are driven by a column registry (visible set + order + widths),
   so columns can be toggled, reordered (drag), resized (drag the edge), and sorted. */
.lrow{grid-template-columns:var(--lcols,minmax(0,2.4fr) minmax(0,1.4fr) 72px 80px 116px) !important}
.lrow .lcell{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--mut);min-width:0}
.lrow .lcell.lc-name{color:var(--fg)}
.lrow .lcell.num{text-align:right;font-variant-numeric:tabular-nums}
.lrow.lhdr .lcell{position:relative;cursor:pointer;user-select:none;font-weight:600}
.lrow.lhdr .lcell:hover{color:var(--fg)}
.lrow.lhdr .lcell.draghover{box-shadow:inset 2px 0 0 var(--accent)}
.lrow.lhdr .lcell.dragging{opacity:.4}
.lcolgrip{position:absolute;top:0;right:-4px;width:9px;height:100%;cursor:col-resize;z-index:2}
.lcolgrip:hover{background:linear-gradient(90deg,transparent,var(--accent),transparent);opacity:.6}
.lcolcfg{margin-left:6px;flex:0 0 auto}
.colmenu{position:absolute;z-index:70;background:var(--top-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border:1px solid var(--border);border-radius:11px;padding:7px;min-width:188px;box-shadow:0 12px 34px rgba(0,0,0,.34)}
.colmenu .cmlbl{font-size:10.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);padding:4px 9px 6px}
.colmenu .cmrow{display:flex;align-items:center;gap:8px;font-size:12.5px;padding:6px 9px;border-radius:7px;cursor:pointer;color:var(--fg)}
.colmenu .cmrow:hover{background:var(--chip)}
.colmenu .cmrow .cmck{width:13px;color:var(--accent-dark);font-weight:bold;visibility:hidden}
.colmenu .cmrow.on .cmck{visibility:visible}
.colmenu .cmrow.lock{opacity:.5;cursor:default}
.colmenu .cmsep{height:1px;background:var(--border);margin:6px 2px}
.colmenu .cmreset{font-size:11.5px;color:var(--accent-dark);padding:6px 9px;cursor:pointer;border-radius:7px}
.colmenu .cmreset:hover{background:var(--chip)}
/* ════════════════════════════════════════════════════════════════════════════
   2025 design system — motion, depth, focus + type craft. Cutting-edge yet
   timeless-luxury: beauty and function fused. Additive, GPU-cheap (transform/
   opacity only), and honors prefers-reduced-motion. (Jason 2026-06-30.)
   ════════════════════════════════════════════════════════════════════════════ */
:root{
  --ease-out:cubic-bezier(.22,1,.36,1);
  --ease-spring:linear(0,.009,.035 2.1%,.141 4.4%,.723 12.9%,.938,1.017,1.066,1.082,1.069,1.045 28.8%,1.008,.995,.992,.998,1.002 41%,1.001,.999 53%,1);
  --dur-1:120ms;--dur-2:200ms;--dur-3:300ms;
  --ring:color-mix(in srgb,var(--accent) 62%,transparent);
  --sh-1:0 1px 1px rgba(28,24,18,.05),0 2px 4px rgba(28,24,18,.05);
  --sh-2:0 1px 2px rgba(28,24,18,.06),0 4px 10px rgba(28,24,18,.07),0 10px 26px rgba(28,24,18,.06);
  --sh-pop:0 2px 6px rgba(20,16,12,.10),0 12px 30px rgba(20,16,12,.16),0 30px 70px rgba(20,16,12,.13);
}
/* typographic craft: optical legibility, Inter stylistic sets, tabular numbers where they tally */
body{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;font-feature-settings:"cv05","cv11","ss01","kern","liga";letter-spacing:-.003em}
.tally,.tal,.lcell.num,.lr,.pvpg,.pvjump,.pvmeta,.lbjump,#lbct,#lbpg,.cct,.lgct,.szl{font-variant-numeric:tabular-nums;font-feature-settings:"tnum" 1}
.vlbl,.catb,.cmlbl{letter-spacing:.07em}#brand{letter-spacing:.18px}
::selection{background:color-mix(in srgb,var(--accent) 38%,transparent)}
/* premium scrollbars: thin, inset, quiet until hover */
::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-corner{background:transparent}
::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--mut) 30%,transparent);border-radius:10px;border:3px solid transparent;background-clip:padding-box}
::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb,var(--mut) 52%,transparent);background-clip:padding-box}
/* focus craft: a calm gold ring, only for keyboard users */
:focus{outline:none}
button:focus-visible,input:focus-visible,select:focus-visible,.chip:focus-visible,[tabindex]:focus-visible,a:focus-visible{outline:2px solid var(--ring);outline-offset:2px;border-radius:7px}
input:focus-visible,.pvjump:focus-visible,.lbjump:focus-visible{outline-offset:1px}
/* frosted toolbar glass */
#top{-webkit-backdrop-filter:saturate(1.5) blur(20px);backdrop-filter:saturate(1.5) blur(20px)}
/* micro-interactions: press = settle, hover = lift */
button.b,.chip,.pvb,.vseg button,.vrow,.cmrow,.lbb button,#slidebtn,.pvrailctl button,.lcolcfg,.colrow,.srow,.fsth{
  transition:transform var(--dur-1) var(--ease-out),background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out),box-shadow var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out)}
button.b:hover,.pvb:hover,#slidebtn:hover{box-shadow:var(--sh-1)}
button.b:active,.pvb:active,.vseg button:active,.chip:active,.lbb button:active,#slidebtn:active,.pvrailctl button:active,.pvb:active{transform:scale(.96)}
.chip:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}
.chip.on{box-shadow:0 1px 7px color-mix(in srgb,var(--accent) 42%,transparent)}
/* tiles: a quiet lift on hover; the focused tile gets a confident gold frame */
.tile .tinner{transition:transform var(--dur-2) var(--ease-out),box-shadow var(--dur-2) var(--ease-out),border-color var(--dur-1)}
.tile:hover .tinner{transform:translateY(-3px);box-shadow:var(--sh-2)}
.tile.foc .tinner{box-shadow:0 0 0 2px var(--accent),var(--sh-2)}
/* rows: smooth selection wash + thumbnail nudge */
.lrow,.colrow,.srow,.cmrow,.vrow,.pvth{transition:background var(--dur-1) var(--ease-out),color var(--dur-1),border-color var(--dur-1),transform var(--dur-1) var(--ease-out)}
.pvth:hover{transform:translateY(-1px)}
/* floating surfaces: soft pop with a spring entrance (an Arc/Linear signature) */
.vmenu,.colmenu,.ctx,.cfmbox,#searchbox{box-shadow:var(--sh-pop)}
.vmenu.on,.colmenu,.ctx{animation:pvpop var(--dur-2) var(--ease-out)}
@keyframes pvpop{from{opacity:0;transform:translateY(-5px) scale(.985)}to{opacity:1;transform:none}}
.vrow:hover,.cmrow:hover:not(.lock),.srow:hover{transform:translateX(2px)}
/* view-mode cross-fade via the View Transitions API (Safari 18+); a no-op elsewhere */
#grid{view-transition-name:vt-stage}
::view-transition-old(vt-stage),::view-transition-new(vt-stage){animation-duration:var(--dur-3);animation-timing-function:var(--ease-out)}
@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important}
  .tile:hover .tinner,.vrow:hover,.cmrow:hover,.srow:hover,.pvth:hover{transform:none}
}
/* craft layer: optical sizing · editorial empty states · lit-edge depth · retina hairlines · whisper of grain */
body{font-optical-sizing:auto}
.pvempty,.lpempty,.fsempty{font-family:var(--font-head);font-style:italic;font-size:15px;line-height:1.62;letter-spacing:.005em;color:var(--mut)}
.pvempty{max-width:74%;padding:26px}
.vmenu,.colmenu,.ctx,#searchbox{box-shadow:var(--sh-pop),inset 0 .5px 0 color-mix(in srgb,#fff 48%,transparent)}
.vmenu{padding:9px}.vlbl{padding:6px 10px}.pvbar{padding:8px 11px}
@media (min-resolution:2dppx){
  #top,#hint,#gridpath,.lrow,.lgrp,.colpane,#fsstrip,.pvbar,.pvcap,.pvrailwrap,.vsep,.cmsep,.lrow.lhdr,.burstsec,.bursthd,#listdiv{border-width:.5px}
}
/* normalized placeholders (softer, never italic) + stable gutters (no layout shift on scroll) */
::placeholder{opacity:1;color:color-mix(in srgb,var(--mut) 78%,transparent);font-style:normal}
#listtbl,.colpane,#sresults{scrollbar-gutter:stable}
/* a whisper of film grain — an honest, non-flat surface (material honesty: texture, not faux-luxury) */
body::after{content:"";position:fixed;inset:0;z-index:999;pointer-events:none;opacity:.032;mix-blend-mode:soft-light;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
/* one-time "make <brand> your default viewer" pill (SPEC-3 item 1; concierge: never a dead
   end). Mirrors reader.html's #defbar exactly, re-themed to this template's own CSS vars. */
/* The pill is ~690px assembled, so a fixed nowrap row ran off BOTH edges of any window
   under ~700px and clipped the message (measured 2026-08-01, tools/defbar_harness.py:
   bar=687px, clipped at 390). It now caps at the viewport and wraps instead. */
#defbar{position:fixed;bottom:22px;left:50%;z-index:65;display:none;align-items:center;gap:11px;
  flex-wrap:wrap;width:max-content;
  padding:8px 10px 8px 16px;background:var(--top-bg);border:1px solid var(--border);border-radius:13px;
  box-shadow:var(--shadow);transform:translateX(-50%);max-width:calc(100vw - 40px)}
#defbar.on{display:flex}
/* The MESSAGE wraps and is never squeezed below a readable floor; the CONTROLS stay
   rigid. Measured 2026-08-08 at 390px: ellipsis alone reduced this to the single
   character "O", and letting the row wrap without pinning the buttons broke their
   labels into "Make / default". Both rules are needed, and they are not the same rule. */
#defbar span{font-size:12.5px;color:var(--fg);flex:1 1 240px;min-width:0;white-space:normal}
/* nowrap + no-shrink: without these the flex row squeezed the buttons and their labels wrapped to
   two lines inside a fixed 28px pill ("Make / default", "Not / now"). */
#defbar button{background:transparent;border:1px solid var(--border);color:var(--fg);border-radius:8px;
  height:28px;padding:0 11px;font-size:12.5px;cursor:pointer;white-space:nowrap;flex:0 0 auto;
  line-height:1}
/* --accent is a mid gold (#C8A872); white on it measures 2.26:1, well under AA. Every
   theme already ships --accent-fg for exactly this (#1a1a1a -> 7.71:1). */
#defbar #defyes{background:var(--accent);border-color:var(--accent);color:var(--accent-fg,#1a1a1a);font-weight:600}
/* v0.54 — mirrors reader.html: x = quiet snooze, checkbox = permanent.
   The muted token in this template is --mut, NOT --muted: var(--muted,...) matched nothing
   and silently fell through to the ivory literal #8A8270, which is 3.78:1 on the light
   themes' white surface. */
#defbar #defnever{display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--mut,#6A6A6B);cursor:pointer;user-select:none;padding-left:3px;white-space:nowrap}
#defbar #defnever input{margin:0;cursor:pointer}
#defbar #defnever span{font-size:11.5px;color:inherit}
#defbar #defx{border:none;color:var(--mut,#6A6A6B);font-size:17px;line-height:1;padding:0 6px;height:26px}
#defbar #defx:hover{color:var(--fg)}
#defbar.busy #defyes{opacity:.6;pointer-events:none}
#defbar.busy #defno,#defbar.busy #defx,#defbar.busy #defnever{opacity:.4;pointer-events:none}
"""

GRID_JS = r"""
const DATA=__DATA__; const items=DATA.items; const KEY="vtriage:"+DATA.task;
let dec=JSON.parse(localStorage.getItem(KEY)||"{}"); let filter="All"; let foc=-1; let order=[]; let lbId=-1;
let lbPagesArr=null, lbPage=0; var pagesCache={};   // multi-page doc paging in the lightbox
let lbZoom=1,lbPanX=0,lbPanY=0;   // photo zoom (wheel/pinch) + pan (drag); still images only
// lbWantPage = the page index to KEEP when paging ←/→ across files (Jason: "stay on page 2 of
// every document"). lbPage is that, clamped to each file's length; only ↑/↓ changes the desire.
var lbWantPage=0;
// Reader pages-per-view: show 1–5 pages side by side (1 = single, 2 = spread). Persisted.
var pagesPerView=Math.max(1,Math.min(5,parseInt(localStorage.getItem('vt-ppv')||'1')||1));
// View mode: 'grid' (thumbnails) or 'list' (sortable rows + preview pane). ?view= overrides; persisted.
var viewMode=(new URLSearchParams(location.search).get('view'))||(DATA&&DATA.defaultView)||localStorage.getItem('vt-viewmode')||'grid';
var listSel=-1;
let sortKey='none', sortDir=1, decFilter='all', slideTimer=null, showNoPrev=false;
// view settings (persisted)
let showFolderTags=(localStorage.getItem('vt-foltags')!=='0');
let groupByFolder=(function(){var v=localStorage.getItem('vt-groupfold');
  if(v==='1')return true; if(v==='0')return false;   // honor an explicit choice
  // no choice saved yet: default ON when the drop spans multiple subfolders, so a parent
  // folder of subfolders shows its groupings out of the box (toggle in View ▾).
  var s={}; (DATA.items||[]).forEach(function(it){if(it.folder)s[it.folder]=1;});
  return Object.keys(s).length>1;})();
let fnMode=(localStorage.getItem('vt-fnmode')||'full');   // full | short | off
function allFolders(){var s={};items.forEach(function(it){if(it.folder)s[it.folder]=1;});return Object.keys(s);}
function multiFolder(){return allFolders().length>1;}
function fnDisplay(n){n=n||'';if(fnMode==='short'&&n.length>26)return n.slice(0,16)+'…'+n.slice(-7);return n;}
function setView(k,v){if(k==='foltags'){showFolderTags=v;localStorage.setItem('vt-foltags',v?'1':'0');}
  else if(k==='groupfold'){groupByFolder=v;localStorage.setItem('vt-groupfold',v?'1':'0');}
  else if(k==='fnmode'){fnMode=v;localStorage.setItem('vt-fnmode',v);}
  foc=-1;render();}
// a beautiful confirmation before any bulk change (nothing destructive happens without it)
function confirmDialog(title,msg,onYes){var ov=document.createElement('div');ov.className='cfm';
  ov.innerHTML='<div class="cfmbox"><div class="cfmh">'+esc(title)+'</div><div class="cfmm">'+msg+'</div>'
   +'<div class="cfmbtns"><button class="cfmcancel">Cancel</button><button class="cfmok">Confirm</button></div></div>';
  document.body.appendChild(ov);
  function close(){ov.remove();document.removeEventListener('keydown',onk,true);}
  function onk(e){if(e.key==='Escape'){e.stopPropagation();close();}else if(e.key==='Enter'){e.stopPropagation();close();onYes();}}
  document.addEventListener('keydown',onk,true);
  ov.querySelector('.cfmcancel').onclick=close;
  ov.querySelector('.cfmok').onclick=function(){close();onYes();};
  ov.onclick=function(e){if(e.target===ov)close();};}
function toggleVMenu(){var m=document.getElementById('vmenu');if(!m)return;m.classList.toggle('on');if(m.classList.contains('on'))syncVMenu();}
function syncVMenu(){var m=document.getElementById('vmenu');if(!m)return;
  var g=m.querySelector('[data-v="groupfold"]'); if(g)g.classList.toggle('on',groupByFolder);
  var t=m.querySelector('[data-v="foltags"]'); if(t)t.classList.toggle('on',showFolderTags);
  Array.prototype.forEach.call(m.querySelectorAll('[data-fn]'),function(b){b.classList.toggle('on',b.dataset.fn===fnMode);});
  Array.prototype.forEach.call(m.querySelectorAll('[data-vm]'),function(b){b.classList.toggle('on',b.dataset.vm===viewMode);});
  var dens=document.body.classList.contains('dense')?'compact':'cozy';
  Array.prototype.forEach.call(m.querySelectorAll('[data-dens]'),function(b){b.classList.toggle('on',b.dataset.dens===dens);});
  var vmf=m.querySelector('.vmf'); if(vmf)vmf.style.display=multiFolder()?'block':'none';
  var fn=document.getElementById('fnsection'); if(fn)fn.style.display=(viewMode==='grid'?'':'none');}
const DORDER={'keep':1,'unsure':2,'':3,'undecided':3,'trash':4};
function setSort(k){if(sortKey===k){sortDir=-sortDir;}else{sortKey=k;sortDir=1;}render();}
function setDecFilter(k){decFilter=k;foc=-1;buildChips();render();}
function sortItems(arr){var a=arr.slice();a.sort(function(x,y){var r=0;
  if(sortKey==='name')r=(x.name||'').localeCompare(y.name||'');
  else if(sortKey==='date')r=((x.mtime||0)-(y.mtime||0));
  else if(sortKey==='size')r=((x.size||0)-(y.size||0));
  else if(sortKey==='folder')r=((x.folder||'').localeCompare(y.folder||''))||((x.name||'').localeCompare(y.name||''));
  else if(sortKey==='type')r=((x.category||'').localeCompare(y.category||''))||((x.name||'').localeCompare(y.name||''));
  else if(sortKey==='ext')r=((typeof fileExt==='function'?fileExt(x.name):'')).localeCompare(typeof fileExt==='function'?fileExt(y.name):'')||((x.name||'').localeCompare(y.name||''));
  else if(sortKey==='pages')r=(((pagesCache[x.path]||[]).length||x.pages||0)-((pagesCache[y.path]||[]).length||y.pages||0))||((x.name||'').localeCompare(y.name||''));
  else if(sortKey==='dims')r=(x.dims||'').localeCompare(y.dims||'')||((x.name||'').localeCompare(y.name||''));
  else if(sortKey==='taken')r=(((x.exif&&x.exif.date)||'')+'').localeCompare(((y.exif&&y.exif.date)||'')+'');
  else if(sortKey==='decision')r=((DORDER[dec[x.name]||'']||3)-(DORDER[dec[y.name]||'']||3));
  return r*sortDir;});return a;}
function setSlideBtn(){var b=document.getElementById('slidebtn');if(b){b.textContent=slideTimer?'⏸':'▷';b.classList.toggle('on',!!slideTimer);b.title=slideTimer?'Stop slideshow':'Start slideshow (auto-advance)';}}
// Slideshow: open the viewer and auto-advance every 2.6s, LOOPING back to the start at the end.
function toggleSlideshow(){if(slideTimer){clearInterval(slideTimer);slideTimer=null;setSlideBtn();return;}
  if(!order.length)return; if(!lb.classList.contains('on'))openLB(foc>=0?foc:order[0]);
  slideTimer=setInterval(function(){var i=order.indexOf(lbId);if(i<0){clearInterval(slideTimer);slideTimer=null;setSlideBtn();return;}
    if(i>=order.length-1)lbJump(0);else lbNav(1);},2600);
  setSlideBtn();}
const grid=document.getElementById('grid');
const GAP=11;
function debounce(fn,ms){var t;return function(){clearTimeout(t);t=setTimeout(fn,ms);};}
// JS masonry: each tile spans as many 1px grid rows as its content is tall, so
// photos keep their true shape (no crop) and pack tightly. Batched read→write.
function masonry(force){
  // Measure only NEW tiles by default (the grid renders in chunks); pass force=true
  // on resize / size-slider changes to recompute every tile's span. Batched read
  // then write so the browser lays out once, not once per tile.
  var tiles=grid.querySelectorAll(force?'.tile':'.tile:not([data-mas])'); var n=tiles.length; if(!n)return;
  var hs=new Array(n);
  for(var i=0;i<n;i++){var inr=tiles[i].firstElementChild; hs[i]=inr?inr.getBoundingClientRect().height:0;}
  for(var j=0;j<n;j++){tiles[j].style.gridRowEnd='span '+(Math.ceil(hs[j])+GAP); tiles[j].setAttribute('data-mas','1');}
}
// ── progressive grid render ──────────────────────────────────────────────────
// Build tiles in chunks so a folder of thousands paints instantly and stays
// responsive. The flat grid fills as you scroll via an IntersectionObserver on a
// bottom sentinel; keyboard nav into a not-yet-rendered tile renders up to it.
var CHUNK=240, rCursor=0, rVis=[], gio=null;
function renderChunk(){
  var sent=document.getElementById('gsent'); if(!sent)return; var guard=0;
  while(rCursor<rVis.length && guard<120){
    var end=Math.min(rCursor+CHUNK, rVis.length), frag=document.createDocumentFragment();
    for(var i=rCursor;i<end;i++)frag.appendChild(tileEl(rVis[i]));
    grid.insertBefore(frag, sent); rCursor=end; masonry(); guard++;
    if(grid.scrollHeight > grid.clientHeight+1200) break;   // filled past the fold; IO resumes on scroll
  }
  if(rCursor>=rVis.length && gio){gio.disconnect(); gio=null;}
}
function ensureRendered(id){var g=0; while(rCursor<rVis.length && !tileFor(id) && g<600){renderChunk(); g++;} return tileFor(id);}
function byId(id){for(var i=0;i<items.length;i++)if(items[i].id===id)return items[i];return null;}
function esc(s){return (s||"").replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function save(){localStorage.setItem(KEY,JSON.stringify(dec));}
function cats(){var c={};items.forEach(function(it){c[it.category]=(c[it.category]||0)+1});return c;}
function visible(){return items.filter(function(it){
  // A To-do pass narrows the library to the documents you put in it, so the queue IS the view and
  // there is no way to lose your place in a folder of four hundred (spec 09 Part 2, "To-do").
  if(TODO && TODO.set[it.id]!==1)return false;
  if(it.preview===false && !showNoPrev)return false;   // unrecognized / no-preview files hidden by default
  var catOk=filter==="All"?true:(filter==="Bursts"?!!it.cluster:it.category===filter);
  if(!catOk)return false;
  if(decFilter==='all')return true;
  if(decFilter==='undecided')return !dec[it.name];
  return (dec[it.name]||'')===decFilter;
});}
function nBursts(){var s={};items.forEach(function(it){if(it.cluster)s[it.cluster]=1;});return Object.keys(s).length;}
function buildChips(){
  var c=cats(); var box=document.getElementById('chips'); var cs=['All'].concat(Object.keys(c).sort());
  var html=cs.map(function(k){var n=k==='All'?items.length:c[k];return '<span class="chip'+(k===filter?' on':'')+'" data-c="'+esc(k)+'">'+esc(k)+' '+n.toLocaleString()+'</span>';}).join('');
  var nb=nBursts(); if(nb>0){html+='<span class="chip'+("Bursts"===filter?' on':'')+'" data-c="Bursts" title="Near-duplicate and burst groups, shown side by side for comparison">⧉ Bursts '+nb+'</span>';}
  var np=items.filter(function(it){return it.preview===false;}).length;
  if(np>0){html+='<span class="chip'+(showNoPrev?' on':'')+'" data-np="1" title="Files with no preview image are hidden by default; click to show them as plain tiles">⊘ '+np+' no preview</span>';}
  box.innerHTML=html;
  Array.prototype.forEach.call(box.querySelectorAll('.chip'),function(ch){ch.onclick=function(){if(ch.dataset.np){showNoPrev=!showNoPrev;buildChips();render();return;}filter=ch.dataset.c;foc=-1;buildChips();render();};});
}
function tileEl(it){
  var el=document.createElement('div');
  var isDoc=/^(PDFs|Decks|Documents|Spreadsheets)$/.test(it.category||'');
  el.className='tile'+(isDoc?' docu':''); el.dataset.id=it.id; if(it.cluster)el.dataset.cluster=it.cluster;
  // Photos reserve their TRUE aspect box (from dims) so the full image shows at its real shape and the
  // masonry span is right before load. Documents instead use a uniform letter-portrait box (.tile.docu
  // CSS) so a folder of PDFs lays out even rather than ragged.
  var ar=''; if(it.dims && !isDoc){var wh=it.dims.split('x'); if(wh.length===2&&+wh[0]>0&&+wh[1]>0)ar=' style="aspect-ratio:'+(+wh[0])+'/'+(+wh[1])+'"';}
  el.innerHTML='<div class="tinner">'
    +(it.thumb?'<img loading="lazy"'+ar+' src="'+it.thumb+'">':'<div class="noimg2">no preview</div>')
    +(it.video?'<div class="vbadge">▶</div>':'')
    +'<div class="cap"><span class="catb '+(it.category||'').toLowerCase()+'">'+esc(it.category||'')+'</span>'
      +((showFolderTags&&multiFolder()&&it.folder)?'<span class="foldb" title="'+esc(it.folder)+'">📁 '+esc(it.folder)+'</span>':'')
      +(it.dims?'<span class="dim">'+it.dims+'</span>':'')
      +(fnMode!=='off'?'<span class="fn">'+esc(fnDisplay(it.name))+'</span>':'')+'</div></div>';
  el.onclick=function(){setFoc(it.id);openLB(it.id);};
  el.oncontextmenu=function(e){e.preventDefault();setFoc(it.id);showCtx(e.clientX,e.clientY,it);};
  return el;
}
function render(){
  if(viewMode==='list'){renderList();return;}
  if(viewMode==='filmstrip'){renderFilmstrip();return;}
  if(viewMode==='column'){renderColumn();return;}
  grid.classList.remove('bursts','listmode','fsmode','colmode');
  if(gio){gio.disconnect(); gio=null;}
  if(filter==='Bursts'){renderBursts();return;}
  if(groupByFolder && multiFolder()){renderByFolder();return;}
  var vis=visible(); if(sortKey!=='none')vis=sortItems(vis); order=vis.map(function(it){return it.id;});
  rVis=vis; rCursor=0;
  grid.innerHTML='<div id="gsent" style="grid-column:1/-1;height:1px;pointer-events:none"></div>';
  gio=new IntersectionObserver(function(es){if(es[0].isIntersecting)renderChunk();},{root:grid,rootMargin:'900px 0px'});
  gio.observe(document.getElementById('gsent'));
  renderChunk(); tally();
}
function folderGroups(){var g={};visible().forEach(function(it){var f=it.folder||'(root)';(g[f]=g[f]||[]).push(it);});
  return Object.keys(g).sort().map(function(k){return {f:k,ms:(sortKey!=='none'?sortItems(g[k]):g[k])};});}
function renderByFolder(){
  grid.classList.add('bursts'); grid.innerHTML=""; order=[];
  var groups=folderGroups(); var frag=document.createDocumentFragment();
  groups.forEach(function(grp){
    grp.ms.forEach(function(it){order.push(it.id);});
    var sec=document.createElement('div'); sec.className='burstsec foldersec'; sec.dataset.folder=grp.f;
    var hd=document.createElement('div'); hd.className='bursthd';
    hd.innerHTML='<span class="bn">📁 '+esc(grp.f)+'</span><span class="bsug">'+grp.ms.length+' file'+(grp.ms.length===1?'':'s')+'</span>';
    sec.appendChild(hd);
    var strip=document.createElement('div'); strip.className='burststrip';
    grp.ms.forEach(function(it){strip.appendChild(tileEl(it));});
    sec.appendChild(strip); frag.appendChild(sec);
  });
  grid.appendChild(frag); tally(); masonry();
}
// ── List view: a sortable row per file (name/folder/type/size/date) + a live preview pane.
// The biggest gap for DOCUMENT folders, where named files aren't distinct as thumbnails.
// Show the ACTIVE layout (icon + name) right on the toolbar button, so it's always clear which
// view you're in — the button is the current selection, not a generic "Views" label.
function syncViewBtn(){var b=document.getElementById('viewbtn');if(!b)return;
  var m={grid:'▦ Grid',list:'☰ List',column:'◫ Columns',filmstrip:'▤ Gallery'};
  b.innerHTML=(m[viewMode]||'View')+' ▾';}
function setViewMode(m){
  try{location.href='hvniz://setview?mode='+encodeURIComponent(m);}catch(e){}   // persist the layout in config.json (survives the file:// reload where localStorage does not)
  function apply(){if(typeof pvFocusToggle==='function')pvFocusToggle(false);   // a view change leaves focus/Zen mode
    viewMode=m;try{localStorage.setItem('vt-viewmode',m);}catch(e){}
    Array.prototype.forEach.call(document.querySelectorAll('[data-vm]'),function(b){b.classList.toggle('on',b.dataset.vm===m);});
    syncViewBtn();render();}
  // cutting-edge cross-fade between layouts via the View Transitions API (Safari 18+/Chrome);
  // a graceful no-op everywhere else, and skipped for reduced-motion users.
  if(document.startViewTransition&&!(window.matchMedia&&matchMedia('(prefers-reduced-motion:reduce)').matches)){
    try{document.startViewTransition(apply);}catch(e){apply();}
  }else{apply();}}
window.setViewMode=setViewMode;
function fmtBytes(b){return b>1048576?(b/1048576).toFixed(1)+' MB':(b>1024?Math.round(b/1024)+' KB':(b||0)+' B');}
// Date Modified narrows with its column instead of ellipsizing (Jason, 2026-08-18: "Aug 18, 2026"
// -> "8/18/2026" -> "8/18/26"). The Date column is user-resizable by dragging its edge, and the
// old formatter always emitted the long form, so narrowing it just clipped the year - the one
// part of a date you cannot guess.
//
// The ladder is a LOCAL DETERMINISTIC TABLE on purpose. Jason's design for this is to let a model
// work the width-to-format ladder out during development and then ship the distilled table, so a
// list render never waits on (or pays for) a cloud call. He specified these three rungs himself,
// so the table below IS that distillation; the thresholds are the measured widths at which each
// form stops fitting at the list's 12px system font, plus the cell's 10px padding.
//
// Thresholds are the WIDTH AT WHICH A FORM STILL FITS, so they are checked widest-first. The
// narrowest rung has no lower bound: below it there is nothing left to shorten, and a clipped
// "8/18/26" still beats a clipped "Aug 18, 2026" because the day and month survive.
var DATE_RUNGS = [
  { min: 118, fmt: { year: 'numeric', month: 'short', day: 'numeric' } },   // Aug 18, 2026
  { min: 92,  fmt: { year: 'numeric', month: 'numeric', day: 'numeric' } }, // 8/18/2026
  { min: 0,   fmt: { year: '2-digit', month: 'numeric', day: 'numeric' } }  // 8/18/26
];
function dateRungIndex(px){for(var i=0;i<DATE_RUNGS.length;i++){if(px>=DATE_RUNGS[i].min)return i;}return DATE_RUNGS.length-1;}
function dateColWidth(){var w=colCfg&&colCfg.widths?colCfg.widths['date']:0;if(w)return w;var d=COLSPECS&&COLSPECS.date?parseFloat(COLSPECS.date.w):124;return isNaN(d)?124:d;}
function fmtMTime(t,px){try{
  var w=(typeof px==='number'&&isFinite(px))?px:dateColWidth();
  return new Date(t*1000).toLocaleDateString(undefined,DATE_RUNGS[dateRungIndex(w)].fmt);
}catch(e){return '';}}
let listSplit=(function(){var v=parseFloat(localStorage.getItem('vt-listsplit'));return (v>=25&&v<=80)?v:56;})();
var collapsedFolders={};   // folder name -> true when its list group is collapsed (session)
// Customizable columns: header + rows are driven by COLSPECS + colCfg (defined in the
// COLUMNS+PREVIEW block below). Name is locked first and can't be hidden (Finder model).
function lhdrHTML(){
  function ar(k){return sortKey===k?(sortDir>0?' ▲':' ▼'):'';}
  var cells=colVisible().map(function(id){var c=COLSPECS[id];
    return '<span class="lcell lc-'+id+(c.num?' num':'')+'" data-col="'+id+'" data-k="'+c.sort+'" draggable="'+(c.lock?'false':'true')+'" title="'+esc(c.label)+(c.lock?'':' — drag to reorder, drag edge to resize')+'">'+esc(c.label)+ar(c.sort)
    +'<span class="lcolgrip" data-grip="'+id+'"></span></span>';}).join('');
  return '<div class="lrow lhdr">'+cells
    +'<span class="lcolcfg pvb" title="Choose columns (or right-click a header)" onclick="openColMenu(event)" style="position:absolute;right:5px;top:4px">⋯</span></div>';
}
// List rows always show the FULL name (truncated by column width via ellipsis); the Filenames
// Full/Short/Off control is a grid-caption setting, so it is hidden in list view.
function lrowHTML(it,grp){
  var cells=colVisible().map(function(id){var c=COLSPECS[id];var v=c.val(it);
    var t=(id==='name'||id==='folder'||id==='path')?(' title="'+esc(v)+'"'):'';
    return '<span class="lcell lc-'+id+(c.num?' num':'')+'"'+t+'>'+esc(v)+'</span>';}).join('');
  return '<div class="lrow" data-id="'+it.id+'"'+(grp!=null?' data-grp="'+esc(grp)+'"':'')+'>'+cells+'</div>';
}
function wireListDivider(divider,tbl,prev){
  divider.onmousedown=function(e){e.preventDefault();var rect=grid.getBoundingClientRect();
    function mm(ev){var pct=((ev.clientX-rect.left)/rect.width)*100;pct=Math.max(25,Math.min(80,pct));
      listSplit=pct;tbl.style.flex='1 1 '+pct+'%';prev.style.flex='1 1 '+(100-pct)+'%';}
    function mu(){document.removeEventListener('mousemove',mm,true);document.removeEventListener('mouseup',mu,true);try{localStorage.setItem('vt-listsplit',String(Math.round(listSplit)));}catch(e){}}
    document.addEventListener('mousemove',mm,true);document.addEventListener('mouseup',mu,true);};}
function renderList(){
  if(gio){gio.disconnect();gio=null;}
  grid.className='listmode'; grid.innerHTML='';
  var vis=visible(); if(sortKey!=='none')vis=sortItems(vis);
  var grouped = groupByFolder && multiFolder();
  var tbl=document.createElement('div'); tbl.id='listtbl';
  var html=lhdrHTML();
  if(grouped){
    order=[]; folderGroups().forEach(function(grp){var col=!!collapsedFolders[grp.f];
      html+='<div class="lgrp'+(col?' collapsed':'')+'" data-folder="'+esc(grp.f)+'"><span class="lgcaret">▸</span><span class="lgname">'+esc(grp.f)+'</span><span class="lgct">'+grp.ms.length+'</span></div>';
      grp.ms.forEach(function(it){order.push(it.id);html+=lrowHTML(it,grp.f);});});
  } else { order=vis.map(function(it){return it.id;}); vis.forEach(function(it){html+=lrowHTML(it);}); }
  tbl.innerHTML=html; tbl.style.setProperty('--lcols',colTemplate());
  var divider=document.createElement('div'); divider.id='listdiv';
  var prev=document.createElement('div'); prev.id='listprev';
  prev.innerHTML='<div class="lpempty">Select a file to preview &nbsp;·&nbsp; double-click or Enter opens it in the Reader</div>';
  tbl.style.flex='1 1 '+listSplit+'%'; prev.style.flex='1 1 '+(100-listSplit)+'%';
  grid.appendChild(tbl); grid.appendChild(divider); grid.appendChild(prev);
  wireListHeader(tbl);
  Array.prototype.forEach.call(tbl.querySelectorAll('.lgrp'),function(g){g.onclick=function(){var f=g.dataset.folder;collapsedFolders[f]=!collapsedFolders[f];g.classList.toggle('collapsed');
    Array.prototype.forEach.call(tbl.querySelectorAll('.lrow[data-grp]'),function(r){if(r.dataset.grp===f)r.style.display=collapsedFolders[f]?'none':'';});};});
  Array.prototype.forEach.call(tbl.querySelectorAll('.lrow[data-id]'),function(r){var id=parseInt(r.dataset.id);
    r.onclick=function(e){if(window.__msel){if(e.metaKey||e.ctrlKey){window.__msel.toggle(id);}else if(e.shiftKey){window.__msel.rangeTo(id);}else{window.__msel.selectOnly(id);}}listSelect(id);}; r.ondblclick=function(){openItem(id);};
    r.oncontextmenu=function(e){e.preventDefault();e.stopPropagation();listSelect(id);if(window.__fileCtx)window.__fileCtx(e.clientX,e.clientY,byId(id));};   // same canonical file-actions menu as the grid
    if(r.dataset.grp&&collapsedFolders[r.dataset.grp])r.style.display='none';});
  wireListDivider(divider,tbl,prev);
  if(order.length)listSelect(order.indexOf(listSel)>=0?listSel:order[0]);
  tally();
}
function listSelect(id){listSel=id;foc=id;var it=byId(id);
  Array.prototype.forEach.call(document.querySelectorAll('#listtbl .lrow.sel'),function(e){e.classList.remove('sel');});
  var row=document.querySelector('#listtbl .lrow[data-id="'+id+'"]'); if(row){row.classList.add('sel');row.scrollIntoView({block:'nearest'});}
  pvShow('listprev',it);
  gridPath();
}
function listStep(d){if(!order.length)return;var i=order.indexOf(listSel);i=(i<0?0:i+d);if(i<0)i=0;if(i>=order.length)i=order.length-1;listSelect(order[i]);}
// ── Filmstrip (Gallery) view: one large preview with a horizontal thumbnail strip below, like
// Finder's Gallery. Click or ←/→ to move; click the big image or Enter to open it full / in Reader.
function renderFilmstrip(){
  if(gio){gio.disconnect();gio=null;}
  grid.className='fsmode'; grid.innerHTML='';
  var vis=visible(); if(sortKey!=='none')vis=sortItems(vis); order=vis.map(function(it){return it.id;});
  var main=document.createElement('div'); main.id='fsmain';
  var strip=document.createElement('div'); strip.id='fsstrip';
  var html='';
  vis.forEach(function(it){html+='<div class="fsth" data-id="'+it.id+'" title="'+esc(it.name)+'">'
    +(it.thumb?'<img loading="lazy" src="'+it.thumb+'">':'<div class="fsnoimg">'+esc(it.category||'')+'</div>')+'</div>';});
  strip.innerHTML=html;
  grid.appendChild(main); grid.appendChild(strip);
  Array.prototype.forEach.call(strip.querySelectorAll('.fsth'),function(t){var id=+t.dataset.id;
    t.onclick=function(e){if(window.__msel){if(e.metaKey||e.ctrlKey){window.__msel.toggle(id);}else if(e.shiftKey){window.__msel.rangeTo(id);}else{window.__msel.selectOnly(id);}}fsSelect(id);}; t.ondblclick=function(){openLB(id);};});
  var sel=(order.indexOf(listSel)>=0?listSel:(order.length?order[0]:-1));
  if(sel>=0)fsSelect(sel); else main.innerHTML='<div class="fsempty">No files</div>';
  tally();
}
function fsSelect(id){listSel=id;foc=id;var it=byId(id);
  Array.prototype.forEach.call(document.querySelectorAll('#fsstrip .fsth.sel'),function(e){e.classList.remove('sel');});
  var t=document.querySelector('#fsstrip .fsth[data-id="'+id+'"]'); if(t){t.classList.add('sel');t.scrollIntoView({inline:'center',block:'nearest'});}
  pvShow('fsmain',it);
  gridPath();
}
function fsStep(d){if(!order.length)return;var i=order.indexOf(listSel);i=(i<0?0:i+d);if(i<0)i=0;if(i>=order.length)i=order.length-1;fsSelect(order[i]);}
// ── Column (Finder Miller-columns) view: Folders -> Files -> Preview. With one folder it collapses
// to Files -> Preview. Click a folder to drill in, a file to preview; double-click / Enter opens it.
var colFolder=null;
function renderColumn(){
  if(gio){gio.disconnect();gio=null;}
  grid.className='colmode'; grid.innerHTML='';
  var vis=visible(); if(sortKey!=='none')vis=sortItems(vis);
  var multi=multiFolder();
  var groups=multi?folderGroups():[{f:null,ms:vis}];
  if(multi&&(colFolder==null||!groups.some(function(g){return g.f===colFolder;})))colFolder=groups[0].f;
  if(multi){
    var col1=document.createElement('div'); col1.className='colpane colf';
    var h1=''; groups.forEach(function(g){h1+='<div class="colrow colfrow'+(g.f===colFolder?' sel':'')+'" data-folder="'+esc(g.f)+'"><span class="cic">📁</span><span class="cnm" title="'+esc(g.f)+'">'+esc(g.f)+'</span><span class="cct">'+g.ms.length+'</span><span class="cchev">›</span></div>';});
    col1.innerHTML=h1; grid.appendChild(col1);
    Array.prototype.forEach.call(col1.querySelectorAll('.colfrow'),function(r){r.onclick=function(){colFolder=r.dataset.folder;renderColumn();};});
  }
  var cur=groups.filter(function(g){return g.f===colFolder;})[0]||groups[0];
  var files=cur?cur.ms:vis; order=files.map(function(it){return it.id;});
  var col2=document.createElement('div'); col2.className='colpane colfiles';
  var h2=''; files.forEach(function(it){h2+='<div class="colrow colfilerow" data-id="'+it.id+'"><span class="cic">'+(it.thumb?'<img src="'+it.thumb+'">':'▤')+'</span><span class="cnm" title="'+esc(it.name)+'">'+esc(it.name)+'</span><span class="cchev">›</span></div>';});
  col2.innerHTML=h2; grid.appendChild(col2);
  var col3=document.createElement('div'); col3.className='colpane colprev'; col3.id='colprev';
  col3.innerHTML='<div class="lpempty">Select a file to preview</div>'; grid.appendChild(col3);
  Array.prototype.forEach.call(col2.querySelectorAll('.colfilerow'),function(r){var id=parseInt(r.dataset.id);
    r.onclick=function(e){if(window.__msel){if(e.metaKey||e.ctrlKey){window.__msel.toggle(id);}else if(e.shiftKey){window.__msel.rangeTo(id);}else{window.__msel.selectOnly(id);}}colSelect(id);}; r.ondblclick=function(){openLB(id);};});
  var sel=(order.indexOf(listSel)>=0?listSel:(order.length?order[0]:-1));
  if(sel>=0)colSelect(sel);
  tally();
}
function colSelect(id){listSel=id;foc=id;var it=byId(id);
  Array.prototype.forEach.call(document.querySelectorAll('.colfilerow.sel'),function(e){e.classList.remove('sel');});
  var r=document.querySelector('.colfilerow[data-id="'+id+'"]'); if(r){r.classList.add('sel');r.scrollIntoView({block:'nearest'});}
  pvShow('colprev',it);
  gridPath();
}
function colStep(d){if(!order.length)return;var i=order.indexOf(listSel);i=(i<0?0:i+d);if(i<0)i=0;if(i>=order.length)i=order.length-1;colSelect(order[i]);}
function burstGroups(){
  var g={}; items.forEach(function(it){if(it.cluster){(g[it.cluster]=g[it.cluster]||[]).push(it);}});
  return Object.keys(g).map(function(k){return {cl:k,ms:g[k]};}).sort(function(a,b){return b.ms.length-a.ms.length;});
}
function renderBursts(){
  grid.classList.add('bursts'); grid.innerHTML=""; order=[];
  var groups=burstGroups(); var frag=document.createDocumentFragment();
  groups.forEach(function(grp){
    grp.ms.forEach(function(it){order.push(it.id);});
    var sec=document.createElement('div'); sec.className='burstsec'; sec.dataset.cluster=grp.cl;
    var hd=document.createElement('div'); hd.className='bursthd';
    hd.innerHTML='<span class="bn">Burst of '+grp.ms.length+'</span><span class="bsug">near-duplicate group</span>';
    sec.appendChild(hd);
    var strip=document.createElement('div'); strip.className='burststrip';
    grp.ms.forEach(function(it){strip.appendChild(tileEl(it));});
    sec.appendChild(strip); frag.appendChild(sec);
  });
  grid.appendChild(frag);
  tally(); masonry();
}
function tileFor(id){return grid.querySelector('.tile[data-id="'+id+'"]');}
function nameFor(id){for(var i=0;i<items.length;i++)if(items[i].id===id)return items[i].name;return null;}

// ── To-do: the keep-or-cut pass (spec 09 Part 2) ────────────────────────────
// "A per-document state of unreviewed / keep / cut, persisted with the working set, a count in
// the header, and K / X / U as the keys. It is the Keep step from the site's own How It Works
// section, which we describe publicly and do not yet give a home."
//
// It does NOT invent a second decision store. `dec[name]` already holds keep / trash / unsure,
// already persists, already drives the tile classes and the decision sort, and the Sort surface
// already writes it. To-do is the missing FRONT DOOR to that store: it takes a selection, walks it
// one document at a time, and keeps a count. A parallel store would have meant two truths about
// the same document, which is worse than the gap it filled.
//
// The queue carries its own cursor rather than borrowing the view's focus, because focus means
// four different things across grid / list / column / filmstrip and a review pass must behave
// identically in all of them.
var TODO=null;   // { ids:[...], set:{id:1}, i:0 }
function todoStart(ids){
  ids=(ids||[]).filter(function(id){return byId(id);});
  if(!ids.length){toast('Select documents to review');return;}
  var set={};ids.forEach(function(id){set[id]=1;});
  TODO={ids:ids,set:set,i:0};
  document.removeEventListener('keydown',todoEscape,true);   // never stack two on a restart
  document.addEventListener('keydown',todoEscape,true);
  // Start on the first UNREVIEWED document: resuming a half-done pass at the top would make you
  // walk past your own decisions to find where you stopped.
  for(var j=0;j<ids.length;j++){if(!dec[nameFor(ids[j])]){TODO.i=j;break;}}
  render();todoSync();
}
// Escape has to be taken in the CAPTURE phase, and that was measured rather than assumed. Driving
// the real keys in WebKit showed the pass still active after Escape while a direct todoEnd() call
// cleaned up perfectly, which locates the fault precisely: the key never reached the bubble-phase
// handler, because something upstream consumes Escape first. Ordering against that consumer would
// be guesswork; a capture listener that exists only while a pass is running is deterministic.
// It is scoped hard: any open overlay owns Escape first, so this never steals it from a dialog.
function todoEscape(e){
  if(e.key!=='Escape'||!TODO)return;
  if(document.querySelector('.cfm, #search.on, #lb.on, #fsaddmenu'))return;   // an overlay outranks the pass
  if(/^(input|textarea|select)$/i.test((e.target&&e.target.tagName)||''))return;
  todoEnd();e.preventDefault();e.stopPropagation();
}
function todoEnd(){
  TODO=null;document.removeEventListener('keydown',todoEscape,true);
  var b=document.getElementById('todobar');if(b)b.remove();render();}
function todoReviewed(){return TODO?TODO.ids.filter(function(id){return !!dec[nameFor(id)];}).length:0;}
// Assignment, not the toggle `set()` does: in a queue, pressing K twice must leave it kept.
function todoMark(d){
  if(!TODO)return;var id=TODO.ids[TODO.i];if(id==null)return;var nm=nameFor(id);if(!nm)return;
  if(d)dec[nm]=d;else delete dec[nm];
  save();
  var el=(typeof tileFor==='function')?tileFor(id):null;
  if(el){el.classList.remove('keep','trash','unsure');if(dec[nm])el.classList.add(dec[nm]);}
  if(TODO.i<TODO.ids.length-1)TODO.i++;
  todoSync();
}
function todoGo(n){if(!TODO)return;TODO.i=Math.max(0,Math.min(TODO.ids.length-1,TODO.i+n));todoSync();}
function todoSync(){
  if(!TODO)return;
  var b=document.getElementById('todobar');
  if(!b){b=document.createElement('div');b.id='todobar';
    var ws=document.getElementById('workspace');
    if(ws&&ws.parentNode)ws.parentNode.insertBefore(b,ws);else document.body.appendChild(b);}
  var cur=byId(TODO.ids[TODO.i]),n=TODO.ids.length,r=todoReviewed();
  var d=cur?(dec[cur.name]||''):'';
  b.innerHTML='<span class="tdct">'+r+' of '+n+' reviewed</span>'
    +'<span class="tdnm" title="'+esc(cur?cur.path||cur.name:'')+'">'+esc(cur?cur.name:'—')+'</span>'
    +'<span class="tdpos">'+(TODO.i+1)+' / '+n+'</span>'
    +'<button class="tdb tdk'+(d==='keep'?' on':'')+'" data-t="keep">Keep <b>K</b></button>'
    +'<button class="tdb tdx'+(d==='trash'?' on':'')+'" data-t="trash">Cut <b>X</b></button>'
    +'<button class="tdb tdu'+(d===''?' on':'')+'" data-t="">Unreviewed <b>U</b></button>'
    +'<span class="tdsp"></span>'
    +'<button class="tdb" data-t="__prev" title="Previous (←)">‹</button>'
    +'<button class="tdb" data-t="__next" title="Next (→)">›</button>'
    +'<button class="tdb tdd" data-t="__done">Done · Esc</button>';
  Array.prototype.forEach.call(b.querySelectorAll('[data-t]'),function(x){
    x.onclick=function(){var t=x.dataset.t;
      if(t==='__done'){todoEnd();return;}
      if(t==='__prev'){todoGo(-1);return;}
      if(t==='__next'){todoGo(1);return;}
      todoMark(t);};});
  // The cursor is also the preview: reviewing without seeing the document is not reviewing.
  if(cur&&typeof setFoc==='function'){try{setFoc(cur.id);}catch(e){}}
}
window.todoStart=todoStart;window.todoEnd=todoEnd;
window.__todoActive=function(){return !!TODO;};   // so a gate can tell "handler never fired" from "todoEnd is broken"
function set(id,d){var nm=nameFor(id); if(dec[nm]===d){delete dec[nm];}else{dec[nm]=d;} save();
  var el=tileFor(id); if(el){el.classList.remove('keep','trash','unsure'); if(dec[nm])el.classList.add(dec[nm]);} tally();}
function setFoc(id){foc=id; Array.prototype.forEach.call(grid.querySelectorAll('.tile.foc'),function(e){e.classList.remove('foc');});
  var el=tileFor(id)||ensureRendered(id); if(el){el.classList.add('foc'); el.scrollIntoView({block:'nearest'});} gridPath();}
function move(delta){ if(!order.length)return; var i=order.indexOf(foc); i=i<0?0:i+delta;
  if(i<0)i=0; if(i>=order.length)i=order.length-1; setFoc(order[i]); }
// Down/up move to the tile visually BELOW/ABOVE the focused one (by geometry), so it works
// regardless of column count or folder grouping — not a fixed index jump (Jason 2026-06-18).
function moveRow(delta){
  if(foc<0){if(order.length)setFoc(order[0]);return;}
  var cur=tileFor(foc); if(!cur){move(delta>0?1:-1);return;}
  var cr=cur.getBoundingClientRect(), cx=cr.left+cr.width/2, best=-1, bs=1e15;
  for(var i=0;i<order.length;i++){ if(order[i]===foc)continue; var t=tileFor(order[i]); if(!t)continue;
    var r=t.getBoundingClientRect();
    if(delta>0){ if(r.top<=cr.top+4)continue; } else { if(r.top>=cr.top-4)continue; }
    var dx=Math.abs(r.left+r.width/2-cx), dy=Math.abs(r.top-cr.top), sc=dy*4+dx;
    if(sc<bs){bs=sc;best=order[i];} }
  if(best>=0)setFoc(best); else move(delta>0?1:-1);
}
// Viewer: show how many files are in view (the triage Keep/Trash/Unsure counter was removed).
function tally(){var n=(typeof order!=='undefined'?order.length:items.length);
  var p=document.getElementById('pos'); if(p)p.textContent=n.toLocaleString()+' file'+(n===1?'':'s'); gridPath();}
function bulk(d){var tgt=visible().filter(function(it){return !dec[it.name];});if(!tgt.length)return;
  confirmDialog((d==='keep'?'Keep all visible?':'Trash all visible?'),
   'Mark <b>'+tgt.length+'</b> undecided item'+(tgt.length===1?'':'s')+' as <b>'+(d==='keep'?'Keep':'Trash')+'</b>. This only changes your decisions (you can undo it) and does <b>not</b> delete or move any files.',
   function(){tgt.forEach(function(it){dec[it.name]=d;});save();render();});}
function sortSuggested(){var tgt=visible().filter(function(it){return it.suggest==='trash'&&!dec[it.name];});if(!tgt.length)return;
  confirmDialog('Smart Select '+tgt.length+' suggested?','Mark <b>'+tgt.length+'</b> suggested item'+(tgt.length===1?'':'s')+' (burst near-duplicates, and obvious noise in photo piles) as <b>Trash</b>. Reviews them as a selection you can undo; nothing is deleted until you confirm a cleanup.',
   function(){tgt.forEach(function(it){dec[it.name]='trash';});save();render();});}
function nSuggested(){return visible().filter(function(it){return it.suggest==='trash'&&!dec[it.name];}).length;}
function exportD(){var out={task:DATA.task,mode:"single",exported:new Date().toISOString(),total:items.length,decisions:{}};
  items.forEach(function(it){if(dec[it.name])out.decisions[it.name]=dec[it.name];});
  var b=new Blob([JSON.stringify(out,null,2)],{type:"application/json"});var a=document.createElement('a');
  a.href=URL.createObjectURL(b);a.download="triage-decisions-"+DATA.task+".json";a.click();}
var SCHEMES=['light','ivory','white','slate','charcoal','dark'];
function toggleTheme(){location.href='hvniz://appearance?toggle';}   // the ◐ button = app-wide light/dark toggle
window.__setScheme=function(s){if(!s)return;document.documentElement.setAttribute('data-theme',s);try{localStorage.setItem('vtriage-theme',s)}catch(e){}};
window.addEventListener('message',function(e){if(e.data&&e.data.type==='jpdev-scheme')window.__setScheme(e.data.scheme);});
// Follow the OS light/dark (set via the app's Appearance pref / system) unless a scheme is pinned.
try{matchMedia('(prefers-color-scheme:dark)').addEventListener('change',function(e){if(!localStorage.getItem('vtriage-theme'))document.documentElement.setAttribute('data-theme',e.matches?'dark':'light');});}catch(_e){}
const lb=document.getElementById('lb');
function openLB(id,want){lbId=id;var it=byId(id);if(!it)return;
  lbWantPage=(want==null?0:want);   // fresh open (click) starts at page 1; ←/→ passes the kept page
  var pic=document.getElementById('lbpic'),vid=document.getElementById('lbvid');
  var isDoc=(it.category==='PDFs'||it.category==='Decks'||it.category==='Documents'||it.category==='Spreadsheets');
  if(it.video&&it.playable){pic.style.display='none';pic.removeAttribute('src');vid.style.display='';vid.src=it.full;}
  else{if(vid){try{vid.pause();}catch(e){}vid.removeAttribute('src');vid.style.display='none';}pic.style.display='';pic.src=it.full||it.thumb||"";}
  document.getElementById('lbnm').textContent=(fnMode==='off'?'':fnDisplay(it.name));
  // multi-page document paging: ↑/↓ flips pages once they render (lazy, per file); ←/→ keeps the page
  lbPagesArr=null; lbPage=0; lbRendering=false; lbResetZoom(); showLBLoad(false);
  if(!it.video){
    if(pagesCache[it.path]){lbPagesArr=pagesCache[it.path];lbPage=Math.min(lbWantPage,Math.max(0,lbPagesArr.length-1));showPages();reqPages(it.path,false);}
    // A non-PDF document has no rendered pages until LibreOffice has converted it, and that takes
    // seconds to MINUTES on a loaded machine (measured 2026-08-04). Until it lands, `it.full` for
    // any non-web-safe file is `it.thumb` — a QUICK LOOK THUMBNAIL, not the page. Measured on a
    // real .docx: the QL thumbnail is 316x512, aspect 0.617, against the true rendered page's
    // 1237x1600, aspect 0.773. So the first thing shown was a DIFFERENT SHAPE from the document,
    // with no page indicator (lbPageInd hides it while lbPagesArr is null) and no loader on a
    // fresh open — which is what a reader reports as "it's cutting off the bottom, there's no
    // footer, there's no page number". The loader now runs on EVERY uncached document open, not
    // only when paging, so the interim frame can never be mistaken for the page itself.
    else if(isDoc){ if(lbWantPage>0){pic.removeAttribute('src');}   // paging: don't flash page 1
      showLBLoad(true); lbRendering=true;
      reqPages(it.path,true); }
  }
  lbPageInd();
  var fold=document.getElementById('lbfold'); if(fold)fold.textContent=(showFolderTags&&it.folder)?('📁 '+it.folder):'';
  // full view mirrors the thumbnail details: folder + dimensions (images only, not page renders) + EXIF
  var ex=document.getElementById('lbex'); if(ex){var bits=[];if(it.video)bits.push('🎞 video');if(it.dims&&!isDoc)bits.push(esc(it.dims));if(it.exif&&it.exif.date)bits.push('📅 '+esc(it.exif.date));if(it.exif&&it.exif.gps)bits.push('<span class="gps">📍 '+esc(it.exif.gps)+'</span>');ex.innerHTML=bits.join(' &nbsp;·&nbsp; ');}
  var pe=document.getElementById('lbpath'); if(pe){pe.innerHTML=lbCrumbs(it.path||'');var sc=pe;sc.scrollLeft=sc.scrollWidth;}  // breadcrumb, scrolled to the file
  applyRot();lbStatus();lbApplyTheme();lb.classList.add('on');}
// Finder-style path breadcrumb: collapse the noisy home/CloudStorage prefix to a friendly root
// (e.g. "Dropbox"), then one clickable crumb per folder. A folder crumb opens that folder in
// Finder; the file crumb reveals the file. Built to read like modern Finder's path bar.
function lbCrumbs(path){
  if(!path)return '';
  var parts=path.split('/').filter(function(s){return s.length;}); var segs=[],acc='';
  for(var i=0;i<parts.length;i++){acc+='/'+parts[i];segs.push({label:parts[i],path:acc});}
  for(var k=0;k<segs.length;k++){ if(segs[k].label==='CloudStorage'&&k+1<segs.length){
    var root=segs[k+1]; segs=[{label:root.label.split('-')[0],path:root.path}].concat(segs.slice(k+2)); break; } }
  if(segs.length>1&&segs[0].label==='Users'){segs=[{label:'~',path:segs[1].path}].concat(segs.slice(2));}
  return segs.map(function(s,i){var isFile=(i===segs.length-1);
    var href=(isFile?'hvniz://reveal?path=':'hvniz://opendefault?path=')+encodeURIComponent(s.path);
    return '<span class="crumb'+(isFile?' file':'')+'" onclick="location.href=\''+href+'\'" title="'+(isFile?'Reveal in Finder':'Open this folder in Finder')+'">'+esc(s.label)+'</span>';
  }).join('<span class="csep">›</span>');}
// Grid-view path bar: the SAME Finder-style breadcrumb as the lightbox, pinned to the bottom of the
// grid. Shows the focused tile's path (or the first item's when nothing is focused) and updates as
// focus / filter / view changes, so folder navigation is available everywhere, not just full view.
function gridPath(){var el=document.getElementById('gridpath');if(!el)return;
  var id=(typeof foc!=='undefined'&&foc!=null&&foc>=0)?foc:((typeof order!=='undefined'&&order&&order.length)?order[0]:(items.length?items[0].id:-1));
  var it=byId(id); el.innerHTML=(it&&it.path)?lbCrumbs(it.path):''; try{el.scrollLeft=el.scrollWidth;}catch(e){}}
function showLBLoad(b){var el=document.getElementById('lbload');if(el)el.classList.toggle('on',!!b);}
// The page indicator doubles as the status line while pages render. A skeleton says
// "something is happening"; this says WHAT, which matters because converting a Word file
// is a LibreOffice run and can take minutes on a loaded machine (measured 2026-08-04:
// past 180 seconds for a ONE-PAGE .docx). Same element the failure messages use, so the
// reader's eye never has to move to find out how the wait ended.
// STATE, not a one-shot write. openLB calls lbPageInd() after this branch and lbPageInd hides
// #lbpg whenever lbPagesArr is null, so a direct write here was undone the same tick. A flag
// survives every later lbPageInd() call, which is the only version that actually holds.
var lbRendering=false;
function isDocCat(it){return !!(it&&(it.category==='PDFs'||it.category==='Decks'||it.category==='Documents'||it.category==='Spreadsheets'));}
// Render the current doc's pages (needCur) and/or GENTLY prefetch nearby files, fanning out from
// the one in view (next, prev, next+2 …) so flipping to the same page across files is instant after
// the first pass. Only uncached doc neighbors are requested; the app renders them one at a time.
function reqPages(curPath,needCur){
  var i=order.indexOf(lbId),pre=[];
  [i+1,i-1,i+2,i-2,i+3,i-3].forEach(function(j){if(j>=0&&j<order.length){var it2=byId(order[j]);if(it2&&it2.path&&!pagesCache[it2.path]&&isDocCat(it2))pre.push(it2.path);}});
  var primary=needCur?curPath:pre.shift();
  if(!primary)return;
  var q='hvniz://pages?path='+encodeURIComponent(primary);
  if(pre.length)q+='&prefetch='+encodeURIComponent(JSON.stringify(pre));
  location.href=q;
}
function closeLB(){if(slideTimer){clearInterval(slideTimer);slideTimer=null;setSlideBtn();}var vid=document.getElementById('lbvid');if(vid){try{vid.pause();}catch(e){}}lbResetZoom();lb.classList.remove('on');lbId=-1;}
function toggleFull(){if(lb.classList.contains('on')){closeLB();}else if(order.length){openLB(foc>=0?foc:order[0]);}}
// Image rotation: view-only, persisted per file in localStorage (non-destructive —
// never touches the original). 90° steps; the lightbox image is re-fitted when sideways.
var rots={};try{rots=JSON.parse(localStorage.getItem('vt-rots-'+DATA.task)||'{}')}catch(e){}
function lbResetZoom(){lbZoom=1;lbPanX=0;lbPanY=0;}
function lbIsPhoto(){var it=byId(lbId);return !!(it&&!it.video&&!isDocCat(it)&&lb.classList.contains('on'));}
function lbZoomBy(f){var nz=Math.min(6,Math.max(1,lbZoom*f));f=(lbZoom?nz/lbZoom:1);lbPanX=f*lbPanX;lbPanY=f*lbPanY;lbZoom=nz;if(lbZoom<=1.001)lbResetZoom();applyRot();}
// applyRot also carries photo zoom/pan: transform = pan, then zoom, then rotate; origin center.
function applyRot(){var it=byId(lbId);if(!it)return;var pic=document.getElementById('lbpic');if(!pic)return;
  var d=((rots[it.path]||0)%360+360)%360;
  pic.style.transformOrigin='center center';
  pic.style.transform='translate('+lbPanX+'px,'+lbPanY+'px) scale('+lbZoom+')'+(d?(' rotate('+d+'deg)'):'');
  if(d===90||d===270){pic.style.maxWidth='92vh';pic.style.maxHeight='94vw';}else{pic.style.maxWidth='';pic.style.maxHeight='';}
  pic.style.cursor=(lbZoom>1?'grab':'');}
function rotate(deg){if(!lb.classList.contains('on')){if(foc>=0)openLB(foc);else if(order.length)openLB(order[0]);else return;}
  var it=byId(lbId);if(!it||it.video)return;
  rots[it.path]=(((rots[it.path]||0)+(deg||90))%360+360)%360;
  try{localStorage.setItem('vt-rots-'+DATA.task,JSON.stringify(rots))}catch(e){}
  applyRot();}
// Open the focused / lightbox file in its default app (Preview, PDF Expert, …).
function editFocused(){var id=(lb.classList.contains('on')?lbId:foc);if(id==null||id<0)return;
  var it=byId(id);if(it&&it.path)location.href='hvniz://opendefault?path='+encodeURIComponent(it.path);}
window.rotate=rotate;window.editFocused=editFocused;
// Open the focused/lightboxed PDF in the advanced PDF.js reader (continuous scroll, zoom,
// text selection, find). The app intercepts hvniz://reader; harmless in a plain browser.
function openReaderDoc(){var id=(lb.classList.contains('on')?lbId:foc);if(id==null||id<0)return;var it=byId(id);if(!it||!it.path)return;
  if(it.category==='PDFs'||it.category==='Decks'||it.category==='Documents')location.href='hvniz://reader?path='+encodeURIComponent(it.path);
  else if(it.category==='Spreadsheets')location.href='hvniz://opendefault?path='+encodeURIComponent(it.path);}
window.openReaderDoc=openReaderDoc;
// Double-click / Enter on a file in a multi-file view opens the in-app LIGHTBOX, so you can
// hold a page and flip that same page across every file in the group (Jason's core review
// flow: up/down changes the page, left/right moves to the next/prev file staying on it). The
// full single-view editor (sign, fill, page ops, annotations) is one click away via the
// lightbox "Edit" button, the R key, and the Cmd-K "Open in Reader" command; a single-file
// "Open With PageHawk" from Finder still goes straight to the editor. openLB handles every
// category (docs render via the native page-image backend; photos/video keep their viewers).
function openItem(id){var it=byId(id);if(!it||!it.path){openLB(id);return;}
  setFoc(id);openLB(id);}
window.openItem=openItem;
function lbJump(i){if(i<0)i=0;if(i>=order.length)i=order.length-1;setFoc(order[i]);openLB(order[i],lbWantPage);}
function pagesReady(obj){if(!obj||!obj.path)return;pagesCache[obj.path]=obj.pages||[];var it=byId(lbId);
  if(lb.classList.contains('on')&&it&&it.path===obj.path&&obj.pages&&obj.pages.length){lbRendering=false;lbPagesArr=obj.pages;lbPage=Math.min(lbWantPage||0,obj.pages.length-1);if(lbPage<0)lbPage=0;showPages();lbPageInd();showLBLoad(false);}
  // A render that produced NOTHING must still end the wait. The shell reports the empty result
  // rather than staying silent (main.swift renderFilePages), so the loader stops here and the
  // reader is told what happened, instead of a spinner that never resolves over a thumbnail that
  // is not the page. Silence was the old behavior and it is what made this look like a crop.
  else if(lb.classList.contains('on')&&it&&it.path===obj.path){lbRendering=false;showLBLoad(false);
    var pgel=document.getElementById('lbpg');
    if(pgel){pgel.classList.remove('clk');pgel.style.display='';
      pgel.textContent=obj.needs_converter?'▤ Install LibreOffice to render this document’s pages':'▤ Could not render this document’s pages';}}
  if(typeof pvOnPages==='function')pvOnPages(obj);}
function lbPageInd(){var el=document.getElementById('lbpg');if(!el)return;
  var jp=document.getElementById('lbjump'),dl=document.getElementById('lbjumplist');var editing=(jp&&document.activeElement===jp);
  if(lbPagesArr&&lbPagesArr.length>1){var L=lbPagesArr.length;var n=Math.max(1,Math.min(pagesPerView,L-lbPage));
    el.textContent=(n>1?('▤ Showing pages '+(lbPage+1)+'–'+(lbPage+n)+' of '+L):('▤ Page '+(lbPage+1)+' of '+L));
    el.classList.add('clk');el.style.display=editing?'none':'';
    if(jp&&!editing){jp.value=String(lbPage+1);jp.style.display='none';}
    if(dl&&dl.dataset.len!==String(L)){var oo='';for(var j=1;j<=L;j++)oo+='<option value="'+j+'"></option>';dl.innerHTML=oo;dl.dataset.len=String(L);}}
  else if(lbRendering){el.classList.remove('clk');el.style.display='';
    el.textContent='\u25A4 Rendering pages\u2026';if(jp)jp.style.display='none';}
  else{el.classList.remove('clk');el.style.display='none';if(jp)jp.style.display='none';}
  updatePPVControl();}
function lbGoto(i){if(!lbPagesArr||!lbPagesArr.length)return;lbPage=Math.max(0,Math.min(i,lbPagesArr.length-1));lbWantPage=lbPage;showPages();lbPageInd();}
function lbOpenJump(){if(!lbPagesArr||lbPagesArr.length<2)return;var jp=document.getElementById('lbjump'),pg=document.getElementById('lbpg');if(!jp)return;
  if(pg)pg.style.display='none';jp.style.display='';jp.value=String(lbPage+1);jp.focus();jp.select();}
function lbCloseJump(){var jp=document.getElementById('lbjump'),pg=document.getElementById('lbpg');if(jp)jp.style.display='none';if(pg&&lbPagesArr&&lbPagesArr.length>1)pg.style.display='';}
window.lbOpenJump=lbOpenJump;
(function(){var jp=document.getElementById('lbjump'),pg=document.getElementById('lbpg');if(!jp)return;
  if(pg){pg.tabIndex=0;pg.setAttribute('role','button');pg.title='Go to page — click or press g, then type a number or pick from the list';
    pg.onclick=function(){lbOpenJump();};
    pg.onkeydown=function(e){if(e.key==='Enter'||e.key===' '){lbOpenJump();e.preventDefault();}};}
  jp.onkeydown=function(e){e.stopPropagation();if(e.key==='Enter'){var n=parseInt(jp.value);if(n>=1)lbGoto(n-1);lbCloseJump();e.preventDefault();}else if(e.key==='Escape'){lbCloseJump();e.preventDefault();}};
  jp.onfocus=function(){jp.select();};
  jp.onblur=function(){setTimeout(lbCloseJump,120);};
  jp.onchange=function(){var n=parseInt(jp.value);if(n>=1)lbGoto(n-1);};})();
// Pages-per-view dropdown: options capped at the document's page count (1 page → hidden; 2 pages →
// up to 2; 20 pages → up to 5). The chosen count persists across files + sessions (vt-ppv).
function updatePPVControl(){
  var box=document.getElementById('lbppv'),sel=document.getElementById('lbppvsel'); if(!box||!sel)return;
  var len=(lbPagesArr&&lbPagesArr.length)||0;
  if(len<=1){box.style.display='none';return;}
  var maxN=Math.min(5,len), cur=Math.max(1,Math.min(pagesPerView,maxN));
  if(sel.dataset.max!==String(maxN)){var o='';for(var k=1;k<=maxN;k++)o+='<option value="'+k+'">'+k+(k===1?' page':' pages')+'</option>';sel.innerHTML=o;sel.dataset.max=String(maxN);}
  sel.value=String(cur);
  box.style.display='inline-flex';
}
// Render the current page group: 1..N pages side by side (pagesPerView). #lbpic is the first page;
// #lbextra (display:contents) holds the rest, so all sit in the #lbpic-wrap flex row.
function showPages(){
  var pic=document.getElementById('lbpic'),ex=document.getElementById('lbextra'),wrap=document.getElementById('lbpic-wrap');
  if(!lbPagesArr||!lbPagesArr.length){if(ex)ex.innerHTML='';return;}
  if(lbPage<0)lbPage=0; if(lbPage>=lbPagesArr.length)lbPage=lbPagesArr.length-1;
  var n=Math.max(1,Math.min(pagesPerView,5,lbPagesArr.length-lbPage));
  if(wrap)wrap.style.setProperty('--ppv',n);
  pic.style.display='';pic.src=lbPagesArr[lbPage]||'';
  var h='';for(var i=1;i<n;i++){h+='<img class="lbpg2" src="'+(lbPagesArr[lbPage+i]||'')+'">';}
  if(ex)ex.innerHTML=h;
  lbApplyTheme();
}
// Reading theme (Day / Night / Sepia) for the FULL viewer, sharing the SAME persisted setting as
// the inline pane so the choice follows the reader everywhere. Applies only to document pages.
function lbApplyTheme(){var w=document.getElementById('lbpic-wrap'),btn=document.getElementById('lbtheme');if(!w)return;
  var it=byId(lbId),doc=isDocCat(it);
  w.classList.remove('night','sepia');
  if(doc){var t=localStorage.getItem('vt-pvtheme')||'day';if(t==='night')w.classList.add('night');else if(t==='sepia')w.classList.add('sepia');}
  if(btn)btn.style.display=doc?'':'none';
  var eb=document.getElementById('lbedit');if(eb)eb.style.display=doc?'':'none';}
function lbCycleTheme(){var seq=['day','night','sepia'],t=localStorage.getItem('vt-pvtheme')||'day';
  t=seq[(seq.indexOf(t)+1)%3];try{localStorage.setItem('vt-pvtheme',t);}catch(e){}
  if(typeof PV!=='undefined')PV.theme=t;lbApplyTheme();
  if(typeof pvApplyTheme==='function'&&typeof PV!=='undefined'&&PV.active)pvApplyTheme();}
window.lbCycleTheme=lbCycleTheme;
function setPPV(n){pagesPerView=Math.max(1,Math.min(5,n||1));try{localStorage.setItem('vt-ppv',pagesPerView);}catch(e){}
  if(lbPagesArr&&lbPagesArr.length){showPages();lbPageInd();}}
window.setPPV=setPPV;
function lbStatus(){if(lbId==null)return;var pos=order.indexOf(lbId);var c=document.getElementById('lbct');if(c)c.textContent='File '+(pos+1)+' of '+order.length;
  var h=document.getElementById('lbhint');if(h){var it=byId(lbId);
    var mid=isDocCat(it)?'<kbd>↑</kbd> <kbd>↓</kbd> pages':((it&&!it.video)?'scroll to zoom &nbsp; drag to pan':'');
    h.innerHTML='<kbd>←</kbd> <kbd>→</kbd> files'+(mid?(' &nbsp; '+mid):'')+' &nbsp; <kbd>Enter</kbd>/<kbd>F</kbd>/<kbd>Esc</kbd> back to Grid';}}
function lbNav(delta){var i=order.indexOf(lbId);if(i<0)return;i+=delta;if(i<0)i=0;if(i>=order.length)i=order.length-1;setFoc(order[i]);openLB(order[i],lbWantPage);}
function lbSet(d){set(lbId,d);lbStatus();lbNav(1);}
// ── native file actions (postback to the app via hvniz://) ──
function openExternal(it){if(it)location.href='hvniz://opendefault?path='+encodeURIComponent(it.path||'');}
function openMenuFor(it){if(it)location.href='hvniz://openmenu?path='+encodeURIComponent(it.path||'');}   // the ▾ : choose an app
function revealItem(it){if(it)location.href='hvniz://reveal?path='+encodeURIComponent(it.path||'');}
function copyItem(it){if(it)location.href='hvniz://copyfile?path='+encodeURIComponent(it.path||'');}
var ctxEl=null;
function hideCtx(){if(ctxEl){ctxEl.remove();ctxEl=null;}}
function showCtx(x,y,it){hideCtx();var m=document.createElement('div');m.className='ctx';
  var rows=[];
  if(it.video)rows.push(['▶  Open in default player','open']);
  else rows.push(['Open in default app','open']);
  rows.push(['Reveal in Finder','reveal']);
  rows.push(['Copy','copy']);
  m.innerHTML=rows.map(function(r){return '<div class="ci" data-a="'+r[1]+'">'+r[0]+'</div>';}).join('');
  document.body.appendChild(m);
  var mw=m.offsetWidth,mh=m.offsetHeight;if(x+mw>innerWidth)x=Math.max(4,innerWidth-mw-6);if(y+mh>innerHeight)y=Math.max(4,innerHeight-mh-6);
  m.style.left=x+'px';m.style.top=y+'px';ctxEl=m;
  Array.prototype.forEach.call(m.querySelectorAll('.ci'),function(c){c.onmousedown=function(e){e.preventDefault();e.stopPropagation();var a=c.dataset.a;if(a==='open')openExternal(it);else if(a==='reveal')revealItem(it);else if(a==='copy')copyItem(it);hideCtx();};});}
document.addEventListener('click',hideCtx);
document.addEventListener('scroll',hideCtx,true);
document.addEventListener('contextmenu',function(e){if(!e.target.closest('.tile'))hideCtx();});
document.addEventListener('keydown',function(e){
  if((e.metaKey||e.ctrlKey)&&!e.altKey){var _vm={'1':'grid','2':'list','3':'column','4':'filmstrip'}[e.key];
    if(_vm){setViewMode(_vm);e.preventDefault();return;}}   // Cmd 1/2/3/4 = Grid/List/Columns/Gallery (Finder model)
  if(e.metaKey||e.ctrlKey)return; var k=e.key.toLowerCase();
  // To-do owns K / X / U while a pass is running, and only then. All three were free before this
  // (checked against every single-letter binding in the engine), so nothing is being taken away.
  if(TODO&&!/^(input|textarea|select)$/i.test((e.target&&e.target.tagName)||'')){
    if(k==='k'){todoMark('keep');e.preventDefault();return;}
    if(k==='x'){todoMark('trash');e.preventDefault();return;}
    if(k==='u'){todoMark('');e.preventDefault();return;}
    if(k==='arrowright'||k==='arrowdown'){todoGo(1);e.preventDefault();return;}
    if(k==='arrowleft'||k==='arrowup'){todoGo(-1);e.preventDefault();return;}
    if(k==='escape'){todoEnd();e.preventDefault();return;}
  }
  if(PV.active&&PV._markPending){   // a pending 'm'/backtick consumes the very next key as the mark letter
    if(/^[a-z]$/.test(k)){var _mm=PV._markPending;PV._markPending=null;if(_mm==='set')pvMarkSet(k);else pvMarkGoto(k);e.preventDefault();return;}
    PV._markPending=null;var t=PV.el&&PV.el.querySelector('#pvtoast');if(t)t.classList.remove('on');if(k==='escape'){e.preventDefault();return;}}
  // Presentation mode swallows navigation: click/→/↓/Space/]/. advance, ←/↑/[/, go back, Esc / P exit.
  if(typeof pvPresentActive==='function'&&pvPresentActive()){
    if(k==='escape'||k==='p'){pvPresentToggle(false);e.preventDefault();return;}
    if(k==='arrowright'||k==='arrowdown'||k===' '||k==='pagedown'||k===']'||k==='.'){pvPage(1);e.preventDefault();return;}
    if(k==='arrowleft'||k==='arrowup'||k==='pageup'||k==='['||k===','){pvPage(-1);e.preventDefault();return;}
    if(k==='t'&&isDocCat(PV.item)){pvCycleTheme();e.preventDefault();return;}
    e.preventDefault();return;   // a clean deck: swallow everything else
  }
  if(k==='enter'||k==='f'){toggleFull();e.preventDefault();return;}
  if(k===' '){var lv=document.getElementById('lbvid');if(lb.classList.contains('on')&&lv&&lv.style.display!=='none')return;toggleFull();e.preventDefault();return;}   // full ⇄ thumbnails (same as PDF view)
  if(k==='p'&&e.shiftKey&&typeof pvPresentToggle==='function'&&PV.active){pvPresentToggle();e.preventDefault();return;}   // ⇧P → presentation mode
  if(k==='p'&&!e.shiftKey){toggleSlideshow();e.preventDefault();return;}   // play/stop slideshow (works from grid or viewer)
  // Esc leaves find mode first and hands the rail back to the verbs, before any preview handler
  // claims the key. Ordering matters: the rail is what the person is looking at.
  if(k==='escape'&&typeof window.__phkFindClose==='function'&&window.__phkFindClose()){e.preventDefault();return;}
  if(k==='escape'&&typeof pvOverviewActive==='function'&&pvOverviewActive()){pvOverviewToggle(false);e.preventDefault();return;}   // Esc closes the page-overview grid
  if(k==='escape'&&typeof pvFocusActive==='function'&&pvFocusActive()){pvFocusToggle(false);e.preventDefault();return;}   // Esc leaves focus/Zen mode
  if(lb.classList.contains('on')){
    if(k==='escape'){closeLB();}
    else if(k==='arrowright'){lbNav(1);e.preventDefault();}
    else if(k==='arrowleft'){lbNav(-1);e.preventDefault();}
    else if(k==='home'){lbJump(0);e.preventDefault();}
    else if(k==='end'){lbJump(order.length-1);e.preventDefault();}
    else if(k==='r'){openReaderDoc();}
    else if(k==='t'&&isDocCat(byId(lbId))){lbCycleTheme();e.preventDefault();}
    else if(k==='g'&&lbPagesArr&&lbPagesArr.length>1){lbOpenJump();e.preventDefault();}
    else if((k==='='||k==='+')&&lbIsPhoto()){lbZoomBy(1.25);e.preventDefault();}
    else if(k==='-'&&lbIsPhoto()){lbZoomBy(0.8);e.preventDefault();}
    else if(k==='0'&&lbIsPhoto()){lbResetZoom();applyRot();e.preventDefault();}
    else if(k==='arrowup'||k==='arrowdown'){if(lbPagesArr&&lbPagesArr.length>1){var n=Math.max(1,Math.min(pagesPerView,lbPagesArr.length));if(k==='arrowdown'){if(lbPage+n<lbPagesArr.length)lbPage+=n;}else{lbPage=Math.max(0,lbPage-n);}lbWantPage=lbPage;showPages();lbPageInd();}e.preventDefault();}
    return;
  }
  // In-pane page navigation. Rule: arrows ALONG the file-list axis move between files; the
  // PERPENDICULAR arrows flip PAGES. So List + Columns (vertical file list) use ←/→ for pages and
  // ↑/↓ for files; Gallery (horizontal filmstrip) uses ↑/↓ for pages and ←/→ for files. Single-key
  // [ ] and , . page in EVERY preview view; Page Up/Down, Opt+arrow, and the scroll wheel also page.
  if((viewMode==='list'||viewMode==='filmstrip'||viewMode==='column')&&PV.active){
    if(PV.pages&&PV.pages.length>1){
      if(e.altKey&&k==='arrowleft'){pvNavBack();e.preventDefault();return;}
      if(e.altKey&&k==='arrowright'){pvNavForward();e.preventDefault();return;}
      if(k==='pagedown'||(e.altKey&&k==='arrowdown')||k===']'||k==='.'){pvPage(1);e.preventDefault();return;}
      if(k==='pageup'||(e.altKey&&k==='arrowup')||k==='['||k===','){pvPage(-1);e.preventDefault();return;}
    }
    if(k==='l'){pvRotate();e.preventDefault();return;}
    if(k==='c'&&isDocCat(PV.item)){pvToggleMode();e.preventDefault();return;}
    if(k==='t'&&isDocCat(PV.item)){pvCycleTheme();e.preventDefault();return;}
    if(k==='i'){pvToggleInfo();e.preventDefault();return;}
    if(k==='z'){pvFocusToggle();e.preventDefault();return;}
    if(k==='h'&&PV.pages&&PV.pages.length>1){pvRailToggle();e.preventDefault();return;}
    if(k==='g'&&PV.pages&&PV.pages.length>1){pvOpenJump();e.preventDefault();return;}
    if(k==='m'&&PV.pages&&PV.pages.length>1){PV._markPending='set';pvFlash('Set mark — press a–z (Esc cancels)');e.preventDefault();return;}
    if(k==='`'&&PV.pages&&PV.pages.length>1){PV._markPending='goto';pvFlash('Go to mark — press a–z (Esc cancels)');e.preventDefault();return;}
    if(k==='o'&&PV.pages&&PV.pages.length>1){pvOverviewToggle();e.preventDefault();return;}
    if(k==='b'&&isDocCat(PV.item)&&PV.pages&&PV.pages.length>1){pvBookToggle();e.preventDefault();return;}
    if(k==='='||k==='+'){pvZoom(1.25);e.preventDefault();return;}
    if(k==='-'){pvZoom(0.8);e.preventDefault();return;}
    if(k==='0'){PV.zoom=1;PV.panX=0;PV.panY=0;PV.fit='page';pvApplyTransform();e.preventDefault();return;}
  }
  // Jason's model: the ARROWS NAVIGATE FILES FIRST. The PDF preview only takes the arrows once you
  // click into it (PV.kfocus) — then they flip pages; Esc or clicking the file list hands the arrows
  // back to the files. Modified page keys ([ ] , . PageUp/Down, Opt+arrow) still page regardless.
  if(k==='escape'&&PV.kfocus){setPvKfocus(false);e.preventDefault();return;}
  if(viewMode==='list'||viewMode==='column'){
    var _st=(k==='arrowdown'||k==='arrowright')?1:((k==='arrowup'||k==='arrowleft')?-1:0);
    if(_st){
      if(PV.kfocus&&PV.active&&PV.pages&&PV.pages.length>1){pvPage(_st);}
      else{(viewMode==='list'?listStep:colStep)(_st);}
      e.preventDefault();return;
    }
  }
  if(viewMode==='filmstrip'){
    if(PV.kfocus&&PV.active&&PV.pages&&PV.pages.length>1){   // PDF focused → ↑/↓ page (←/→ still files)
      if(k==='arrowdown'){pvPage(1);e.preventDefault();return;}
      if(k==='arrowup'){pvPage(-1);e.preventDefault();return;}
    }
    if(k==='arrowright'||k==='arrowdown'){fsStep(1);e.preventDefault();return;}
    if(k==='arrowleft'||k==='arrowup'){fsStep(-1);e.preventDefault();return;}
  }
  if(k==='arrowright'){move(1);e.preventDefault();}
  else if(k==='arrowleft'){move(-1);e.preventDefault();}
  else if(k==='arrowdown'){moveRow(1);e.preventDefault();}
  else if(k==='arrowup'){moveRow(-1);e.preventDefault();}
  else if(k==='home'&&order.length){setFoc(order[0]);e.preventDefault();}
  else if(k==='end'&&order.length){setFoc(order[order.length-1]);e.preventDefault();}
  else if(k==='r'&&foc>=0){openReaderDoc();}
});
lb.addEventListener('click',function(e){if((e.target===lb||e.target.id==='lbpic-wrap')&&lbZoom<=1)closeLB();});
// Photo zoom/pan: wheel/pinch to zoom toward the cursor, drag to pan when zoomed, double-click to
// toggle. Still images only (docs use the Reader, videos play). Pan clipped by #lbpic-wrap overflow.
(function(){var wrap=document.getElementById('lbpic-wrap');if(!wrap)return;
  wrap.addEventListener('wheel',function(e){
    if(lbIsPhoto()){e.preventDefault();
      var r=wrap.getBoundingClientRect(),ox=e.clientX-(r.left+r.width/2),oy=e.clientY-(r.top+r.height/2);
      var nz=Math.min(6,Math.max(1,lbZoom*Math.exp(-e.deltaY*0.0015))),f=(lbZoom?nz/lbZoom:1);
      lbPanX=ox-f*(ox-lbPanX);lbPanY=oy-f*(oy-lbPanY);lbZoom=nz;if(lbZoom<=1.001)lbResetZoom();applyRot();return;}
    // Multi-page doc in the lightbox: natural accumulate-to-page (same feel as the preview pane).
    if(lb.classList.contains('on')&&lbPagesArr&&lbPagesArr.length>1){
      e.preventDefault();var np=phkWheelPages(e.deltaY);if(np)lbGoto(lbPage+np);}
  },{passive:false});
  wrap.addEventListener('dblclick',function(e){if(!lbIsPhoto())return;e.preventDefault();
    if(lbZoom>1){lbResetZoom();}else{var r=wrap.getBoundingClientRect(),ox=e.clientX-(r.left+r.width/2),oy=e.clientY-(r.top+r.height/2),nz=2.5;lbPanX=ox-nz*ox;lbPanY=oy-nz*oy;lbZoom=nz;}applyRot();});
  wrap.addEventListener('mousedown',function(e){if(!lbIsPhoto()||lbZoom<=1)return;e.preventDefault();
    var sx=e.clientX,sy=e.clientY,px=lbPanX,py=lbPanY,pic=document.getElementById('lbpic');if(pic)pic.style.cursor='grabbing';
    function mm(ev){lbPanX=px+(ev.clientX-sx);lbPanY=py+(ev.clientY-sy);applyRot();}
    function mu(){document.removeEventListener('mousemove',mm,true);document.removeEventListener('mouseup',mu,true);var p=document.getElementById('lbpic');if(p)p.style.cursor=(lbZoom>1?'grab':'');}
    document.addEventListener('mousemove',mm,true);document.addEventListener('mouseup',mu,true);});
})();
// thumbnail size: persists; re-lays out masonry on change
var TWK='vtriage-tw';
function setTW(v){document.documentElement.style.setProperty('--tw',v+'px');try{localStorage.setItem(TWK,v)}catch(e){}}
var tw0=localStorage.getItem(TWK)||'180'; setTW(tw0);
// P3: browse density (spacing) — separate from --tw (tile size). Persisted; applied on load.
function setDensity(d){var on=(d==='compact');document.body.classList.toggle('dense',on);try{localStorage.setItem('vt-density',on?'compact':'cozy')}catch(e){}}
try{setDensity(localStorage.getItem('vt-density')==='compact'?'compact':'cozy');}catch(e){}
var twi=document.getElementById('tw'); if(twi){twi.value=tw0;twi.oninput=function(){setTW(twi.value);masonry(true);};}
setTimeout(function(){try{tbzInitTop();}catch(e){}},0);   // top-bar customize (deferred so TOPBAR_DESC is defined)
window.addEventListener('resize',debounce(function(){masonry(true);},120));
grid.addEventListener('load',debounce(function(){masonry(true);},90),true);  // re-span ALL tiles after a lazy-load burst, so late thumbnails don't leave stale (collapsed/overlapping) row heights
// ── Brand wordmark auto-fit ─────────────────────────────────────────────────
// The centered wordmark (.brandc) is absolutely positioned over #top, so it paints OVER the
// left/right control clusters whenever they reach the toolbar center — a narrow window, the
// selection action bar, or a long brand name ("Winnow Photos"). Show it ONLY when the free
// center span (the .sp flex spacer between the two clusters) is wide enough to hold it; else
// fade it out. One ResizeObserver on the spacer covers window resize, the selection bar
// appearing/leaving, chip changes, and view-label width changes in a single hook.
function fitBrand(){var top=document.getElementById('top');if(!top)return;
  var brand=top.querySelector('.brandc'),sp=top.querySelector('.sp');if(!brand||!sp)return;
  var tr=top.getBoundingClientRect(),spr=sp.getBoundingClientRect();
  var center=tr.left+tr.width/2,half=brand.offsetWidth/2,PAD=12;
  var fits=(center-half-PAD>=spr.left)&&(center+half+PAD<=spr.right);
  top.classList.toggle('brandclip',!fits);}
(function(){var sp=document.querySelector('#top .sp');
  if(sp&&window.ResizeObserver){try{new ResizeObserver(function(){fitBrand();}).observe(sp);}catch(e){}}
  window.addEventListener('resize',debounce(fitBrand,60));
  window.addEventListener('load',function(){requestAnimationFrame(fitBrand);});
  fitBrand();})();
document.addEventListener('click',function(e){var vd=document.querySelector('.vdrop');var m=document.getElementById('vmenu');if(m&&m.classList.contains('on')&&vd&&!vd.contains(e.target))m.classList.remove('on');});
// ════════════════════════════════════════════════════════════════════════════
//  CUSTOMIZABLE COLUMNS + POWERFUL INLINE PREVIEW (List / Gallery / Columns)
//  The side pane becomes a real document viewer (Page X of Y, page nav, zoom,
//  fit, rotate, jump-to-page, pages-per-view, a page-thumbnail rail) reusing the
//  lightbox page-render backend; the list columns become add/move/resize/sort.
// ════════════════════════════════════════════════════════════════════════════
var COLSPECS={
  name:{label:'Name',w:'minmax(150px,2.4fr)',sort:'name',lock:true,val:function(it){return it.name||'';}},
  folder:{label:'Folder',w:'minmax(96px,1.3fr)',sort:'folder',val:function(it){return it.folder||'';}},
  type:{label:'Kind',w:'94px',sort:'type',val:function(it){return it.category||'';}},
  ext:{label:'Ext',w:'56px',sort:'ext',val:function(it){return fileExt(it.name);}},
  size:{label:'Size',w:'76px',sort:'size',num:true,val:function(it){return it.size?fmtBytes(it.size):'';}},
  date:{label:'Date Modified',w:'124px',sort:'date',num:true,val:function(it){return it.mtime?fmtMTime(it.mtime):'';}},
  pages:{label:'Pages',w:'60px',sort:'pages',num:true,val:function(it){var p=pagesCache[it.path];if(p&&p.length)return String(p.length);if(it.pages)return String(it.pages);return isDocCat(it)?'…':'';}},
  dims:{label:'Dimensions',w:'106px',sort:'dims',num:true,val:function(it){return it.dims||'';}},
  taken:{label:'Date Taken',w:'118px',sort:'taken',num:true,val:function(it){return (it.exif&&it.exif.date)?String(it.exif.date):'';}},
  path:{label:'Location',w:'minmax(120px,1.6fr)',sort:'folder',val:function(it){return prettyDir(it.path||'');}}
};
var COLDEFAULT=['name','folder','type','size','date'];
function fileExt(n){n=n||'';var i=n.lastIndexOf('.');return (i>0&&i<n.length-1)?n.slice(i+1).toUpperCase():'';}
function prettyDir(p){if(!p)return '';var i=p.lastIndexOf('/');var d=i>0?p.slice(0,i):p;
  d=d.replace(/\/Users\/[^/]+\/Library\/CloudStorage\/[^/]*-/,'~/').replace(/^\/Users\/[^/]+/,'~');return d;}
var colCfg=(function(){try{var c=JSON.parse(localStorage.getItem('vt-cols')||'null');
  if(c&&c.order&&c.order.length)return {order:c.order.filter(function(id){return COLSPECS[id];}),widths:c.widths||{}};}catch(e){}
  return {order:COLDEFAULT.slice(),widths:{}};})();
function colSave(){try{localStorage.setItem('vt-cols',JSON.stringify(colCfg));}catch(e){}}
function colVisible(){var o=colCfg.order.filter(function(id){return COLSPECS[id]&&id!=='name';});o.unshift('name');return o;}
function colTemplate(){return colVisible().map(function(id){var w=colCfg.widths[id];return w?(w+'px'):COLSPECS[id].w;}).join(' ');}
function applyColTemplate(){var t=document.getElementById('listtbl');if(t)t.style.setProperty('--lcols',colTemplate());}
var colDragId=null;
function wireListHeader(tbl){
  var hdr=tbl.querySelector('.lhdr');if(!hdr)return;
  Array.prototype.forEach.call(hdr.querySelectorAll('.lcell[data-k]'),function(h){
    h.onclick=function(e){if(e.target.classList.contains('lcolgrip'))return;if(h.dataset.dragged){h.dataset.dragged='';return;}setSort(h.dataset.k);};
    h.oncontextmenu=function(e){openColMenu(e);};
    h.ondragstart=function(e){if(h.getAttribute('draggable')!=='true'){e.preventDefault();return;}colDragId=h.dataset.col;h.classList.add('dragging');try{e.dataTransfer.setData('text/plain',h.dataset.col);e.dataTransfer.effectAllowed='move';}catch(_){}};
    h.ondragend=function(){h.classList.remove('dragging');colDragId=null;Array.prototype.forEach.call(hdr.querySelectorAll('.draghover'),function(c){c.classList.remove('draghover');});};
    h.ondragover=function(e){if(!colDragId||colDragId===h.dataset.col)return;e.preventDefault();h.classList.add('draghover');};
    h.ondragleave=function(){h.classList.remove('draghover');};
    h.ondrop=function(e){e.preventDefault();h.classList.remove('draghover');if(!colDragId||colDragId===h.dataset.col)return;moveCol(colDragId,h.dataset.col);h.dataset.dragged='1';};
  });
  Array.prototype.forEach.call(hdr.querySelectorAll('.lcolgrip'),function(g){
    g.onmousedown=function(e){e.preventDefault();e.stopPropagation();var id=g.dataset.grip;var cell=g.parentNode;var startX=e.clientX;var startW=cell.getBoundingClientRect().width;
      var startRung=(id==='date')?dateRungIndex(startW):-1;
      // Resizing the Date column can change which date FORM fits, and the form lives in the row
      // text rather than in the CSS template, so applyColTemplate() alone cannot restyle it.
      // Re-render only when the rung actually changes: a renderList() on every mousemove would
      // make the drag crawl on a large library, and every intermediate pixel renders identically.
      function mm(ev){var w=Math.max(46,Math.round(startW+(ev.clientX-startX)));colCfg.widths[id]=w;applyColTemplate();
        if(id==='date'){var r=dateRungIndex(w);if(r!==startRung){startRung=r;renderList();}}}
      function mu(){document.removeEventListener('mousemove',mm,true);document.removeEventListener('mouseup',mu,true);colSave();}
      document.addEventListener('mousemove',mm,true);document.addEventListener('mouseup',mu,true);};
    g.ondblclick=function(e){e.stopPropagation();delete colCfg.widths[g.dataset.grip];colSave();applyColTemplate();};
  });
}
function moveCol(src,dst){if(src==='name'||dst==='name')return;var o=colVisible();var si=o.indexOf(src),di=o.indexOf(dst);if(si<0||di<0)return;
  o.splice(si,1);di=o.indexOf(dst);o.splice(di+(si<di?1:0),0,src);
  var hidden=colCfg.order.filter(function(id){return o.indexOf(id)<0&&COLSPECS[id];});
  colCfg.order=o.concat(hidden);colSave();renderList();}
var colMenuEl=null;
function colMenuOutside(e){if(colMenuEl&&!colMenuEl.contains(e.target))closeColMenu();}
function closeColMenu(){if(colMenuEl){colMenuEl.remove();colMenuEl=null;document.removeEventListener('click',colMenuOutside,true);}}
function openColMenu(e){if(e){e.preventDefault();e.stopPropagation();}closeColMenu();
  var m=document.createElement('div');m.className='colmenu';
  var vis=colVisible();var html='<div class="cmlbl">Show columns</div>';
  Object.keys(COLSPECS).forEach(function(id){var c=COLSPECS[id];var on=vis.indexOf(id)>=0;
    html+='<div class="cmrow'+(on?' on':'')+(c.lock?' lock':'')+'" data-col="'+id+'"><span class="cmck">✓</span>'+esc(c.label)+'</div>';});
  html+='<div class="cmsep"></div><div class="cmreset">Reset to defaults</div>';
  m.innerHTML=html;document.body.appendChild(m);
  var x=(e&&e.clientX)||120,y=(e&&e.clientY)||80;var mw=m.offsetWidth,mh=m.offsetHeight;
  if(x+mw>innerWidth)x=innerWidth-mw-6;if(y+mh>innerHeight)y=innerHeight-mh-6;
  m.style.left=Math.max(4,x)+'px';m.style.top=Math.max(4,y)+'px';colMenuEl=m;
  Array.prototype.forEach.call(m.querySelectorAll('.cmrow'),function(r){if(r.classList.contains('lock'))return;
    r.onclick=function(ev){ev.stopPropagation();toggleCol(r.dataset.col);};});
  m.querySelector('.cmreset').onclick=function(ev){ev.stopPropagation();colCfg={order:COLDEFAULT.slice(),widths:{}};colSave();closeColMenu();renderList();};
  setTimeout(function(){document.addEventListener('click',colMenuOutside,true);},0);
}
function toggleCol(id){if(id==='name'||!COLSPECS[id])return;var o=colCfg.order.filter(function(x){return COLSPECS[x];});var i=o.indexOf(id);
  if(i>=0)o.splice(i,1);else o.push(id);colCfg.order=o;colSave();
  if(colMenuEl){var r=colMenuEl.querySelector('.cmrow[data-col="'+id+'"]');if(r)r.classList.toggle('on');}
  renderList();}
window.openColMenu=openColMenu;
// ── Inline preview engine (a real viewer in the side pane) ──
var PV={item:null,el:null,pages:null,page:0,zoom:1,panX:0,panY:0,rot:0,fit:'page',ppv:1,reqPath:null,reqTimer:null,active:false,hist:[],histIdx:-1,_nav:false,_resumed:false,_rzTimer:null,focus:false,_markPending:null,_toastTimer:null,overview:false,
  mode:(localStorage.getItem('vt-pvmode')||'paged'),theme:(localStorage.getItem('vt-pvtheme')||'day'),info:false,scrollRAF:0,_prog:false,book:(localStorage.getItem('vt-pvbook')==='1'),present:false,
  railOpen:(localStorage.getItem('vt-pvrail-show')!=='0'),railPos:(localStorage.getItem('vt-pvrail-pos')||'bottom'),
  railSizeB:(parseInt(localStorage.getItem('vt-pvrail-h'))||78),railSizeR:(parseInt(localStorage.getItem('vt-pvrail-w'))||96)};
function pvFmtMeta(it){var b=[];if(it.dims&&!isDocCat(it))b.push(it.dims);if(it.size)b.push(fmtBytes(it.size));
  if(it.exif&&it.exif.date)b.push('📅 '+it.exif.date);return b.join('  ·  ');}
function pvStage(){return PV.el?PV.el.querySelector('#pvstage'):null;}
// Keyboard focus between the file list and the PDF preview (Jason: files first). PV.kfocus true = the
// PDF has the arrows (page-flip); false = the file list has them. Click the preview to focus the PDF;
// click the list, select a file, or press Esc to hand the arrows back to the files.
function setPvKfocus(on){ on=!!on&&PV.active; if(PV.kfocus===on)return; PV.kfocus=on;
  try{document.body.classList.toggle('pvkfocus',on);}catch(e){}
  // The verb rail acts on the file list, so it follows the arrows rather than the selection alone.
  try{if(window.__msel&&window.__msel.railSync)window.__msel.railSync();}catch(e){} }
document.addEventListener('mousedown',function(e){
  if(e.target.closest('.pvstagewrap,#pvstage,.pvbar')){ setPvKfocus(true); }
  else if(e.target.closest('.lrow,.tile,.fsth,.colcell,.lgrp,#listtbl,#fsstrip')){ setPvKfocus(false); }
},true);
// ── The embedded reader (ballot option B) ────────────────────────────────────────────────
// READER_HTML is the absolute path the host resolved; "" means it could not be found, and every
// call site below already guards on it so the pane silently keeps its fast preview.
var READER_HTML=(DATA&&DATA.readerHtml)||'';
// Sticky per this machine. Default ON for documents, which is what Jason asked for; the toggle in
// the pane bar is the way back to the fast preview, and the choice persists.
function pvEmbedOn(){ try{ return localStorage.getItem('vt-pvembed')!=='0'; }catch(e){ return true; } }
function pvEmbedSet(on){ try{ localStorage.setItem('vt-pvembed',on?'1':'0'); }catch(e){}
  if(PV.item&&PV.el) pvShow(PV.el,PV.item); }
function pvReaderURL(path){
  // file:// + encodeURI for the reader itself (a path with spaces must not break the frame src),
  // encodeURIComponent for the document, which is a VALUE inside the query.
  // theme rides along so the framed reader opens on the same scheme as the library around it.
  var t=''; try{ t=document.documentElement.dataset.theme||''; }catch(e){}
  return 'file://'+encodeURI(READER_HTML)
       + '?pdf='+encodeURIComponent(path)
       + '&orig='+encodeURIComponent(path)
       + '&embed=1&sb=0&z=page-width'
       + (t?('&theme='+encodeURIComponent(t)):'');
}
function pvShowEmbedded(el,it){
  PV.embedded=true;
  // The pane's own paging/zoom/keyboard model belongs to the fast preview. While the reader is in
  // the frame it owns all of that, so this stands the pane's model down rather than leaving two
  // things listening for the same arrow key.
  PV.pages=null; PV.active=true;
  try{ document.body.classList.remove('pvkfocus'); }catch(e){}
  var name=(it.name||it.path||'').replace(/[<>&"]/g,'');
  el.innerHTML=''
   +'<div class="pvbar pvbar-embed">'
   +  '<span class="pvembname" title="'+name+'">'+name+'</span>'
   +  '<span class="pvsp"></span>'
   +  '<button class="pvb" id="pvembquick" title="Back to the fast page preview (the editor stays one click away)">Quick preview</button>'
   +  '<button class="pvb" id="pvembfull" title="Open this document in its own window (⇧F)">Open in window</button>'
   +'</div>'
   +'<div class="pvmain pvmain-embed"><iframe class="pvreader" id="pvreader" title="Editor"></iframe></div>';
  var fr=el.querySelector('#pvreader');
  // src is assigned AFTER insertion so a failed load cannot leave a half-built pane behind.
  if(fr) fr.src=pvReaderURL(it.path);
  var q=el.querySelector('#pvembquick'); if(q) q.onclick=function(){ pvEmbedSet(false); };
  var f=el.querySelector('#pvembfull');
  if(f) f.onclick=function(){ location.href='hvniz://reader?path='+encodeURIComponent(it.path); };
  // The rail acts on the FILE LIST, and in editor mode the frame has the keyboard, so the rail
  // behaves exactly as it does when the fast preview takes the arrows.
  try{ if(window.__msel&&window.__msel.railSync)window.__msel.railSync(); }catch(e){}
}

function pvShow(container,it){
  var el=(typeof container==='string')?document.getElementById(container):container;
  if(!el)return;if(PV.reqTimer){clearTimeout(PV.reqTimer);PV.reqTimer=null;}   // cancel a stale page request from a prior selection
  if(PV._rzTimer){clearTimeout(PV._rzTimer);PV._rzTimer=null;}                  // drop a pending resume-chip fade from the prior doc
  if(PV._toastTimer){clearTimeout(PV._toastTimer);PV._toastTimer=null;}
  PV.el=el;PV.item=it;PV.active=true;PV.zoom=1;PV.panX=0;PV.panY=0;PV.page=0;PV.pages=null;PV.hist=[];PV.histIdx=-1;PV._nav=false;PV._resumed=false;PV._markPending=null;PV.overview=false;PV.present=false;
  PV.kfocus=false;try{document.body.classList.remove('pvkfocus');}catch(e){}   // new file → arrows stay on the file list until you click the preview
  // ...and the rail comes back with them. This path clears the class DIRECTLY rather than through
  // setPvKfocus, so without this line a new preview would inherit the previous file's hidden rail.
  try{if(window.__msel&&window.__msel.railSync)window.__msel.railSync();}catch(e){}
  PV.rot=(it&&rots[it.path])||0;
  if(!it){el.innerHTML='<div class="pvempty">Select a file to preview &nbsp;·&nbsp; double-click or Enter opens it full</div>';return;}
  var doc=isDocCat(it);
  // ── EDITOR MODE: the pane IS the reader ────────────────────────────────────────────────
  // Jason 2026-08-27, ballot option B: "just making the preview the reader and just making sure
  // it's responsive so it fits the space given to us... it's otherwise a full-powered reader.
  // It's not even a reader; it's an editor."
  //
  // It embeds the SAME reader.html a reader window runs, so the pane gets every tool — Fill &
  // Sign, Edit, Annotate, Export, search — with no second implementation to drift. Proven in a
  // real WKWebView before it was built: reader.html boots inside a file:// iframe of this page
  // and pdf.js renders through it, because the review web view already sets
  // allowFileAccessFromFileURLs and owns the vtpdf:// scheme handler the frame inherits.
  //
  // WHY IT IS A MODE AND NOT A REPLACEMENT. The fast preview exists for triage: arrowing through
  // forty files wants a page image, not a reader boot per file. So the pane keeps both and
  // REMEMBERS which one you were in, per this rule: if you are working you stay in the editor as
  // you move between documents; if you are flipping, you stay in preview. Nothing else in the
  // engine changes, which is also why an embed failure can fall straight back.
  if(doc && pvEmbedOn() && READER_HTML){ pvShowEmbedded(el,it); return; }
  PV.embedded=false;   // cleared on EVERY fall-through, so the flag can never outlive the frame
  el.innerHTML=''
   +'<div class="pvbar">'
   +'<button class="pvb" id="pvback" title="Back  ( ⌥← )" style="display:none">↩</button>'
   +'<button class="pvb" id="pvfwd" title="Forward  ( ⌥→ )" style="display:none">↪</button>'
   +'<button class="pvb" id="pvprev" title="Previous page  ( [ or arrow )">‹</button>'
   +'<span class="pvpg" id="pvpg" tabindex="0" role="button" title="Go to page — click or press g, then type a number or pick from the list"></span>'
   +'<input class="pvjump" id="pvjump" type="text" inputmode="numeric" list="pvjumplist" autocomplete="off" style="display:none" title="Type a page number, or scroll the list, then Enter">'
   +'<datalist id="pvjumplist"></datalist>'
   +'<button class="pvb" id="pvnext" title="Next page  ( ] or arrow )">›</button>'
   +(doc?'<button class="pvb" id="pvmode" title="Continuous scroll ⇄ single page (c)">↕</button>':'')
   +'<select class="pvppv" id="pvppvsel" title="Pages side by side"></select>'
   +(doc?'<button class="pvb" id="pvbook" title="Two-page book spread — cover alone, then 2·3, 4·5 (b)">◫</button>':'')
   +'<span class="pvsp" id="pvsp"></span>'
   +'<button class="pvb" id="pvinfo" title="File info (i)">ⓘ</button>'
   +(doc?'<button class="pvb" id="pvtheme" title="Reading theme: Day / Night / Sepia (t)">◐</button>':'')
   +'<button class="pvb" id="pvzo" title="Zoom out (−)">−</button>'
   +'<button class="pvb" id="pvfit" title="Fit width / Fit page">⤢</button>'
   +'<button class="pvb" id="pvzi" title="Zoom in (+)">+</button>'
   +'<button class="pvb" id="pvrot" title="Rotate 90° (l)">⟳</button>'
   +'<button class="pvb" id="pvovbtn" title="All pages — contact sheet (o)">⊞</button>'
   +'<button class="pvb" id="pvrailtog" title="Show / hide page thumbnails (h)">▦</button>'
   +(doc&&READER_HTML?'<button class="pvb" id="pvedit" title="Turn this pane into the full editor: sign, fill, annotate, edit and export, right here">Edit here</button>':'')
   +(doc?'<button class="pvb" id="pvrdr" title="Open in PageHawk Reader — sign, fill, annotate, page tools (r)">Open in Reader</button>':'')
   +'<button class="pvb" id="pvfocus" title="Focus mode — dim everything but the page (z)">⛶</button>'
   +'<button class="pvb" id="pvpresent" title="Presentation mode — full-screen, one page at a time (⇧P)">⧉</button>'
   +'<button class="pvb" id="pvfull" title="Open full view (Space / F)">⤢ Full</button>'
   +'</div>'
   +'<div class="pvmain" id="pvmain">'
   +'<div class="pvstagewrap">'
   +'<div class="pvstage" id="pvstage"><div class="pvspin"></div></div>'
   +'<div class="pvprog" id="pvprog"><div class="pvprogfill" id="pvprogfill"></div></div>'
   +'<div class="pvcap"><span class="pvnm" id="pvnm"></span><span class="pvmarks" id="pvmarks"></span><span class="pvpct" id="pvpct" title="Reading progress"></span><span class="pvmeta" id="pvmeta"></span></div>'
   +'<div class="pvresume" id="pvresume"></div>'
   +'<div class="pvtoast" id="pvtoast"></div>'
   +'<div class="pvfocushint" id="pvfocushint" style="display:none">Focus mode &nbsp;·&nbsp; <b>Esc</b> or <b>z</b> to exit</div>'
   +'<div class="pvpreshint" id="pvpreshint" style="display:none">Presentation &nbsp;·&nbsp; click or <b>→</b> advances &nbsp;·&nbsp; <b>Esc</b> exits</div>'
   +'<div class="pvov" id="pvov" style="display:none"></div>'
   +'</div>'
   +'<div class="pvrailwrap" id="pvrailwrap"><div class="pvrailgrip" id="pvrailgrip" title="Drag to resize · double-click to reset"></div>'
   +'<div class="pvrailctl"><button id="pvraildock" title="Dock to the right / bottom">⇄</button><button id="pvrailclose" title="Hide thumbnails (h)">✕</button></div>'
   +'<div class="pvrail" id="pvrail"></div></div>'
   +'</div>';
  el.querySelector('#pvnm').textContent=it.name||'';
  el.querySelector('#pvmeta').textContent=pvFmtMeta(it);
  el.querySelector('#pvprev').onclick=function(){pvPage(-1);};
  el.querySelector('#pvnext').onclick=function(){pvPage(1);};
  var pvbk=el.querySelector('#pvback');if(pvbk)pvbk.onclick=function(){pvNavBack();};
  var pvfw=el.querySelector('#pvfwd');if(pvfw)pvfw.onclick=function(){pvNavForward();};
  el.querySelector('#pvzi').onclick=function(){pvZoom(1.25);};
  el.querySelector('#pvzo').onclick=function(){pvZoom(0.8);};
  el.querySelector('#pvfit').onclick=function(){pvToggleFit();};
  el.querySelector('#pvrot').onclick=function(){pvRotate();};
  var rdr=el.querySelector('#pvrdr');if(rdr)rdr.onclick=function(){pvOpenReader();};
  el.querySelector('#pvfull').onclick=function(){openLB(it.id,PV.page);};
  var pvfc=el.querySelector('#pvfocus');if(pvfc)pvfc.onclick=function(){pvFocusToggle();};
  var pvov=el.querySelector('#pvovbtn');if(pvov)pvov.onclick=function(){pvOverviewToggle();};
  var pvpr=el.querySelector('#pvpresent');if(pvpr)pvpr.onclick=function(){pvPresentToggle();};
  var pvbk2=el.querySelector('#pvbook');if(pvbk2)pvbk2.onclick=function(){pvBookToggle();};
  var md=el.querySelector('#pvmode');if(md)md.onclick=function(){pvToggleMode();};
  var th=el.querySelector('#pvtheme');if(th)th.onclick=function(){pvCycleTheme();};
  el.querySelector('#pvinfo').onclick=function(){pvToggleInfo();};
  el.querySelector('#pvrailtog').onclick=function(){pvRailToggle();};
  el.querySelector('#pvrailclose').onclick=function(){pvRailToggle(false);};
  el.querySelector('#pvraildock').onclick=function(){pvRailDock();};
  pvWireRailGrip(el.querySelector('#pvrailgrip'));
  var jp=el.querySelector('#pvjump'),pgEl=el.querySelector('#pvpg');
  pgEl.onclick=function(){pvOpenJump();};                                  // click the page number to edit it
  pgEl.onkeydown=function(e){if(e.key==='Enter'||e.key===' '){pvOpenJump();e.preventDefault();}};
  jp.onkeydown=function(e){e.stopPropagation();if(e.key==='Enter'){var n=parseInt(jp.value);if(n>=1)pvGoto(n-1);pvCloseJump();e.preventDefault();}else if(e.key==='Escape'){pvCloseJump();e.preventDefault();}};
  jp.onfocus=function(){jp.select();};   // select-all so typing replaces the current page
  jp.onblur=function(){setTimeout(pvCloseJump,120);};   // delay so a dropdown click registers before closing
  jp.onchange=function(){var n=parseInt(jp.value);if(n>=1)pvGoto(n-1);};   // picking from the dropdown jumps
  el.querySelector('#pvppvsel').onchange=function(){PV.ppv=Math.max(1,parseInt(this.value)||1);PV.mode='paged';PV.book=false;try{localStorage.setItem('vt-pvbook','0');}catch(e){}pvBookApply();pvRender();pvUpdateBar();};
  PV.info=false;
  if(doc){ if(pagesCache[it.path]&&pagesCache[it.path].length){PV.pages=pagesCache[it.path];pvMaybeResume();pvRender();pvNavRecord(PV.page);}
    else{pvSinglePage(it.full||it.thumb||'');pvRequestPages(it.path);} }
  else{ PV.pages=null;pvSinglePage((!it.video&&it.full)?it.full:(it.thumb||'')); }
  var stg=el.querySelector('#pvstage');   // P1 #4: right-click the page itself for page tools (covers single-page docs)
  if(stg&&doc){stg.oncontextmenu=function(e){e.preventDefault();pvPageMenu(e,PV.page||0);};}
  pvUpdateBar();pvFocusApply();pvPresentApply();pvBookApply();pvMarksBar();
  var _pve=el.querySelector('#pvedit');
  if(_pve) _pve.onclick=function(){ pvEmbedSet(true); };
  var _pvb=el.querySelector('.pvbar');
  if(_pvb){_pvb.oncontextmenu=function(e){e.preventDefault();e.stopPropagation();tbzMenu(PVBAR_DESC,e.clientX,e.clientY);};tbzApply(PVBAR_DESC);}
}
/* ── Customizable preview toolbar ─────────────────────────────────────────────
   Reorder / hide / restore toolbar buttons + Icons / Text / Icons+text display
   modes. Mirrors the customizable-columns (colCfg) idiom: right-click → menu,
   HTML5 drag to reorder, hidden items live in an inline tray, everything saved
   to localStorage. Display modes are CSS-only (data-label + pseudo-elements) so
   no button innerHTML is touched and every existing click handler stays wired.
   Hidden uses a class with !important because pvUpdateBar sets inline display. */
// Generalized toolbar-customize engine (tbz*): one engine drives any toolbar from a
// descriptor {key, hasDisp, editing, bar(), glued, order, meta}. Two are registered —
// the preview toolbar (PVBAR_DESC) and the browse top bar (TOPBAR_DESC).
var PVBAR_DESC={
  key:'vt-pvbar', hasDisp:true, defDisp:'both', editing:false,
  bar:function(){return PV.el&&PV.el.querySelector('.pvbar');},
  glued:{pvpg:['pvjump','pvjumplist']},
  order:['pvback','pvfwd','pvprev','pvpg','pvnext','pvmode','pvppvsel','pvbook','pvsp','pvinfo','pvtheme','pvzo','pvzi','pvfit','pvrot','pvovbtn','pvrailtog','pvedit','pvrdr','pvfocus','pvpresent','pvfull'],
  meta:{
    pvback:{l:'Back',rm:1,bl:1}, pvfwd:{l:'Forward',rm:1,bl:1},
    pvprev:{l:'Prev',rm:0,bl:1}, pvpg:{l:'Page',rm:0}, pvnext:{l:'Next',rm:0,bl:1},
    pvmode:{l:'Scroll',rm:1,bl:1}, pvppvsel:{l:'Layout',rm:1}, pvbook:{l:'Spread',rm:1,bl:1},
    pvsp:{l:'Flexible Space',rm:1,flex:1}, pvinfo:{l:'Info',rm:1,bl:1}, pvtheme:{l:'Theme',rm:1,bl:1},
    pvzo:{l:'Zoom out',rm:1,bl:1}, pvfit:{l:'Fit',rm:1,bl:1}, pvzi:{l:'Zoom in',rm:1,bl:1},
    pvrot:{l:'Rotate',rm:1,bl:1}, pvovbtn:{l:'All pages',rm:1,bl:1}, pvrailtog:{l:'Thumbnails',rm:1,bl:1},
    pvedit:{l:'Edit here',rm:1}, pvrdr:{l:'Reader',rm:1}, pvfocus:{l:'Focus',rm:1,bl:1}, pvpresent:{l:'Present',rm:1,bl:1},
    pvfull:{l:'Full',rm:1}
  }
};
var TOPBAR_DESC={
  key:'vt-topbar', hasDisp:false, editing:false,
  bar:function(){return document.getElementById('top');},
  glued:{},
  order:['topsize','topview','slidebtn','toprefresh','toptheme'],
  meta:{ topsize:{l:'Size',rm:1}, topview:{l:'View',rm:1}, slidebtn:{l:'Slideshow',rm:1}, toprefresh:{l:'Refresh',rm:1}, toptheme:{l:'Theme',rm:1} }
};
var TBZ_DESCS=[PVBAR_DESC,TOPBAR_DESC];
function tbzCfg(desc){
  var order=null,hidden=[];
  try{order=JSON.parse(localStorage.getItem(desc.key+'-order'));}catch(e){}
  if(!Array.isArray(order))order=null;
  try{hidden=JSON.parse(localStorage.getItem(desc.key+'-hidden'));}catch(e){}
  if(!Array.isArray(hidden))hidden=[];
  var disp=desc.defDisp||'both';
  if(desc.hasDisp){try{disp=localStorage.getItem(desc.key+'-disp')||disp;}catch(e){} if(['icon','text','both'].indexOf(disp)<0)disp='icon';}
  return {order:order,hidden:hidden,disp:disp};
}
function tbzSaveOrder(desc,o){try{localStorage.setItem(desc.key+'-order',JSON.stringify(o));}catch(e){}}
function tbzSaveHidden(desc,h){try{localStorage.setItem(desc.key+'-hidden',JSON.stringify(h));}catch(e){}}
function tbzSaveDisp(desc,d){try{localStorage.setItem(desc.key+'-disp',d);}catch(e){}}
function tbzEffOrder(desc,cfg){
  var base=(cfg.order&&cfg.order.length)?cfg.order.filter(function(id){return desc.meta[id];}):desc.order.slice();
  desc.order.forEach(function(id){if(base.indexOf(id)<0)base.push(id);});
  return base;
}
function tbzApply(desc){
  var bar=desc.bar(); if(!bar)return;
  bar._tbz=desc;
  var cfg=tbzCfg(desc), hidden=cfg.hidden, order=tbzEffOrder(desc,cfg);
  if(desc.hasDisp)bar.setAttribute('data-disp',cfg.disp);
  order.forEach(function(id){
    var m=desc.meta[id], node=bar.querySelector('#'+id);
    if(!m||!node)return;
    node.classList.add('pvitem');
    node.setAttribute('data-id',id); node.setAttribute('data-label',m.l);
    if(desc.hasDisp&&m.bl)node.classList.add('pvbl');
    node.classList.toggle('pvhidden', hidden.indexOf(id)>=0);
  });
  order.forEach(function(id){
    var node=bar.querySelector('#'+id); if(!node)return;
    bar.appendChild(node);
    var g=desc.glued&&desc.glued[id];
    if(g)g.forEach(function(sid){var s=bar.querySelector('#'+sid);if(s)bar.appendChild(s);});
  });
  tbzStripEdit(bar);
  bar.classList.toggle('pvcustomizing',!!desc.editing);
  if(desc.editing)tbzBuildEdit(desc,bar);
}
function tbzStripEdit(bar){
  Array.prototype.forEach.call(bar.querySelectorAll('.rmx,.pvdropind'),function(n){n.remove();});
  var host=bar.parentNode; if(!host)return;   // remove ANY edit UI directly under this host (robust against a rebuilt bar)
  Array.prototype.forEach.call(host.querySelectorAll(':scope > .pvbtray, :scope > .pvbedit'),function(c){c.remove();});
}
function tbzBuildEdit(desc,bar){
  var cfg=tbzCfg(desc);
  if(!bar._pvswallow){bar.addEventListener('click',tbzSwallowClick,true);bar._pvswallow=1;}   // in edit mode a tap grabs; don't fire the control's action
  Array.prototype.forEach.call(bar.querySelectorAll('.pvitem'),function(node){
    var id=node.getAttribute('data-id'), m=desc.meta[id]; if(!m)return;
    node.removeAttribute('draggable');
    node.onpointerdown=function(e){tbzPointerStart(desc,e,id,false,node);};
    if(m.rm&&!node.querySelector('.rmx')){var x=document.createElement('span');x.className='rmx';x.textContent='×';x.title='Hide '+m.l;
      x.onpointerdown=function(ev){ev.stopPropagation();};   // the × badge must not start a drag
      x.onclick=function(ev){ev.stopPropagation();ev.preventDefault();tbzHide(desc,id);};node.appendChild(x);}
  });
  var host=bar.parentNode; if(!host)return;
  var noun=desc.hasDisp?'buttons':'controls';
  var tray=document.createElement('div');tray.className='pvbtray';tray._forbar=bar;
  var lbl='<span class="pvtlbl">Hidden '+noun+' — drag onto the toolbar to add them back</span>';
  var chips='', nchip=0;
  cfg.hidden.forEach(function(id){var m=desc.meta[id];if(!m)return;if(!bar.querySelector('#'+id))return;
    chips+='<span class="pvchip" data-id="'+id+'"><span class="cg">'+(m.flex?'⇿':'▢')+'</span>'+esc(m.l)+'</span>';nchip++;});
  tray.innerHTML=lbl+(nchip?chips:'<span class="pvtempty">Nothing hidden — every '+(desc.hasDisp?'button':'control')+' is on the toolbar.</span>');
  Array.prototype.forEach.call(tray.querySelectorAll('.pvchip'),function(ch){
    ch.onpointerdown=function(e){tbzPointerStart(desc,e,ch.getAttribute('data-id'),true,ch);};});
  var eb=document.createElement('div');eb.className='pvbedit';eb._forbar=bar;
  var seg=desc.hasDisp?'<span class="pvseg" id="pvdispseg"><button data-d="icon">Icons</button><button data-d="text">Text</button><button data-d="both">Icons + text</button></span>':'';
  eb.innerHTML='<span class="pvel"><b>Customizing '+(desc.hasDisp?'toolbar':'top bar')+'.</b> Drag to reorder · drag to the tray to hide · drag back to restore.</span>'+seg
    +'<button class="pveb" id="pvbreset">Reset</button><button class="pveb pri" id="pvbdone">Done</button>';
  host.insertBefore(tray,bar.nextSibling);
  host.insertBefore(eb,tray.nextSibling);
  if(desc.hasDisp)Array.prototype.forEach.call(eb.querySelectorAll('#pvdispseg button'),function(b){
    b.classList.toggle('on',b.getAttribute('data-d')===cfg.disp);
    b.onclick=function(){tbzSaveDisp(desc,b.getAttribute('data-d'));tbzApply(desc);};});
  eb.querySelector('#pvbreset').onclick=function(){tbzSaveOrder(desc,desc.order.slice());tbzSaveHidden(desc,[]);if(desc.hasDisp)tbzSaveDisp(desc,desc.defDisp||'both');tbzApply(desc);};
  eb.querySelector('#pvbdone').onclick=function(){tbzExit(desc);};
}
// Pointer-based drag (WKWebView does not fire HTML5 drag/drop for in-page reordering — it
// starts a native drag ghost instead, so we drive reorder with pointer events).
var pvbarPD=null;
function tbzSwallowClick(e){var bar=e.currentTarget,desc=bar&&bar._tbz;if(!desc||!desc.editing)return;if(e.target.closest&&e.target.closest('.rmx'))return;e.stopPropagation();e.preventDefault();}
function tbzAfterPointer(bar,x){
  var best=null,bd=-Infinity;
  Array.prototype.forEach.call(bar.querySelectorAll('.pvitem:not(.pvdrag):not(.pvhidden)'),function(el){
    var b=el.getBoundingClientRect();var off=x-(b.left+b.width/2);if(off<0&&off>bd){bd=off;best=el;}});
  return best;
}
function tbzPointerStart(desc,e,id,fromTray,node){
  if(e.button&&e.button!==0)return;
  e.preventDefault();
  pvbarPD={desc:desc,id:id,fromTray:fromTray,node:node,moved:false,sx:e.clientX,sy:e.clientY};
  try{node.setPointerCapture&&node.setPointerCapture(e.pointerId);}catch(_){}
  document.addEventListener('pointermove',tbzPointerMove,true);
  document.addEventListener('pointerup',tbzPointerUp,true);
  document.addEventListener('pointercancel',tbzPointerUp,true);
}
function tbzPointerMove(e){
  var d=pvbarPD; if(!d)return;
  if(!d.moved){ if(Math.abs(e.clientX-d.sx)<4&&Math.abs(e.clientY-d.sy)<4)return; d.moved=true; if(!d.fromTray)d.node.classList.add('pvdrag'); }
  e.preventDefault();
  var bar=d.desc.bar(); if(!bar)return;
  if(!d.fromTray){   // live-reorder the real control as the pointer moves
    var after=tbzAfterPointer(bar,e.clientX);
    if(after==null){ if(bar.lastElementChild!==d.node)bar.appendChild(d.node); }
    else if(after!==d.node){ bar.insertBefore(d.node,after); }
  }
  var tray=bar.parentNode&&bar.parentNode.querySelector('.pvbtray');
  if(tray){ var tr=tray.getBoundingClientRect(); var m=d.desc.meta[d.id];
    var over=(e.clientY>=tr.top&&e.clientY<=tr.bottom&&e.clientX>=tr.left&&e.clientX<=tr.right);
    tray.classList.toggle('pvdragover', !!(over&&!d.fromTray&&m&&m.rm)); }
}
function tbzPointerUp(e){
  document.removeEventListener('pointermove',tbzPointerMove,true);
  document.removeEventListener('pointerup',tbzPointerUp,true);
  document.removeEventListener('pointercancel',tbzPointerUp,true);
  var d=pvbarPD; pvbarPD=null; if(!d)return;
  var desc=d.desc, bar=desc.bar(); if(!bar)return;
  if(d.node)d.node.classList.remove('pvdrag');
  var tray=bar.parentNode&&bar.parentNode.querySelector('.pvbtray'); if(tray)tray.classList.remove('pvdragover');
  if(!d.moved)return;   // a tap without movement — ignore (× badge handles removal)
  var m=desc.meta[d.id];
  var overTray=false; if(tray){var tr=tray.getBoundingClientRect();overTray=(e.clientY>=tr.top-6&&e.clientY<=tr.bottom+6&&e.clientX>=tr.left-6&&e.clientX<=tr.right+6);}
  var br=bar.getBoundingClientRect(); var overBar=(e.clientY>=br.top-8&&e.clientY<=br.bottom+10&&e.clientX>=br.left-8&&e.clientX<=br.right+8);
  if(!d.fromTray){
    if(overTray&&m&&m.rm){ tbzHide(desc,d.id); return; }
    var order=[]; Array.prototype.forEach.call(bar.querySelectorAll('.pvitem'),function(n){var nid=n.getAttribute('data-id');if(nid)order.push(nid);});
    tbzSaveOrder(desc,order); tbzApply(desc);
  } else if(overBar){
    tbzDropInBar(desc,d.id, tbzAfterPointer(bar,e.clientX));
  }
}
function tbzDropInBar(desc,id,afterEl){
  var cfg=tbzCfg(desc);var order=tbzEffOrder(desc,cfg).filter(function(x){return x!==id;});
  var pos=order.length;
  if(afterEl){var aid=afterEl.getAttribute('data-id');var i=order.indexOf(aid);if(i>=0)pos=i;}
  order.splice(pos,0,id);tbzSaveOrder(desc,order);
  tbzSaveHidden(desc,cfg.hidden.filter(function(x){return x!==id;}));
  tbzApply(desc);
}
function tbzHide(desc,id){var cfg=tbzCfg(desc);if(cfg.hidden.indexOf(id)<0){var h=cfg.hidden.slice();h.push(id);tbzSaveHidden(desc,h);}tbzApply(desc);}
function tbzEnter(desc){TBZ_DESCS.forEach(function(o){if(o!==desc){o.editing=false;if(o.bar())tbzApply(o);}});desc.editing=true;tbzApply(desc);}
function tbzExit(desc){desc.editing=false;tbzApply(desc);}
function tbzMenu(desc,x,y){
  tbzCloseMenu();var cfg=tbzCfg(desc);
  var m=document.createElement('div');m.className='pvbmenu';m.id='pvbmenu';
  function ck(on){return on?'<span class="mck">✓</span>':'';}
  var html='<button data-c="cust">⚙︎ Customize '+(desc.hasDisp?'Toolbar':'Top Bar')+'…</button><div class="msep"></div>';
  if(desc.hasDisp){html+='<button data-c="icon">Icons only'+ck(cfg.disp==='icon')+'</button>'
    +'<button data-c="text">Text only'+ck(cfg.disp==='text')+'</button>'
    +'<button data-c="both">Icons and text'+ck(cfg.disp==='both')+'</button><div class="msep"></div>';}
  html+='<button data-c="reset">↺ Reset to Default</button>';
  m.innerHTML=html;
  document.body.appendChild(m);
  m.style.left=Math.min(x,innerWidth-210)+'px';m.style.top=Math.min(y,innerHeight-210)+'px';
  m.addEventListener('click',function(e){var b=e.target.closest('button');if(!b)return;var c=b.getAttribute('data-c');tbzCloseMenu();
    if(c==='cust'){tbzEnter(desc);}
    else if(c==='reset'){tbzSaveOrder(desc,desc.order.slice());tbzSaveHidden(desc,[]);if(desc.hasDisp)tbzSaveDisp(desc,desc.defDisp||'both');tbzApply(desc);}
    else{tbzSaveDisp(desc,c);tbzApply(desc);}});
  setTimeout(function(){document.addEventListener('mousedown',tbzMenuOutside,true);},0);
}
function tbzMenuOutside(e){if(!e.target.closest('#pvbmenu'))tbzCloseMenu();}
function tbzCloseMenu(){var m=document.getElementById('pvbmenu');if(m)m.remove();document.removeEventListener('mousedown',tbzMenuOutside,true);}
function tbzInitTop(){var t=document.getElementById('top'); if(!t||!document.getElementById('topsize'))return;
  if(!t._tbzinit){t._tbzinit=1; t.addEventListener('contextmenu',function(e){if(e.target.closest('#vmenu')||e.target.closest('.pvbmenu'))return;e.preventDefault();e.stopPropagation();tbzMenu(TOPBAR_DESC,e.clientX,e.clientY);});}
  tbzApply(TOPBAR_DESC);}
function pvToggleMode(){PV.mode=(PV.mode==='scroll')?'paged':'scroll';try{localStorage.setItem('vt-pvmode',PV.mode);}catch(e){}
  if(PV.mode==='scroll'&&PV.book){PV.book=false;try{localStorage.setItem('vt-pvbook','0');}catch(e){}pvBookApply();}   // scroll supersedes the book spread
  PV.zoom=1;PV.panX=0;PV.panY=0;pvRender();pvUpdateBar();}
function pvCycleTheme(){var seq=['day','night','sepia'];PV.theme=seq[(seq.indexOf(PV.theme)+1)%3];try{localStorage.setItem('vt-pvtheme',PV.theme);}catch(e){}pvApplyTheme();}
function pvApplyTheme(){var s=pvStage();if(!s)return;s.classList.remove('night','sepia');if(PV.theme==='night')s.classList.add('night');else if(PV.theme==='sepia')s.classList.add('sepia');
  var b=PV.el&&PV.el.querySelector('#pvtheme');if(b){b.textContent=(PV.theme==='night'?'🌙':(PV.theme==='sepia'?'◑':'◐'));b.classList.toggle('on',PV.theme!=='day');}}
function pvInfoHTML(it){var rows=[['Name',it.name||''],['Kind',it.category||''],['Type',fileExt(it.name)||'—']];
  if(PV.pages&&PV.pages.length)rows.push(['Pages',String(PV.pages.length)]);
  if(it.dims)rows.push(['Dimensions',it.dims]);
  if(it.size)rows.push(['Size',fmtBytes(it.size)]);
  if(it.mtime)rows.push(['Modified',fmtMTime(it.mtime)]);
  if(it.exif&&it.exif.date)rows.push(['Date taken',String(it.exif.date)]);
  if(it.exif&&it.exif.gps)rows.push(['Location',String(it.exif.gps)]);
  if(it.path)rows.push(['Where',prettyDir(it.path)]);
  return rows.map(function(r){return '<div class="pir"><span class="pik">'+esc(r[0])+'</span><span class="piv">'+esc(r[1])+'</span></div>';}).join('');}
function pvToggleInfo(){PV.info=!PV.info;var s=pvStage();if(!s)return;var ex=s.querySelector('.pvinfo');
  if(!PV.info){if(ex)ex.classList.remove('on');var bb=PV.el.querySelector('#pvinfo');if(bb)bb.classList.remove('on');return;}
  if(!ex){ex=document.createElement('div');ex.className='pvinfo';s.appendChild(ex);}
  ex.innerHTML=pvInfoHTML(PV.item);ex.classList.add('on');var bb=PV.el.querySelector('#pvinfo');if(bb)bb.classList.add('on');}
function pvSinglePage(src){var s=pvStage();if(!s)return;
  s.classList.remove('hasscroll');s.style.setProperty('--pvn',1);
  s.innerHTML='<div class="pvspin"></div>'+(src?'<img id="pvimg" src="'+src+'">':'<div class="pvempty">No preview available for this file</div>');
  pvApplyTransform();pvApplyTheme();pvReshowInfo();pvRail();}
function pvRender(){var s=pvStage();if(!s||!PV.pages||!PV.pages.length)return;
  if(PV.page<0)PV.page=0;if(PV.page>=PV.pages.length)PV.page=PV.pages.length-1;
  if(PV.mode==='scroll'&&PV.pages.length>1){pvRenderScroll();return;}
  s.classList.remove('hasscroll');
  var start,n;
  if(PV.book&&!PV.present){PV.page=pvBookStart(PV.page);start=PV.page;n=(start===0?1:Math.min(2,PV.pages.length-start));}   // cover alone, then 2·3, 4·5
  else{start=PV.page;n=Math.max(1,Math.min(PV.ppv,5,PV.pages.length-PV.page));}
  s.style.setProperty('--pvn',n);
  var h='<div class="pvspin"></div><img id="pvimg" src="'+(PV.pages[start]||'')+'">';
  for(var i=1;i<n;i++)h+='<img class="pvpg2" src="'+(PV.pages[start+i]||'')+'">';
  s.innerHTML=h;pvApplyTransform();pvApplyTheme();pvReshowInfo();pvRail();}
// Book-spread page alignment: page 0 (the cover) shows alone; thereafter pages pair up as
// 1·2, 3·4, 5·6 … so a two-page spread reads like an open book. Returns the spread's start index.
function pvBookStart(p){return p<=0?0:(p-((p-1)%2));}
// Continuous-scroll: stack every page in a scroller; the page indicator follows the scroll.
function pvRenderScroll(){var s=pvStage();if(!s||!PV.pages)return;
  s.classList.add('hasscroll');s.style.removeProperty('--pvn');
  var h='<div class="pvspin"></div><div class="pvscroll" id="pvscroll" style="--pvz:'+PV.zoom+'">';
  for(var i=0;i<PV.pages.length;i++)h+='<img class="pvscrollpg" data-p="'+i+'" loading="lazy" src="'+(PV.pages[i]||'')+'">';
  h+='</div>';s.innerHTML=h;
  var sc=s.querySelector('#pvscroll');
  // Reserve a per-page height estimate (portrait letter) so offsetTop is correct BEFORE the lazy
  // images load. Without this, scrolling to any page >1 lands at the top because unloaded images
  // are 0px tall. Each image clears its estimate on load; we re-snap to the target once it lays out.
  var estH=Math.max(140,Math.round((sc.clientWidth||500)*11/8.5));
  Array.prototype.forEach.call(sc.querySelectorAll('.pvscrollpg'),function(im){im.style.minHeight=estH+'px';
    im.addEventListener('load',function(){im.style.minHeight='';},{once:true});});
  sc.addEventListener('scroll',pvScrollTrack,{passive:true});
  pvApplyTheme();pvReshowInfo();pvRail();
  pvScrollToPage(PV.page,true);
  var tgt=sc.querySelector('.pvscrollpg[data-p="'+PV.page+'"]');
  if(tgt&&PV.page>0)tgt.addEventListener('load',function(){pvScrollToPage(PV.page,true);},{once:true});}
function pvScrollTrack(){if(PV.scrollRAF)return;PV.scrollRAF=requestAnimationFrame(function(){PV.scrollRAF=0;
  if(PV._prog){PV._prog=false;return;}   // ignore the scroll event our own programmatic jump just fired
  var sc=PV.el&&PV.el.querySelector('#pvscroll');if(!sc)return;var top=sc.scrollTop+sc.clientHeight*0.18,best=0,bd=1e15;
  Array.prototype.forEach.call(sc.querySelectorAll('.pvscrollpg'),function(im){var pp=parseInt(im.dataset.p);if(isNaN(pp))return;var d=Math.abs(im.offsetTop-top);if(d<bd){bd=d;best=pp;}});
  if(best!==PV.page){PV.page=best;pvUpdateBar();pvRailSel();}});}
function pvScrollToPage(i,instant){if(!PV.pages)return;PV.page=Math.max(0,Math.min(i,PV.pages.length-1));
  var sc=PV.el&&PV.el.querySelector('#pvscroll');if(sc){var im=sc.querySelector('.pvscrollpg[data-p="'+PV.page+'"]');if(im){PV._prog=true;sc.scrollTop=im.offsetTop;}}
  pvUpdateBar();pvRailSel();}
function pvReshowInfo(){if(PV.info){var s=pvStage();if(!s)return;var ex=s.querySelector('.pvinfo');if(!ex){ex=document.createElement('div');ex.className='pvinfo on';s.appendChild(ex);}ex.innerHTML=pvInfoHTML(PV.item);ex.classList.add('on');}}
function pvApplyTransform(){var s=pvStage();if(!s)return;
  if(s.classList.contains('hasscroll')){var sc=s.querySelector('#pvscroll');if(sc)sc.style.setProperty('--pvz',PV.zoom);return;}
  var img=s.querySelector('#pvimg');if(!img)return;
  var d=((PV.rot%360)+360)%360;
  img.style.transformOrigin='center center';
  img.style.transform='translate('+PV.panX+'px,'+PV.panY+'px) scale('+PV.zoom+')'+(d?(' rotate('+d+'deg)'):'');
  img.style.cursor=(PV.zoom>1?'grab':'');
  s.classList.toggle('fitw',PV.fit==='width'&&PV.zoom<=1.001&&(!PV.pages||PV.pages.length<=1));}
function pvZoom(f){PV.zoom=Math.min(6,Math.max(1,PV.zoom*f));if(PV.zoom<=1.001){PV.zoom=1;PV.panX=0;PV.panY=0;}PV.fit='free';pvApplyTransform();}
function pvToggleFit(){PV.fit=(PV.fit==='width')?'page':'width';PV.zoom=1;PV.panX=0;PV.panY=0;pvApplyTransform();}
function pvRotate(){if(!PV.item)return;PV.rot=(PV.rot+90)%360;rots[PV.item.path]=PV.rot;try{localStorage.setItem('vt-rots-'+DATA.task,JSON.stringify(rots));}catch(e){}pvApplyTransform();}
function pvPage(d){if(!PV.pages||PV.pages.length<2)return;
  if(PV.mode==='scroll'){pvScrollToPage(PV.page+d);return;}
  if(PV.book&&!PV.present){var s=pvBookStart(PV.page);var t=(d>0?(s===0?1:s+2):(s<=1?0:s-2));
    PV.page=Math.max(0,Math.min(t,PV.pages.length-1));PV.zoom=1;PV.panX=0;PV.panY=0;pvRender();pvUpdateBar();pvNavRecord(PV.page);return;}
  var n=Math.max(1,Math.min(PV.ppv,PV.pages.length));
  if(d>0){if(PV.page+n<PV.pages.length)PV.page+=n;}else{PV.page=Math.max(0,PV.page-n);}PV.zoom=1;PV.panX=0;PV.panY=0;pvRender();pvUpdateBar();pvNavRecord(PV.page);}
function pvGoto(i){if(!PV.pages||!PV.pages.length)return;
  if(PV.mode==='scroll'){pvScrollToPage(i);return;}
  PV.page=Math.max(0,Math.min(i,PV.pages.length-1));PV.zoom=1;PV.panX=0;PV.panY=0;pvRender();pvUpdateBar();pvNavRecord(PV.page);}
// Navigation history (back/forward) — a per-document jump stack, like Acrobat "Previous View" /
// Sumatra Alt+←/→. Every page step or jump records a view; Alt+←/→, the toolbar ↩/↪, or ⌘K walk it.
// The most-requested-yet-missing feature across PDF readers (Sioyek/Zotero/Sumatra research, 2026-06-30).
function pvNavRecord(p){if(PV._nav||typeof p!=='number')return;
  if(PV.hist.length&&PV.hist[PV.histIdx]===p)return;
  PV.hist=PV.hist.slice(0,PV.histIdx+1);PV.hist.push(p);
  if(PV.hist.length>200){PV.hist.shift();}PV.histIdx=PV.hist.length-1;pvNavBtns();}
function pvNavGo(idx){if(!PV.pages||idx<0||idx>=PV.hist.length)return;
  PV._nav=true;PV.histIdx=idx;var p=Math.max(0,Math.min(PV.hist[idx],PV.pages.length-1));
  if(PV.mode==='scroll'){pvScrollToPage(p);}else{PV.page=p;PV.zoom=1;PV.panX=0;PV.panY=0;pvRender();}
  PV._nav=false;pvUpdateBar();pvNavBtns();}
function pvNavBack(){if(PV.histIdx>0)pvNavGo(PV.histIdx-1);}
function pvNavForward(){if(PV.histIdx<PV.hist.length-1)pvNavGo(PV.histIdx+1);}
function pvNavBtns(){if(!PV.el)return;var b=PV.el.querySelector('#pvback'),f=PV.el.querySelector('#pvfwd');
  var multi=!!(PV.pages&&PV.pages.length>1);
  if(b){b.style.display=multi?'':'none';b.disabled=!(PV.histIdx>0);}
  if(f){f.style.display=multi?'':'none';f.disabled=!(PV.histIdx<PV.hist.length-1);}}
// ── Reading progress + resume-last-page ─────────────────────────────────────────────────────
// An ambient gold progress bar under the page + a quiet % in the caption; each document's reading
// position is saved (one global map keyed by absolute path, so it survives across folders/tasks and
// sessions) and restored next time the doc opens, with a transient "Resumed · Start over" chip.
// Engine-only, persisted, verified headlessly (scratchpad/cdp_readprog.py). 2026-06-30.
var PVPOS_KEY='vt-pvpos';
var pvPos=(function(){try{return JSON.parse(localStorage.getItem(PVPOS_KEY)||'{}')||{};}catch(e){return {};}})();
function pvPosSave(path,page,len){if(!path||typeof page!=='number'||!len||len<2)return;
  pvPos[path]={page:page,len:len,ts:Date.now()};
  var ks=Object.keys(pvPos);if(ks.length>400){ks.sort(function(a,b){return (pvPos[a].ts||0)-(pvPos[b].ts||0);});for(var i=0;i<ks.length-400;i++)delete pvPos[ks[i]];}
  try{localStorage.setItem(PVPOS_KEY,JSON.stringify(pvPos));}catch(e){}}
function pvPosGet(path){var r=path&&pvPos[path];return (r&&typeof r.page==='number')?r:null;}
// Restore the saved page exactly once per document open, BEFORE the first render; show the chip.
function pvMaybeResume(){if(PV._resumed||!PV.item||!PV.pages||PV.pages.length<2)return;PV._resumed=true;
  var r=pvPosGet(PV.item.path);if(!r||r.page<=0||r.page>=PV.pages.length)return;
  PV.page=r.page;pvResumeChip(r.page+1);}
// Ambient progress bar + caption % + persist position. Called from pvUpdateBar (every page change).
function pvProgUpdate(){if(!PV.el)return;
  var wrap=PV.el.querySelector('#pvprog'),fill=PV.el.querySelector('#pvprogfill'),pct=PV.el.querySelector('#pvpct');
  var len=(PV.pages&&PV.pages.length)||0,multi=len>1;
  // NB: set an explicit display, never '' — the CSS default for #pvprog/#pvpct is display:none, so ''
  // would revert to hidden instead of showing them.
  if(wrap)wrap.style.display=multi?'block':'none';
  if(multi){var f=Math.max(0,Math.min(100,Math.round(((PV.page+1)/len)*100)));
    if(fill)fill.style.width=f+'%';
    if(pct){pct.textContent=f+'%';pct.style.display='inline-block';}
    if(PV.item){var prev=pvPos[PV.item.path];if(!prev||prev.page!==PV.page)pvPosSave(PV.item.path,PV.page,len);}   // dedup: write only on real movement
  }else if(pct){pct.style.display='none';}}
function pvResumeChip(pageNum){var c=PV.el&&PV.el.querySelector('#pvresume');if(!c)return;
  c.innerHTML='<span>Resumed on page '+pageNum+'</span><button class="pvrz" id="pvrzbtn" title="Jump back to page 1">Start over</button>';
  var b=c.querySelector('#pvrzbtn');if(b)b.onclick=function(){pvGoto(0);pvResumeHide();};
  c.classList.add('on');if(PV._rzTimer)clearTimeout(PV._rzTimer);PV._rzTimer=setTimeout(pvResumeHide,4200);}
function pvResumeHide(){var c=PV.el&&PV.el.querySelector('#pvresume');if(c)c.classList.remove('on');if(PV._rzTimer){clearTimeout(PV._rzTimer);PV._rzTimer=null;}}
// ── Focus / Zen reading mode ────────────────────────────────────────────────────────────────
// Promote the live preview pane (PV.el = #listprev / #colprev / #fsmain) to a clean full-window
// reading surface that covers the file list, sidebar, and app chrome so only the document is in
// view. Toggle with the toolbar ⛶, the 'z' key, or ⌘K; Esc exits. Persists across doc switches
// within a view (pvShow re-applies it); auto-exits on a view change. Engine-only. 2026-06-30.
function pvFocusActive(){return !!(PV.focus&&PV.active);}
function pvFocusToggle(force){var want=(force===undefined)?!PV.focus:!!force;
  if(want&&!PV.active)return;   // nothing to focus
  PV.focus=want;pvFocusApply();
  if(want)setTimeout(function(){pvApplyTransform();},30);}   // re-fit the page to the larger surface
function pvFocusApply(){var on=!!(PV.focus&&PV.active);
  document.body.classList.toggle('pv-focus',on);
  if(PV.el){PV.el.classList.toggle('pvfocused',on);
    var b=PV.el.querySelector('#pvfocus');if(b){b.classList.toggle('on',!!PV.focus);b.title=(PV.focus?'Exit focus mode (z / Esc)':'Focus mode — dim everything but the page (z)');}
    var h=PV.el.querySelector('#pvfocushint');if(h)h.style.display=on?'':'none';}}
window.pvFocusToggle=pvFocusToggle;window.pvFocusActive=pvFocusActive;
// ── Presentation mode ───────────────────────────────────────────────────────────────────────
// A chromeless, dark, one-page-at-a-time slide deck of the CURRENT document. Distinct from the
// file slideshow (auto-advance across files) and the lightbox (full-screen WITH chrome): this
// strips ALL chrome, goes dark, forces single-page, and advances on click / → / ↓ / Space (←/↑
// back), Esc / ⇧P exits. Forces paged single-page while active and restores the prior mode/ppv on
// exit. Auto-exits on a doc switch (pvShow resets PV.present). Engine-only. 2026-06-30.
function pvPresentActive(){return !!(PV.present&&PV.active);}
function pvPresentToggle(force){var want=(force===undefined)?!PV.present:!!force;
  if(want&&!PV.active)return;   // nothing to present
  if(want){PV._presMode=PV.mode;PV._presPpv=PV.ppv;PV._presBook=PV.book;PV.mode='paged';PV.ppv=1;PV.book=false;}   // single page, no scroll/spread
  else if(PV.present){PV.mode=PV._presMode||PV.mode;PV.ppv=PV._presPpv||PV.ppv;PV.book=!!PV._presBook;}
  PV.present=want;PV.zoom=1;PV.panX=0;PV.panY=0;pvPresentApply();pvRender();pvUpdateBar();
  if(want)setTimeout(function(){pvApplyTransform();},30);}   // re-fit the page to the full surface
function pvPresentApply(){var on=!!(PV.present&&PV.active);
  document.body.classList.toggle('pv-present',on);
  if(PV.el){PV.el.classList.toggle('pvpresented',on);
    var b=PV.el.querySelector('#pvpresent');if(b){b.classList.toggle('on',!!PV.present);b.title=(PV.present?'Exit presentation (⇧P / Esc)':'Presentation mode — full-screen, one page at a time (⇧P)');}
    var h=PV.el.querySelector('#pvpreshint');if(h)h.style.display=on?'block':'none';   // explicit, not '' (CSS default is none)
    var s=pvStage();if(s)s.onclick=on?function(ev){if(ev&&ev.target&&ev.target.closest&&ev.target.closest('.pvb'))return;pvPage(1);}:null;}}
window.pvPresentToggle=pvPresentToggle;window.pvPresentActive=pvPresentActive;
// ── Book / two-page spread ──────────────────────────────────────────────────────────────────
// The preview already does N-up via the pages-per-view control; book mode is the cover-aware twin:
// it shows the cover (page 1) alone, then true 1·2-style spreads (pages 2·3, 4·5 …) so a document
// reads like an open book. A paged-mode layout toggle; persisted; superseded while presenting (which
// forces single page) and turned off by continuous-scroll or picking an explicit pages-per-view.
function pvBookActive(){return !!(PV.book&&PV.active);}
function pvBookToggle(force){var want=(force===undefined)?!PV.book:!!force;
  if(want&&(!PV.pages||PV.pages.length<2))return;
  PV.book=want;try{localStorage.setItem('vt-pvbook',want?'1':'0');}catch(e){}
  if(want){PV.mode='paged';PV.page=pvBookStart(PV.page);}
  PV.zoom=1;PV.panX=0;PV.panY=0;pvBookApply();pvRender();pvUpdateBar();}
function pvBookApply(){var b=PV.el&&PV.el.querySelector('#pvbook');if(b){b.classList.toggle('on',!!PV.book);
  b.title=(PV.book?'Single page (b)':'Two-page book spread — cover alone, then 2·3, 4·5 (b)');}}
window.pvBookToggle=pvBookToggle;window.pvBookActive=pvBookActive;
// ── Document marks (a–z), Sioyek-style ──────────────────────────────────────────────────────
// Drop a named mark on the current page with 'm' then a letter; jump back with backtick (`) then
// the letter. Per-document, persisted (one global map keyed by absolute path). Set marks also show
// as clickable chips in the caption. Engine-only, verified headlessly (cdp_marks.py). 2026-06-30.
var PVMARKS_KEY='vt-pvmarks';
var pvMarks=(function(){try{return JSON.parse(localStorage.getItem(PVMARKS_KEY)||'{}')||{};}catch(e){return {};}})();
function pvMarksFor(path){return (path&&pvMarks[path])||{};}
function pvMarkSet(letter){if(!PV.item||!PV.pages||PV.pages.length<2)return;
  var p=PV.item.path;if(!pvMarks[p])pvMarks[p]={};pvMarks[p][letter]=PV.page;
  try{localStorage.setItem(PVMARKS_KEY,JSON.stringify(pvMarks));}catch(e){}
  pvFlash('Mark “'+letter+'” set · page '+(PV.page+1));pvMarksBar();}
function pvMarkGoto(letter){if(!PV.item||!PV.pages)return;var m=pvMarksFor(PV.item.path);
  if(typeof m[letter]!=='number'){pvFlash('No mark “'+letter+'”');return;}
  var pg=Math.max(0,Math.min(m[letter],PV.pages.length-1));pvGoto(pg);pvFlash('Mark “'+letter+'” · page '+(pg+1));}
function pvMarksBar(){var bar=PV.el&&PV.el.querySelector('#pvmarks');if(!bar)return;
  var m=PV.item?pvMarksFor(PV.item.path):{};var ls=Object.keys(m).filter(function(k){return /^[a-z]$/.test(k);}).sort();
  if(!ls.length){bar.innerHTML='';bar.style.display='none';return;}
  bar.style.display='inline-flex';   // explicit, not '' (the CSS default for #pvmarks is display:none)
  bar.innerHTML='<span class="pvmklbl">Marks</span>'+ls.map(function(l){return '<button class="pvmk" data-l="'+l+'" title="Jump to mark ‘'+l+'’ (page '+(m[l]+1)+')  ·  type  ` then '+l+'">'+l+'<span class="pvmkp">'+(m[l]+1)+'</span></button>';}).join('');
  Array.prototype.forEach.call(bar.querySelectorAll('.pvmk'),function(b){b.onclick=function(){pvMarkGoto(b.dataset.l);};});}
function pvFlash(msg){var t=PV.el&&PV.el.querySelector('#pvtoast');if(!t)return;
  t.textContent=msg;t.classList.add('on');if(PV._toastTimer)clearTimeout(PV._toastTimer);
  PV._toastTimer=setTimeout(function(){var x=PV.el&&PV.el.querySelector('#pvtoast');if(x)x.classList.remove('on');PV._toastTimer=null;},1800);}
window.pvMarkSet=pvMarkSet;window.pvMarkGoto=pvMarkGoto;
// ── Page-overview grid (contact sheet) ──────────────────────────────────────────────────────
// A full grid of every page thumbnail over the reading surface; click a page to jump there and close.
// Toggle with the toolbar ⊞, the 'o' key, or ⌘K; Esc / o / clicking a page closes it. The current page
// is highlighted and scrolled into view. Engine-only, verified headlessly (cdp_overview.py). 2026-06-30.
function pvOverviewActive(){return !!(PV.overview&&PV.active);}
function pvOverviewToggle(force){var want=(force===undefined)?!PV.overview:!!force;
  if(want&&(!PV.pages||PV.pages.length<2))return;
  PV.overview=want;pvOverviewApply();}
function pvOverviewApply(){var ov=PV.el&&PV.el.querySelector('#pvov');if(!ov)return;
  var b=PV.el.querySelector('#pvovbtn');
  if(!(PV.overview&&PV.pages&&PV.pages.length>1)){ov.style.display='none';ov.innerHTML='';if(b)b.classList.remove('on');return;}
  var h='<div class="pvovhd"><span>All pages · '+PV.pages.length+'</span><button class="pvb" id="pvovx" title="Close (Esc / o)">✕</button></div><div class="pvovgrid">';
  for(var i=0;i<PV.pages.length;i++)h+='<div class="pvovc'+(i===PV.page?' cur':'')+'" data-p="'+i+'"><img loading="lazy" src="'+(PV.pages[i]||'')+'"><span class="pvovn">'+(i+1)+'</span></div>';
  ov.innerHTML=h+'</div>';ov.style.display='flex';if(b)b.classList.add('on');
  ov.querySelector('#pvovx').onclick=function(){pvOverviewToggle(false);};
  Array.prototype.forEach.call(ov.querySelectorAll('.pvovc'),function(c){c.onclick=function(){pvGoto(parseInt(c.dataset.p));pvOverviewToggle(false);};});
  var cur=ov.querySelector('.pvovc.cur');if(cur)setTimeout(function(){try{cur.scrollIntoView({block:'center'});}catch(e){}},20);}
window.pvOverviewToggle=pvOverviewToggle;window.pvOverviewActive=pvOverviewActive;
function pvOpenJump(){if(!PV.el||!PV.pages||PV.pages.length<2)return;var jp=PV.el.querySelector('#pvjump'),pg=PV.el.querySelector('#pvpg');if(!jp)return;
  if(pg)pg.style.display='none';jp.style.display='';jp.value=String(PV.page+1);jp.focus();jp.select();}
function pvCloseJump(){if(!PV.el)return;var jp=PV.el.querySelector('#pvjump'),pg=PV.el.querySelector('#pvpg');if(jp)jp.style.display='none';if(pg&&PV.pages&&PV.pages.length>1)pg.style.display='';}
window.pvOpenJump=pvOpenJump;
function pvUpdateBar(){if(!PV.el)return;var pg=PV.el.querySelector('#pvpg'),jp=PV.el.querySelector('#pvjump'),
   pv=PV.el.querySelector('#pvprev'),nx=PV.el.querySelector('#pvnext'),sel=PV.el.querySelector('#pvppvsel'),md=PV.el.querySelector('#pvmode');
  var len=(PV.pages&&PV.pages.length)||0;var multi=len>1;var scroll=(PV.mode==='scroll');var editing=(jp&&document.activeElement===jp);
  if(pg){ if(multi){var n=(scroll?1:(PV.book?((PV.page<=0)?1:Math.min(2,len-pvBookStart(PV.page))):Math.max(1,Math.min(PV.ppv,len-PV.page))));pg.textContent=(n>1?('Pages '+(PV.page+1)+'–'+(PV.page+n)+' / '+len):('Page '+(PV.page+1)+' / '+len));pg.classList.add('clk');}
    else{pg.classList.remove('clk');if(len===1)pg.textContent='1 page';else pg.textContent=isDocCat(PV.item)?'Rendering…':'';}
    pg.style.display=editing?'none':''; }
  if(pv){pv.style.display=multi?'':'none';pv.disabled=!multi;}
  if(nx){nx.style.display=multi?'':'none';nx.disabled=!multi;}
  if(jp){
    if(multi){var dl=PV.el.querySelector('#pvjumplist');
      if(dl&&dl.dataset.len!==String(len)){var oo='';for(var j=1;j<=len;j++)oo+='<option value="'+j+'"></option>';dl.innerHTML=oo;dl.dataset.len=String(len);}
      if(!editing){jp.value=String(PV.page+1);jp.style.display='none';}
    }else{jp.value='';jp.style.display='none';}}
  if(md){md.style.display=multi?'':'none';md.textContent=(scroll?'▭':'↕');md.classList.toggle('on',scroll);md.title=(scroll?'Single page (c)':'Continuous scroll (c)');}
  if(sel){ if(len>1&&!scroll&&!PV.book){var maxN=Math.min(5,len);if(sel.dataset.max!==String(maxN)){var o='';for(var k=1;k<=maxN;k++)o+='<option value="'+k+'">'+k+(k===1?' pg':' pgs')+'</option>';sel.innerHTML=o;sel.dataset.max=String(maxN);}sel.value=String(Math.max(1,Math.min(PV.ppv,maxN)));sel.style.display='';}else sel.style.display='none'; }
  pvNavBtns();pvProgUpdate();}
function pvRail(){var r=PV.el&&PV.el.querySelector('#pvrail');if(!r)return;
  if(!PV.pages||PV.pages.length<2){r.innerHTML='';r.dataset.path='';pvRailApply();return;}
  if(r.dataset.path!==((PV.item&&PV.item.path)||'')||r.children.length!==PV.pages.length){
    var h='';for(var i=0;i<PV.pages.length;i++)h+='<div class="pvth" data-p="'+i+'"><img loading="lazy" src="'+PV.pages[i]+'"><span class="pvthn">'+(i+1)+'</span></div>';
    r.innerHTML=h;r.dataset.path=(PV.item&&PV.item.path)||'';
    Array.prototype.forEach.call(r.querySelectorAll('.pvth'),function(t){t.onclick=function(){pvGoto(parseInt(t.dataset.p));};
      t.oncontextmenu=function(e){e.preventDefault();e.stopPropagation();pvPageMenu(e,parseInt(t.dataset.p));};
      t.title='Page '+(parseInt(t.dataset.p)+1)+' — click to view, right-click for page tools';});}
  pvRailApply();pvRailSel();}
function pvRailSel(){var r=PV.el&&PV.el.querySelector('#pvrail');if(!r)return;
  Array.prototype.forEach.call(r.querySelectorAll('.pvth'),function(t){t.classList.toggle('sel',parseInt(t.dataset.p)===PV.page);});
  var selth=r.querySelector('.pvth.sel');if(selth)selth.scrollIntoView({inline:'nearest',block:'nearest'});}
// ── Configurable rail: dock bottom/right, resize (grip), collapse/close/reopen, all persisted ──
function pvRailApply(){if(!PV.el)return;var main=PV.el.querySelector('#pvmain'),wrap=PV.el.querySelector('#pvrailwrap');if(!main||!wrap)return;
  var right=(PV.railPos==='right');main.classList.toggle('railright',right);
  if(right){wrap.style.height='';wrap.style.width=PV.railSizeR+'px';}else{wrap.style.width='';wrap.style.height=PV.railSizeB+'px';}
  var hasThumbs=!!(PV.pages&&PV.pages.length>1);
  wrap.classList.toggle('hidden',!(hasThumbs&&PV.railOpen));
  var tog=PV.el.querySelector('#pvrailtog');if(tog){tog.style.display=hasThumbs?'':'none';tog.classList.toggle('on',PV.railOpen);}}
function pvRailToggle(force){PV.railOpen=(force===undefined)?!PV.railOpen:!!force;try{localStorage.setItem('vt-pvrail-show',PV.railOpen?'1':'0');}catch(e){}pvRailApply();}
function pvRailDock(){PV.railPos=(PV.railPos==='right')?'bottom':'right';try{localStorage.setItem('vt-pvrail-pos',PV.railPos);}catch(e){}pvRailApply();pvRailSel();}
function pvWireRailGrip(g){if(!g)return;
  g.onmousedown=function(e){e.preventDefault();var wrap=PV.el&&PV.el.querySelector('#pvrailwrap');if(!wrap)return;
    var right=(PV.railPos==='right');var s0=right?e.clientX:e.clientY;var rc=wrap.getBoundingClientRect();var z0=right?rc.width:rc.height;
    function mm(ev){var cur=right?ev.clientX:ev.clientY;var v=Math.max(46,Math.min(right?440:320,Math.round(z0+(s0-cur))));
      if(right){wrap.style.width=v+'px';PV.railSizeR=v;}else{wrap.style.height=v+'px';PV.railSizeB=v;}}
    function mu(){document.removeEventListener('mousemove',mm,true);document.removeEventListener('mouseup',mu,true);
      try{localStorage.setItem(right?'vt-pvrail-w':'vt-pvrail-h',String(right?PV.railSizeR:PV.railSizeB));}catch(e){}}
    document.addEventListener('mousemove',mm,true);document.addEventListener('mouseup',mu,true);};
  g.ondblclick=function(e){e.stopPropagation();if(PV.railPos==='right'){PV.railSizeR=96;}else{PV.railSizeB=78;}
    try{localStorage.setItem(PV.railPos==='right'?'vt-pvrail-w':'vt-pvrail-h',String(PV.railPos==='right'?96:78));}catch(e){}pvRailApply();};}
function pvRequestPages(path){if(!path)return;PV.reqPath=path;
  if(PV.reqTimer)clearTimeout(PV.reqTimer);
  PV.reqTimer=setTimeout(function(){var st=pvStage();if(st)st.classList.add('loading');
    location.href='hvniz://pages?path='+encodeURIComponent(path);},140);}
function pvOnPages(obj){if(!obj||!obj.path)return;
  if(PV.active&&PV.item&&PV.item.path===obj.path){PV.pages=obj.pages||[];PV.page=Math.min(PV.page,Math.max(0,PV.pages.length-1));pvMaybeResume();
    var st=pvStage();if(st)st.classList.remove('loading');pvRender();pvUpdateBar();pvNavRecord(PV.page);
    var rc=document.querySelector('#listtbl .lrow[data-id="'+PV.item.id+'"] .lc-pages');if(rc&&PV.pages.length)rc.textContent=String(PV.pages.length);}}
function pvOpenReader(){var it=PV.item;if(!it||!it.path)return;
  if(it.category==='PDFs'||it.category==='Decks'||it.category==='Documents')location.href='hvniz://reader?path='+encodeURIComponent(it.path);
  else location.href='hvniz://opendefault?path='+encodeURIComponent(it.path);}
// ── P1 #4: page management — right-click a page thumbnail (or the page) for page tools ──
// Destructive ops (rotate/delete/duplicate) go through the native permission gate
// (confirmFileOp) so they can't happen by accident; extract/split/merge make NEW files.
function pvCloseMenus(){Array.prototype.forEach.call(document.querySelectorAll('.pvpmenu'),function(m){m.remove();});}
function pvPageMenu(ev,pageIdx){
  var it=PV.item; if(!it||!it.path||!isDocCat(it))return;
  var path=it.path, pg=pageIdx+1, multi=(PV.pages&&PV.pages.length>1);
  pvCloseMenus();
  var m=document.createElement('div'); m.className='fsmenu pvpmenu';
  function ep(s){return encodeURIComponent(s);}
  function op(q){location.href='hvniz://pageop?'+q+'&path='+ep(path);}
  function row(label,sub,fn){var d=document.createElement('div');d.className='fsmi';
    d.innerHTML='<div>'+label+'</div>'+(sub?'<div class="fsmsub">'+sub+'</div>':'');
    d.onmousedown=function(e){e.stopPropagation();};d.onclick=function(e){e.stopPropagation();pvCloseMenus();fn();};return d;}
  function sep(){var s=document.createElement('div');s.className='cisep';return s;}
  m.appendChild(row('Rotate page '+pg+' right','',function(){op('op=rotate&deg=90&page='+pg);}));
  m.appendChild(row('Rotate page '+pg+' left','',function(){op('op=rotate&deg=-90&page='+pg);}));
  m.appendChild(row('Duplicate page '+pg,'',function(){op('op=duplicate&page='+pg);}));
  m.appendChild(row('Extract page '+pg+'…','Save as a new PDF',function(){op('op=extract&pages='+pg);}));
  if(multi){m.appendChild(sep());
    m.appendChild(row('Delete page '+pg,'Removes it from the file',function(){op('op=delete&page='+pg);}));}
  m.appendChild(sep());
  m.appendChild(row('Split into pages…','One PDF per page',function(){op('op=split');}));
  m.appendChild(row('Merge with…','Append other PDFs',function(){op('op=merge');}));
  document.body.appendChild(m);
  var x=Math.min(ev.clientX,window.innerWidth-m.offsetWidth-8),y=Math.min(ev.clientY,window.innerHeight-m.offsetHeight-8);
  m.style.left=Math.max(6,x)+'px';m.style.top=Math.max(6,y)+'px';
  setTimeout(function(){document.addEventListener('mousedown',pvCloseMenus,{once:true});
    document.addEventListener('keydown',function k(e){if(e.key==='Escape'){pvCloseMenus();document.removeEventListener('keydown',k,true);}},true);},0);}
// global toast (self-contained, no FM scope dependency) + post-op reload of the mutated doc
var phkToastT=null;
window.phkToast=function(msg,bad){var t=document.getElementById('fmtoast');
  if(!t){t=document.createElement('div');t.id='fmtoast';document.body.appendChild(t);}
  t.textContent=msg;t.className=bad?'bad on':'on';clearTimeout(phkToastT);
  phkToastT=setTimeout(function(){t.className=(t.className.indexOf('bad')>=0?'bad':'');},2200);};
window.phkPageOpDone=function(op,path){ if(!path)return;
  if(typeof pagesCache!=='undefined')pagesCache[path]=null;   // drop stale rendered pages
  if(PV.active&&PV.item&&PV.item.path===path){
    var r=PV.el&&PV.el.querySelector('#pvrail');if(r)r.dataset.path='';   // force rail rebuild
    pvRequestPages(path); } };
// Natural trackpad/wheel paging: ACCUMULATE scroll distance so a short swipe turns ONE page and a
// longer swipe turns a few — instead of one-page-per-event, which flips pages far too fast under
// trackpad momentum (Jason 2026-07-03: "not too fast, swipe down to change a page, swipe long to
// change a few"). Same code path on a Mac trackpad and a PC wheel. A pause starts a fresh gesture;
// a continuous gesture is capped so a hard flick never rockets through the document.
var __phkWAcc=0,__phkWT=0,__phkWGest=0,__PHK_WSTEP=120,__PHK_WCAP=8;
function phkWheelPages(dy){
  var now=(window.performance&&performance.now)?performance.now():+new Date();
  if(now-__phkWT>300){__phkWAcc=0;__phkWGest=0;}   // >300ms idle = new gesture
  __phkWT=now; __phkWAcc+=dy; var out=0;
  while(Math.abs(__phkWAcc)>=__PHK_WSTEP && __phkWGest<__PHK_WCAP){
    out+=(__phkWAcc>0?1:-1); __phkWGest++;
    __phkWAcc-=(__phkWAcc>0?1:-1)*__PHK_WSTEP;
  }
  return out;
}
document.addEventListener('wheel',function(e){if(!PV.active)return;var s=pvStage();if(!s||!s.contains(e.target))return;
  if(PV.mode==='scroll'&&PV.pages&&PV.pages.length>1)return;   // continuous: let the native scroller scroll; pvScrollTrack updates the indicator
  if(PV.zoom>1){e.preventDefault();pvZoom(e.deltaY>0?0.9:1.11);}   // zoomed: wheel keeps zooming
  else if(PV.pages&&PV.pages.length>1){e.preventDefault();var np=phkWheelPages(e.deltaY);if(np)pvGoto(PV.page+np);}   // paged docs: natural accumulate-to-page
  else if(PV.item&&!isDocCat(PV.item)&&!PV.item.video){e.preventDefault();pvZoom(e.deltaY>0?0.9:1.11);}
},{passive:false});
// Drag-to-pan a zoomed preview image (so nothing is cropped out of reach). Mirrors the lightbox.
document.addEventListener('mousedown',function(e){if(!PV.active||PV.zoom<=1)return;var s=pvStage();if(!s||!s.contains(e.target))return;
  var img=s.querySelector('#pvimg');if(!img)return;e.preventDefault();
  var sx=e.clientX,sy=e.clientY,px=PV.panX,py=PV.panY;img.style.cursor='grabbing';
  function mm(ev){PV.panX=px+(ev.clientX-sx);PV.panY=py+(ev.clientY-sy);pvApplyTransform();}
  function mu(){document.removeEventListener('mousemove',mm,true);document.removeEventListener('mouseup',mu,true);var s2=pvStage(),im=s2&&s2.querySelector('#pvimg');if(im)im.style.cursor='grab';}
  document.addEventListener('mousemove',mm,true);document.addEventListener('mouseup',mu,true);});
window.pvShow=pvShow;

buildChips(); render(); syncViewBtn(); if(order.length&&viewMode!=='list')setFoc(order[0]);
Array.prototype.forEach.call(document.querySelectorAll('[data-vm]'),function(b){b.classList.toggle('on',b.dataset.vm===viewMode);});

// ── One-time default-viewer-app offer (SPEC-3 item 1, ADDITIVE). Same hvniz:// bridge and
// Swift-side handler as reader.html's identical block (main.swift "case defaultpdf" etc.) —
// this is the grid/lightbox surface a single opened IMAGE actually renders through (a lone
// image is a 1-item grid, never reader.html). Swift decides whether to show it (not already
// default + never offered before) and pushes window.PHK_BRAND_NAME / PHK_CLAIMS_IMAGES on
// didFinish (same pattern as PHK_REDACT), so the label is brand-correct with no Python change
// needed for future brands. Only offer for a genuinely single-item open — a multi-file grid
// session is a bulk review, not a "just opened this" moment.
(function(){ try{
  if(!DATA.items || DATA.items.length!==1) return;
  window.__phkToast = window.__phkToast || window.phkToast;   // reuse this page's existing toast
  var bar=document.getElementById('defbar'); if(!bar) return;
  var lbl=bar.querySelector('span');
  window.phkOfferDefault=function(held,total){
    if(lbl){
      var name=window.PHK_BRAND_NAME||'PageHawk';
      var what='PDFs';
      if(typeof total==='number'&&total>1){
        what=(typeof held==='number'&&held>0)?('the other '+(total-held)+' document types it opens')
                                             :('every document type it opens');
      } else if(window.PHK_CLAIMS_IMAGES){ what='PDFs and images'; }
      lbl.textContent='Open documents this fast every time: make '+name+' your default for '+what+'?';
    }
    bar.classList.add('on');
  };
  window.phkHideDefault=function(){ bar.classList.remove('on'); bar.classList.remove('busy'); };
  window.__phkDefaultBusy=function(on){ bar.classList.toggle('busy',!!on);
    var y=document.getElementById('defyes'); if(y) y.textContent=on?'Setting\u2026':'Make default'; };
  window.__phkDefaultProgress=function(h,t){ var y=document.getElementById('defyes');
    if(y&&bar.classList.contains('busy')) y.textContent='Setting '+h+'/'+t+'\u2026'; };
  var yes=document.getElementById('defyes'), no=document.getElementById('defno'), x=document.getElementById('defx');
  function close(){ var ck=document.getElementById('defneverck'); var never=ck&&ck.checked;
    bar.classList.remove('on');
    location.href = never ? 'hvniz://defaultnever' : 'hvniz://defaultsnooze'; }
  if(yes) yes.onclick=function(){ location.href='hvniz://setdefaultall'; };
  if(no) no.onclick=close;
  if(x) x.onclick=close;
  // Guard: only probe when the host confirmed it handles the concierge (main.swift sets
  // PHK_DEFAULT_OK). Otherwise the probe leaks to macOS ("no application set to open hvniz://…").
  setTimeout(function(){ if(!window.PHK_DEFAULT_OK) return; try{ location.href='hvniz://defaultpdf'; }catch(e){} },1600);
 }catch(e){ console.warn('default-app offer init',e); }
})();
"""


# Shared "jump to a file or folder" overlay (⌘K / ⌘J / ⌘F). The flip viewer has its
# own inline copy; these power the SAME jump in the grid (single) mode, which never had
# it. Injected as a separate <script> that reuses the grid's globals (items/dec/order/
# setFoc/ensureRendered/render), so GRID_JS itself is untouched.
SEARCH_CSS = """
#search{display:none;position:fixed;inset:0;background:rgba(10,10,12,.5);z-index:60;align-items:flex-start;justify-content:center}
#searchbox{margin-top:12vh;width:min(560px,92vw);background:var(--top-bg);border:1px solid var(--border);border-radius:12px;box-shadow:0 18px 70px rgba(0,0,0,.5);overflow:hidden;backdrop-filter:blur(8px)}
#sinput{width:100%;border:0;outline:0;background:transparent;color:var(--fg);font-family:var(--font);font-size:17px;padding:14px 18px;border-bottom:1px solid var(--border)}
#sresults{max-height:48vh;overflow:auto}
.srow{padding:9px 18px;font-size:14px;color:var(--fg);cursor:pointer;display:flex;justify-content:space-between;gap:12px;align-items:center}
.srow.folder>span:first-child{font-weight:600;color:var(--accent-dark)}
.srow.cmd>span:first-child{color:var(--accent-dark);font-weight:500}
.srow .sfeat{color:var(--mut);font-size:11px;white-space:nowrap}
.srow.son{background:var(--accent);color:var(--accent-fg)}.srow.son .sfeat,.srow.son.folder>span:first-child{color:var(--accent-fg)}
.sempty{padding:14px 18px;color:var(--mut);font-size:13px}
"""
SEARCH_DIV = ('<div id="search"><div id="searchbox"><input id="sinput" type="text" '
              'placeholder="Search files and folders, or run a command… ↑/↓ to choose, Enter to go">'
              '<div id="sresults"></div></div></div>')
GRID_SEARCH_JS = r"""
(function(){
  var sIdx=0, sMatches=[];
  function searchOpen(){var s=document.getElementById("search");return !!s&&s.style.display==="flex";}
  function closeSearch(){var s=document.getElementById("search");if(s)s.style.display="none";}
  // ── Find in THIS document, in the right rail ────────────────────────────────────────────────
  //
  // Jason, 2026-08-19, on the first attempt: "I tried command f and it opened the file in a new
  // tab rather than searching in line. in this space, can we have command f open / replace the
  // right sidebar for search instead?" He is right and the first attempt was wrong: opening a tab
  // to answer "is this word in here" throws away the place you were standing.
  //
  // So Cmd-F takes over the SAME rail the verbs live in, and Escape gives it back exactly as it
  // was. The rail is the right home because it is already the panel that speaks about the current
  // selection, and replacing it costs no layout and no new window.
  //
  // WHAT IT SEARCHES, and why it is not a client-side scan: the preview renders page IMAGES, so
  // there is no text in this page to walk. The shell already owns a real index (the same one
  // behind Search across this folder), so this fires that identical query and keeps only the hits
  // whose path is the document in hand. Reusing the proven rail means OCR-less scans, fuzzy
  // fallback and Pro gating all behave here exactly as they do there, instead of a second search
  // with its own subtly different answers.
  var FIND = { on:false, path:null, name:null, q:'', status:'', hits:null };

  function findTarget(){
    if(typeof PV!=='undefined' && PV && PV.active && PV.item && PV.item.path) return PV.item;
    var items=(typeof selItems==='function')?selItems():[];
    if(items.length===1 && items[0] && items[0].path) return items[0];
    if(typeof foc!=='undefined' && foc!=null && foc>=0 && typeof byId==='function'){
      var it=byId(foc); if(it && it.path) return it;
    }
    return null;
  }

  function findInDocument(){
    var t=findTarget();
    // Nothing to search INSIDE. Fall back to the library palette rather than swallowing the key:
    // a shortcut that silently does nothing reads as broken.
    if(!t){ openSearch(); return; }
    FIND.on=true; FIND.path=t.path; FIND.name=t.name||''; FIND.hits=null; FIND.status='';
    renderFindRail(true);
  }

  function findClose(){
    if(!FIND.on) return false;
    FIND.on=false; FIND.hits=null;
    if(typeof verbRailSync==='function') verbRailSync();
    return true;
  }
  window.__phkFindClose=findClose;

  function renderFindRail(focusInput){
    var r=(typeof buildVerbRail==='function')?buildVerbRail():null;
    if(!r) return;
    r.classList.add('on');
    var h='<div class="vrhd">Find in this document</div>'
        + '<div class="vrnm">'+esc(FIND.name||'this document')+'</div>'
        + '<input id="vrfind" type="search" autocomplete="off" spellcheck="false" '
        +   'placeholder="Find text… (Enter)" value="'+esc(FIND.q)+'">'
        + '<div class="vrfs">'+esc(FIND.status)+'</div>'
        + '<div class="vrfr" id="vrfr"></div>'
        + '<button class="vrx" data-v="__findclose">Back to actions · Esc</button>';
    r.innerHTML=h;
    var inp=r.querySelector('#vrfind');
    if(inp){
      inp.onkeydown=function(e){
        if(e.key==='Enter'){ FIND.q=inp.value; runFindQuery(); e.preventDefault(); }
        else if(e.key==='Escape'){ findClose(); e.preventDefault(); }
        e.stopPropagation();   // the grid's own single-letter shortcuts must not eat typing
      };
      if(focusInput) setTimeout(function(){ try{inp.focus();inp.select();}catch(e){} },0);
    }
    var x=r.querySelector('[data-v="__findclose"]'); if(x) x.onclick=findClose;
    renderFindHits();
  }

  function runFindQuery(){
    var q=(FIND.q||'').trim();
    if(q.length<2){ FIND.status='Type at least 2 characters.'; FIND.hits=null; renderFindRail(false); return; }
    FIND.status='Searching this document…'; FIND.hits=null; renderFindRail(false);
    location.href='hvniz://search?q='+encodeURIComponent(q)+'&mode=exact';
  }

  // Chain rather than replace: the folder-wide search owns this global and must keep working while
  // the rail is open. Clobbering it would break the other surface in a way nothing here tests.
  var _prevSearchResults = window.phkSearchResults;
  window.phkSearchResults = function(r){
    try{ if(typeof _prevSearchResults==='function') _prevSearchResults(r); }catch(e){}
    if(!FIND.on || !r) return;
    var docs=r.docs||[];
    var mine=null;
    for(var i=0;i<docs.length;i++){ if(docs[i] && docs[i].path===FIND.path){ mine=docs[i]; break; } }
    FIND.hits=(mine && mine.hits)?mine.hits:[];
    var scanned=(r.scannedNoText||[]).some(function(sc){ return (sc&&(sc.path||sc))===FIND.path; });
    FIND.status = FIND.hits.length
      ? (FIND.hits.length+' match'+(FIND.hits.length===1?'':'es'))
      : (scanned ? 'This document has no text layer yet — run Make Searchable (OCR) first.'
                 : (r.indexing ? 'Still indexing…' : 'No matches in this document.'));
    renderFindRail(false);
  };

  function renderFindHits(){
    var box=document.getElementById('vrfr'); if(!box) return;
    if(!FIND.hits || !FIND.hits.length){ box.innerHTML=''; return; }
    var h='';
    FIND.hits.slice(0,60).forEach(function(hit,i){
      var pg=(hit && (hit.page||hit.anchor))||'';
      h+='<button class="vrfh" data-i="'+i+'">'
        +  '<span class="vrfp">'+esc(String(pg||'?'))+'</span>'
        +  '<span class="vrfx">'+esc(String(hit.snippet||'').replace(/\s+/g,' ').slice(0,140))+'</span>'
        +'</button>';
    });
    if(FIND.hits.length>60) h+='<div class="vrfs">'+(FIND.hits.length-60)+' more.</div>';
    box.innerHTML=h;
    Array.prototype.forEach.call(box.querySelectorAll('.vrfh'),function(b){
      b.onclick=function(){
        var hit=FIND.hits[+b.dataset.i]; if(!hit) return;
        var pg=parseInt(hit.page,10);
        // Jump the PREVIEW rather than opening anything: staying put is the whole point of this fix.
        if(pg>0 && typeof pvGoto==='function' && typeof PV!=='undefined' && PV.pages && PV.pages.length){
          pvGoto(Math.min(PV.pages.length-1, pg-1));
        }
      };
    });
  }

  function openSearch(){var s=document.getElementById("search");if(!s)return;s.style.display="flex";var inp=document.getElementById("sinput");inp.value="";renderSearch("");inp.focus();}
  function folderCounts(){var c={};items.forEach(function(it){if(it.folder)c[it.folder]=(c[it.folder]||0)+1;});return c;}
  // Command-palette actions (run from ⌘K, the power-user pattern).
  function cmdList(){return [
    {type:'cmd',label:'Grid view',sub:'⌘1',fn:function(){setViewMode('grid');}},
    {type:'cmd',label:'List view',sub:'⌘2',fn:function(){setViewMode('list');}},
    {type:'cmd',label:'Columns view',sub:'⌘3',fn:function(){setViewMode('column');}},
    {type:'cmd',label:'Gallery view',sub:'⌘4',fn:function(){setViewMode('filmstrip');}},
    {type:'cmd',label:'Open in Reader',sub:'R',fn:function(){if(typeof openReaderDoc==='function')openReaderDoc();}},
    {type:'cmd',label:'Search across this folder (full text in every PDF)',sub:'⇧⌘F',fn:function(){if(typeof openCrossSearch==='function')openCrossSearch();}},
    ].concat(window.PHK_REDACT?[{type:'cmd',label:'Redact & clean a document (Pro) — burn out text, strip metadata',sub:'',fn:function(){location.href='hvniz://redact';}}]:[]).concat([
    {type:'cmd',label:'Back (navigation history)',sub:'⌥←',fn:function(){if(typeof pvNavBack==='function')pvNavBack();}},
    {type:'cmd',label:'Forward (navigation history)',sub:'⌥→',fn:function(){if(typeof pvNavForward==='function')pvNavForward();}},
    {type:'cmd',label:'Focus mode (Zen) — dim everything but the page',sub:'Z',fn:function(){if(typeof pvFocusToggle==='function')pvFocusToggle();}},
    {type:'cmd',label:'Set a page mark (then press a–z)',sub:'M',fn:function(){if(typeof PV!=='undefined'&&PV.active&&PV.pages&&PV.pages.length>1){PV._markPending='set';if(typeof pvFlash==='function')pvFlash('Set mark — press a–z (Esc cancels)');}}},
    {type:'cmd',label:'Jump to a page mark (then press a–z)',sub:'`',fn:function(){if(typeof PV!=='undefined'&&PV.active&&PV.pages&&PV.pages.length>1){PV._markPending='goto';if(typeof pvFlash==='function')pvFlash('Go to mark — press a–z (Esc cancels)');}}},
    {type:'cmd',label:'Page overview (contact sheet)',sub:'O',fn:function(){if(typeof pvOverviewToggle==='function')pvOverviewToggle();}},
    {type:'cmd',label:'Presentation mode — full-screen, one page at a time',sub:'⇧P',fn:function(){if(typeof pvPresentToggle==='function')pvPresentToggle();}},
    {type:'cmd',label:'Book spread — two pages, cover aligned',sub:'B',fn:function(){if(typeof pvBookToggle==='function')pvBookToggle();}},
    {type:'cmd',label:'Reading theme: Day / Night / Sepia',sub:'T',fn:function(){if(typeof lbCycleTheme==='function')lbCycleTheme();}},
    {type:'cmd',label:'Cycle color scheme',sub:'',fn:function(){if(typeof toggleTheme==='function')toggleTheme();}},
    {type:'cmd',label:'Start / stop slideshow',sub:'P',fn:function(){if(typeof toggleSlideshow==='function')toggleSlideshow();}},
    {type:'cmd',label:'Choose columns…',sub:'',fn:function(){if(typeof openColMenu==='function')openColMenu();}},
    {type:'cmd',label:'Refresh & recache',sub:'',fn:function(){location.href='hvniz://recache';}}
  ]);}
  function renderSearch(q){q=(q||"").trim().toLowerCase();
    var fc=folderCounts();
    var cmds=cmdList().filter(function(c){return !q||c.label.toLowerCase().indexOf(q)>=0;});
    var fol=Object.keys(fc).filter(function(g){return !q||g.toLowerCase().indexOf(q)>=0;})
      .map(function(g){return {type:'folder',label:g,sub:fc[g]+' file'+(fc[g]===1?'':'s'),folder:g};});
    var fil=items.filter(function(it){return !q||((it.name||'').toLowerCase().indexOf(q)>=0)
        ||(it.features&&Object.keys(it.features).map(function(k){return it.features[k];}).join(' ').toLowerCase().indexOf(q)>=0);})
      .map(function(it){return {type:'file',label:(it.name||''),sub:(dec[it.name]||''),id:it.id};});
    sMatches=cmds.concat(fol).concat(fil); sIdx=0;
    var res=document.getElementById("sresults");
    if(!sMatches.length){res.innerHTML='<div class="sempty">No command, file, or folder matches that</div>';return;}
    res.innerHTML=sMatches.slice(0,250).map(function(m,i){var ic=(m.type==='folder')?'\u{1F4C1} ':(m.type==='cmd'?'› ':'');
      return '<div class="srow '+m.type+(i===0?' son':'')+'" data-i="'+i+'"><span>'+ic+esc(m.label)+'</span><span class="sfeat">'+esc(m.sub)+'</span></div>';}).join("");
    Array.prototype.forEach.call(res.querySelectorAll('.srow'),function(r){r.onclick=function(){doJump(parseInt(r.getAttribute('data-i'),10));};});}
  function moveSearch(d){if(!sMatches.length)return;sIdx=(sIdx+d+sMatches.length)%sMatches.length;
    var rows=document.querySelectorAll("#sresults .srow");Array.prototype.forEach.call(rows,function(r,i){r.classList.toggle("son",i===sIdx);});
    if(rows[sIdx])rows[sIdx].scrollIntoView({block:'nearest'});}
  function ensureVisible(id){if(typeof order!=='undefined'&&order.indexOf(id)<0){filter='All';if(typeof decFilter!=='undefined')decFilter='all';foc=-1;if(typeof buildChips==='function')buildChips();render();}}
  function doJump(i){var m=sMatches[i];if(!m)return;closeSearch();
    if(m.type==='cmd'){try{m.fn();}catch(e){}return;}
    var tid=null;
    if(m.type==='file'){tid=m.id;}
    else{for(var j=0;j<items.length;j++){if(items[j].folder===m.folder){tid=items[j].id;break;}}}
    if(tid==null)return;
    ensureVisible(tid);
    if(typeof setFoc==='function'){try{setFoc(tid);}catch(e){}}
    // If the full viewer is open, jump to the target IN the full viewer (else the lightbox keeps
    // showing the old file behind the search and nothing appears to happen).
    if(typeof lb!=='undefined'&&lb.classList.contains('on')&&typeof openLB==='function'){try{openLB(tid);return;}catch(e){}}
    var t=(typeof ensureRendered==='function')?ensureRendered(tid):null;
    if(t&&t.scrollIntoView)t.scrollIntoView({block:m.type==='folder'?'start':'center',behavior:'smooth'});}
  document.addEventListener("keydown",function(e){var K=e.key;
    // Cmd-F only. Cmd-K and Cmd-J were bound here too and this handler is CAPTURE-phase with
    // stopPropagation, so it swallowed both before AppKit ever saw them — which is why a Swift
    // binding for either would have worked everywhere EXCEPT a library page, i.e. exactly the
    // surface the feature is for, and would have read as intermittent rather than structural.
    // CORRECTED 2026-08-19 on Jason's instruction: "command F needs search within the doc --
    // command k is for search across the library". The comment above this line used to claim
    // "in-page find keeps Cmd-F", and that was never what the code did: openSearch() opens the
    // FILE-AND-COMMAND PALETTE, which searches across the library. So Cmd-F had been opening a
    // library search while describing itself as an in-page find, which is exactly what he hit.
    // The palette moves to Cmd-K, where this file's own cmdList() comment already said it lived.
    if((e.metaKey||e.ctrlKey)&&!e.shiftKey&&(K==='k'||K==='K')){openSearch();e.preventDefault();e.stopPropagation();}
    if((e.metaKey||e.ctrlKey)&&!e.shiftKey&&(K==='f'||K==='F')){findInDocument();e.preventDefault();e.stopPropagation();}
  },true);
  var inp=document.getElementById("sinput");
  if(inp){
    inp.addEventListener("input",function(ev){renderSearch(ev.target.value);});
    inp.addEventListener("keydown",function(ev){var K=ev.key;
      if(K==="ArrowDown"){moveSearch(1);ev.preventDefault();}
      else if(K==="ArrowUp"){moveSearch(-1);ev.preventDefault();}
      else if(K==="Enter"){if(sMatches[sIdx])doJump(sIdx);ev.preventDefault();}
      else if(K==="Escape"){closeSearch();ev.preventDefault();}
      ev.stopPropagation();});
    var so=document.getElementById("search");
    if(so)so.addEventListener("click",function(ev){if(ev.target.id==="search")closeSearch();});
  }
})();
"""

# ── P1: cross-folder full-text search overlay (⇧⌘F) ──────────────────────────
# A distinct surface from the ⌘K file/folder/command palette above: this searches
# the TEXT INSIDE every PDF in the open folder. The query goes to the native shell
# (hvniz://search), which reads each PDF's text layer with PDFKit off the main
# thread and posts grouped, page-precise hits back to window.phkSearchResults. A
# clicked hit opens the document to its page (hvniz://reader?path=…&page=N). Reuses
# the grid's global `esc`; otherwise fully self-contained (builds its DOM on first
# open). Mirrors the #search overlay's look (var(--top-bg)/--border/--accent).
XSEARCH_CSS = """
#xsearch{display:none;position:fixed;inset:0;background:rgba(10,10,12,.5);z-index:62;align-items:flex-start;justify-content:center}
#xsbox{margin-top:9vh;width:min(680px,94vw);max-height:80vh;display:flex;flex-direction:column;background:var(--top-bg);border:1px solid var(--border);border-radius:12px;box-shadow:0 18px 70px rgba(0,0,0,.5);overflow:hidden;backdrop-filter:blur(8px)}
#xshd{display:flex;align-items:center;justify-content:space-between;padding:10px 16px 4px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut)}
#xsclose{cursor:pointer;font-size:13px;color:var(--mut)}#xsclose:hover{color:var(--fg)}
#xsinput{width:100%;border:0;outline:0;background:transparent;color:var(--fg);font-family:var(--font);font-size:17px;padding:6px 16px 12px;border-bottom:1px solid var(--border)}
#xsstatus{padding:8px 16px;font-size:12px;color:var(--mut);border-bottom:1px solid var(--border)}
#xsstatus:empty{display:none}
#xsresults{overflow:auto;flex:1 1 auto;min-height:0}
.xsdoc{border-bottom:1px solid var(--border)}
.xsdh{position:sticky;top:0;background:var(--top-bg);display:flex;justify-content:space-between;gap:12px;align-items:baseline;padding:9px 16px 5px;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
.xsdn{font-family:var(--font-head);font-size:14px;font-weight:600;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.xsdc{flex:0 0 auto;font-size:11px;color:var(--accent-dark);white-space:nowrap}
.xshit{display:flex;gap:10px;padding:6px 16px;cursor:pointer;align-items:baseline;font-size:13px;color:var(--fg)}
.xshit:hover{background:var(--accent);color:var(--accent-fg)}
.xshit:hover .xspg{color:var(--accent-fg)}
.xspg{flex:0 0 auto;font-size:11px;color:var(--mut);min-width:38px}
.xssn{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.xssn mark{background:rgba(200,168,114,.34);color:inherit;border-radius:2px;padding:0 1px}
.xsdc.xsocrtag{color:var(--mut)}
.xshit .xspg.xsocrpg{color:var(--accent-dark)}
/* "Make searchable" banner: appears only when the folder holds scanned (no-text) PDFs */
#xsocr{padding:0 16px}#xsocr:empty{display:none}
.xsocrb{display:flex;align-items:center;gap:12px;justify-content:space-between;margin:11px 0;padding:11px 13px;background:var(--chip);border:1px solid var(--border);border-radius:9px;font-size:12.5px;color:var(--fg);line-height:1.45}
.xsocrb .xsocrt b{font-weight:600}.xsocrb .xsocrt span{color:var(--mut)}
.xsocrbtn{flex:0 0 auto;font-family:var(--font);font-size:12px;padding:7px 14px;border:1px solid var(--accent);border-radius:8px;background:var(--accent);color:var(--accent-fg);cursor:pointer;white-space:nowrap}
.xsocrbtn:hover{filter:brightness(1.06)}
.xsocrbtn[disabled]{opacity:.55;cursor:default;filter:none}
/* v0.37 Search Workbench: mode toggle, type badges, hit selection, compare/explain, lock */
#xstools{display:flex;align-items:center;gap:10px;padding:7px 16px 9px;border-bottom:1px solid var(--border)}
.xsmode{display:inline-flex;border:1px solid var(--border);border-radius:8px;overflow:hidden}
.xsmode .xsmb{font-family:var(--font);font-size:11.5px;padding:5px 12px;background:transparent;color:var(--mut);border:0;cursor:pointer}
.xsmode .xsmb.on{background:var(--accent);color:var(--accent-fg)}
.xsmode .xsmb.lock{opacity:.7}
.xsmode .xsmb .xslk{font-size:9px;margin-left:4px}
.xstip{font-size:11px;color:var(--mut);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.xsbadge{flex:0 0 auto;display:inline-block;font-size:9px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:2px 6px;border-radius:5px;margin-right:8px;vertical-align:middle}
.xsb-pdf{background:rgba(210,90,80,.16);color:#C0564C}
.xsb-docx{background:rgba(70,110,200,.16);color:#3E62B0}
.xsb-xlsx{background:rgba(70,150,90,.16);color:#3B8B52}
.xsb-text,.xsb-md,.xsb-rtf{background:rgba(150,150,150,.18);color:var(--mut)}
.xsb-gdoc{background:rgba(66,133,244,.16);color:#3B6FD4}
.xsb-gsheet{background:rgba(52,168,83,.16);color:#2E9149}
.xsb-gslides{background:rgba(244,180,0,.18);color:#B8890B}
.xsdh .xsdn{display:flex;align-items:center;min-width:0}
.xshit{position:relative}
.xshit .xschk{flex:0 0 auto;width:15px;height:15px;border:1.5px solid var(--border);border-radius:4px;display:inline-flex;align-items:center;justify-content:center;font-size:10px;color:transparent}
.xshit.sel .xschk{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}
.xshit.sel{background:rgba(200,168,114,.12)}
.xsanchor{flex:0 0 auto;font-size:10.5px;color:var(--accent-dark);min-width:44px}
/* locked (Pro) documents — shown so free users see hits EXIST, never a dead end */
.xsdoc.locked{opacity:.96}
.xslockrow{display:flex;align-items:center;gap:11px;justify-content:space-between;padding:8px 16px 11px}
.xslockrow .xslkt{font-size:12.5px;color:var(--mut)}
.xslockrow .xslkt b{color:var(--fg);font-weight:600}
.xsunlock{flex:0 0 auto;font-family:var(--font);font-size:11.5px;padding:6px 13px;border:1px solid var(--accent);border-radius:8px;background:transparent;color:var(--accent-dark);cursor:pointer}
.xsunlock:hover{background:var(--accent);color:var(--accent-fg)}
/* floating action bar when hits are selected */
#xsactions{display:none;align-items:center;gap:9px;padding:9px 16px;border-top:1px solid var(--border);background:var(--top-bg)}
#xsactions.on{display:flex}
#xsactions .xsct{font-size:12px;color:var(--mut);flex:1}
#xsactions .xsact{font-family:var(--font);font-size:12px;padding:6px 14px;border:1px solid var(--border);border-radius:8px;background:var(--chip);color:var(--fg);cursor:pointer;white-space:nowrap}
#xsactions .xsact:hover:not([disabled]){border-color:var(--accent)}
#xsactions .xsact.prim{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}
#xsactions .xsact[disabled]{opacity:.45;cursor:default}
/* explain panel */
#xsexplain{display:none;border-top:1px solid var(--border);max-height:38vh;overflow:auto;padding:13px 16px;background:var(--chip)}
#xsexplain.on{display:block}
#xsexplain .xsexh{display:flex;justify-content:space-between;align-items:center;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);margin-bottom:8px}
#xsexplain .xsexx{cursor:pointer}
#xsexplain .xsexbody{font-size:13px;line-height:1.55;color:var(--fg);white-space:pre-wrap}
#xsexplain .xsexbody mark{background:rgba(200,168,114,.28);border-radius:3px;padding:0 3px;font-weight:600}
#xsidx{padding:6px 16px;font-size:11.5px;color:var(--accent-dark)}#xsidx:empty{display:none}
"""
XSEARCH_JS = r"""
(function(){
  var box, inp, statusEl, ocrEl, resEl, toolsEl, idxEl, actionsEl, explainEl;
  var lastQ='', tmr=null, mode='exact', pro=false, lastPayload=null;
  var lastScans=[], ocrPending=false;   // P1 #2: scanned (no-text) PDFs + OCR-in-flight flag
  var selected=[];                       // workbench: selected hits for compare/explain
  var BADGE={pdf:'PDF',docx:'DOCX',xlsx:'XLSX',text:'TXT',md:'MD',rtf:'RTF',gdoc:'DOC',gsheet:'SHEET',gslides:'SLIDES'};
  function build(){
    if(box)return;
    var d=document.createElement('div'); d.id='xsearch';
    d.innerHTML='<div id="xsbox">'
      +'<div id="xshd"><span id="xsscope">Search across this folder</span><span id="xsclose" title="Close (Esc)">✕</span></div>'
      +'<input id="xsinput" type="text" autocomplete="off" spellcheck="false" placeholder="Find text in every document in this folder… (Enter to search)">'
      +'<div id="xstools"><span class="xsmode"><button class="xsmb on" data-mode="exact">Exact</button>'
        +'<button class="xsmb" data-mode="fuzzy">Fuzzy</button></span><span class="xstip" id="xstip"></span></div>'
      +'<div id="xsstatus"></div><div id="xsidx"></div><div id="xsocr"></div><div id="xsresults"></div>'
      +'<div id="xsexplain"></div>'
      +'<div id="xsactions"><span class="xsct" id="xsselct"></span>'
        +'<button class="xsact" id="xsclrsel">Clear</button>'
        +'<button class="xsact" id="xsexpbtn">Explain these hits</button>'
        +'<button class="xsact prim" id="xscmpbtn" disabled>Compare</button></div>'
      +'</div>';
    document.body.appendChild(d);
    box=d; inp=d.querySelector('#xsinput'); statusEl=d.querySelector('#xsstatus');
    ocrEl=d.querySelector('#xsocr'); resEl=d.querySelector('#xsresults');
    toolsEl=d.querySelector('#xstools'); idxEl=d.querySelector('#xsidx');
    actionsEl=d.querySelector('#xsactions'); explainEl=d.querySelector('#xsexplain');
    d.addEventListener('click',function(ev){if(ev.target.id==='xsearch')closeCrossSearch();});
    d.querySelector('#xsclose').onclick=closeCrossSearch;
    // mode toggle (fuzzy is Pro; free users see a nudge instead of switching)
    Array.prototype.forEach.call(toolsEl.querySelectorAll('.xsmb'),function(b){
      b.onclick=function(){ var m=b.getAttribute('data-mode');
        if(m==='fuzzy'&&!pro){ nudge('Typo-tolerant fuzzy search is part of PageHawk Pro.'); location.href='hvniz://prounlock'; return; }
        setMode(m); if(lastQ) runQuery(lastQ); };
    });
    d.querySelector('#xsclrsel').onclick=clearSel;
    d.querySelector('#xscmpbtn').onclick=doCompare;
    d.querySelector('#xsexpbtn').onclick=doExplain;
    inp.addEventListener('keydown',function(ev){ev.stopPropagation();
      if(ev.key==='Escape'){ if(explainEl.classList.contains('on')){hideExplain();return;} closeCrossSearch();ev.preventDefault();}
      else if(ev.key==='Enter'){runQuery(inp.value);ev.preventDefault();}});
    inp.addEventListener('input',function(ev){var v=ev.target.value;clearTimeout(tmr);
      if(v.trim().length>=3){tmr=setTimeout(function(){runQuery(v);},450);}});
  }
  function setMode(m){ mode=m;
    Array.prototype.forEach.call(toolsEl.querySelectorAll('.xsmb'),function(b){
      b.classList.toggle('on', b.getAttribute('data-mode')===m); }); }
  function nudge(t){ var tip=document.getElementById('xstip'); if(tip)tip.textContent=t; }
  function openCrossSearch(){build();box.style.display='flex';
    try{var last=localStorage.getItem('phk_xs_last'); if(last&&!inp.value){inp.value=last;}}catch(e){}
    inp.focus();inp.select();
    if(inp.value.trim().length>=3) runQuery(inp.value); }
  function closeCrossSearch(){if(box)box.style.display='none';}
  window.closeCrossSearch=closeCrossSearch;
  function runQuery(q){q=(q||'').trim();
    if(q.length<2){statusEl.textContent=q?'Type at least 2 characters.':'';resEl.innerHTML='';if(ocrEl)ocrEl.innerHTML='';return;}
    lastQ=q; ocrPending=false; clearSel(); hideExplain(); nudge('');
    try{localStorage.setItem('phk_xs_last',q);}catch(e){}
    statusEl.textContent='Searching “'+q+'” across the folder…'; resEl.innerHTML=''; idxEl.textContent=''; if(ocrEl)ocrEl.innerHTML='';
    location.href='hvniz://search?q='+encodeURIComponent(q)+'&mode='+encodeURIComponent(mode);
  }
  function hl(sn,m){ if(!m)return esc(sn);
    var i=sn.toLowerCase().indexOf(m.toLowerCase()); if(i<0)return esc(sn);
    return esc(sn.slice(0,i))+'<mark>'+esc(sn.slice(i,i+m.length))+'</mark>'+esc(sn.slice(i+m.length)); }
  function badge(t){ var lbl=BADGE[t]||t.toUpperCase(); return '<span class="xsbadge xsb-'+esc(t)+'">'+esc(lbl)+'</span>'; }
  // "Make searchable" OCR banner (unchanged behavior for scanned PDFs)
  function renderOcrBanner(scans){
    if(!ocrEl)return;
    if(!scans||!scans.length){ocrEl.innerHTML='';return;}
    var n=scans.length;
    ocrEl.innerHTML='<div class="xsocrb"><span class="xsocrt"><b>'+n+' scanned document'+(n===1?'':'s')
      +'</b> <span>'+(n===1?'has':'have')+' no text layer, so search can’t read '+(n===1?'it':'them')
      +' yet. Make '+(n===1?'it':'them')+' searchable on your Mac.</span></span>'
      +'<button class="xsocrbtn" id="xsocrgo">Make searchable</button>';
    var b=ocrEl.querySelector('#xsocrgo'); if(b)b.onclick=function(){runOCR(scans);};
  }
  function runOCR(scans){
    if(!scans||!scans.length||ocrPending)return;
    ocrPending=true;
    var paths=scans.map(function(s){return s.path;});
    var b=ocrEl&&ocrEl.querySelector('#xsocrgo'); if(b){b.disabled=true;b.textContent='Reading…';}
    statusEl.textContent='Reading '+paths.length+' scanned document'+(paths.length===1?'':'s')+' on your Mac…';
    location.href='hvniz://ocr?q='+encodeURIComponent(lastQ)+'&paths='+encodeURIComponent(JSON.stringify(paths));
  }
  window.phkOCRProgress=function(p){
    if(!ocrPending||!p)return;
    statusEl.textContent='Recognizing '+(p.name||'')+' — '+p.done+' of '+p.total+'…';
  };
  window.phkIndexProgress=function(p){
    if(!p)return;
    idxEl.textContent='Building the search index on your Mac — '+p.done+' of '+p.total+' ('+(p.name||'')+')…';
  };
  window.phkSearchResults=function(r){
    if(!r||r.q!==lastQ)return;   // ignore stale / superseded callbacks
    lastPayload=r; pro=!!r.pro; if(r.mode) setMode(r.mode);
    if(!r.indexing){ idxEl.textContent=''; ocrPending=false; }
    var docs=r.docs||[]; var scans=r.scannedNoText||[]; lastScans=scans;
    renderOcrBanner(scans);
    // header status line
    var head='';
    if(!docs.length && !r.indexing){
      if(r.note==='short'){ head='Type at least 3 characters.'; }
      else if(mode==='exact' && r.fuzzyAvailable){ head='No exact matches — '+r.fuzzyPreview+' close match'+(r.fuzzyPreview===1?'':'es')+' found.'; }
      else { head='No matches'+(r.scanned!=null?(' in '+r.scanned+' document'+(r.scanned===1?'':'s')):'')+'.'; }
    } else {
      head=(r.hitCount||0)+' match'+(r.hitCount===1?'':'es')+' in '+(r.docCount-(r.lockedDocCount||0))+' document'+((r.docCount-(r.lockedDocCount||0))===1?'':'s');
      if(r.indexing) head+=' (PDFs shown; indexing other formats…)';
      else if(r.indexed) head+=' · '+(mode==='fuzzy'?'fuzzy':'exact');
    }
    statusEl.textContent=head;
    // fuzzy nudge line under the toggle
    if(mode==='exact' && r.fuzzyAvailable){ nudge(pro?'Try Fuzzy for '+r.fuzzyPreview+' close match'+(r.fuzzyPreview===1?'':'es')+'.':'0 exact — Fuzzy (Pro) finds '+r.fuzzyPreview+' close.'); }
    else if(r.lockedDocCount){ nudge(r.lockedDocCount+' more in '+(r.lockedFormats||[]).map(function(t){return BADGE[t]||t;}).join(', ')+' — unlock with Pro.'); }
    else nudge('');

    var h='';
    docs.forEach(function(doc){
      var cls='xsdoc'+(doc.locked?' locked':'');
      h+='<div class="'+cls+'"><div class="xsdh" title="'+esc(doc.path)+'"><span class="xsdn">'+badge(doc.type)+esc(doc.name)
        +'</span><span class="xsdc">'+doc.count+' hit'+(doc.count===1?'':'s')+(doc.shown<doc.count?(' · top '+doc.shown):'')+'</span></div>';
      if(doc.locked){
        h+='<div class="xslockrow"><span class="xslkt">🔒 <b>'+doc.count+' hit'+(doc.count===1?'':'s')+'</b> in this '+(BADGE[doc.type]||doc.type)+' — searching beyond PDF is Pro.</span>'
          +'<button class="xsunlock" data-unlock="1">Unlock with Pro</button></div>';
      } else {
        (doc.hits||[]).forEach(function(ht,hi){
          var an=ht.atype==='page'?('p.'+esc(String(ht.anchor||ht.page))):(ht.atype==='glink'?'open ↗':esc(String(ht.anchor||'')));
          h+='<div class="xshit" data-path="'+esc(doc.path)+'" data-type="'+esc(doc.type)+'" data-page="'+(ht.page||0)+'" data-anchor="'+esc(ht.anchor||'')+'" data-snip="'+esc(ht.snippet||'')+'" data-name="'+esc(doc.name)+'">'
            +'<span class="xschk">✓</span><span class="xsanchor">'+an+'</span>'
            +'<span class="xssn">'+hl(ht.snippet||'',ht.match||'')+'</span></div>';
        });
      }
      h+='</div>';
    });
    resEl.innerHTML=h;
    // wire unlock buttons (gentle Pro upsell — never a dead end)
    Array.prototype.forEach.call(resEl.querySelectorAll('.xsunlock'),function(b){
      b.onclick=function(e){e.stopPropagation(); location.href='hvniz://prounlock';};
    });
    // wire hits: checkbox toggles selection; body opens the hit
    Array.prototype.forEach.call(resEl.querySelectorAll('.xshit'),function(el){
      var chk=el.querySelector('.xschk');
      chk.onclick=function(e){e.stopPropagation(); toggleSel(el);};
      el.onclick=function(){ openHit(el); };
    });
  };
  function hitDesc(el){ return {path:el.getAttribute('data-path'), type:el.getAttribute('data-type'),
    page:parseInt(el.getAttribute('data-page')||'0',10), anchor:el.getAttribute('data-anchor'),
    name:el.getAttribute('data-name'), snippet:el.getAttribute('data-snip')}; }
  function keyOf(d){ return d.path+'|'+d.anchor; }
  function toggleSel(el){
    var d=hitDesc(el), k=keyOf(d), i=-1;
    for(var j=0;j<selected.length;j++){ if(keyOf(selected[j])===k){i=j;break;} }
    if(i>=0){ selected.splice(i,1); el.classList.remove('sel'); }
    else { selected.push(d); el.classList.add('sel'); }
    refreshActions();
  }
  function clearSel(){ selected=[]; if(resEl) Array.prototype.forEach.call(resEl.querySelectorAll('.xshit.sel'),function(e){e.classList.remove('sel');}); refreshActions(); }
  function refreshActions(){
    if(!actionsEl)return;
    var n=selected.length;
    actionsEl.classList.toggle('on', n>0);
    var ct=document.getElementById('xsselct'), cmp=document.getElementById('xscmpbtn'), exp=document.getElementById('xsexpbtn');
    if(ct)ct.textContent=n+' selected'+(n===2?'':n>2?' · pick exactly 2 to compare':' · pick 2 to compare');
    if(cmp)cmp.disabled=(n!==2);
    if(exp)exp.textContent=pro?'Explain these hits':'Explain these hits (Pro)';
  }
  function openHit(el){
    var d=hitDesc(el);
    if(d.type==='pdf'){ closeCrossSearch(); location.href='hvniz://reader?path='+encodeURIComponent(d.path)+'&page='+(d.page||1); }
    else if(d.atype==='glink'){ closeCrossSearch();
      // Google Doc/Sheet/Slides: open the live doc in the browser (native http handler),
      // falling back to handing the local pointer file to Drive-for-desktop.
      if(d.anchor && /^https?:/.test(d.anchor)) location.href=d.anchor;
      else location.href='hvniz://opendefault?path='+encodeURIComponent(d.path); }
    else { location.href='hvniz://opendefault?path='+encodeURIComponent(d.path); }
  }
  function doCompare(){ if(selected.length!==2)return;
    var a=selected[0].path,b=selected[1].path;
    location.href='hvniz://xcompare?a='+encodeURIComponent(a)+'&b='+encodeURIComponent(b); }
  function b64(s){ try{return btoa(unescape(encodeURIComponent(s)));}catch(e){return btoa(s);} }
  function doExplain(){ if(!selected.length)return;
    var hits=selected.map(function(d){ var an=d.type==='pdf'?('p.'+(d.anchor||d.page)):d.anchor; return {name:d.name,anchor:an,snippet:d.snippet}; });
    showExplain('<em>Thinking on your Mac…</em>');
    location.href='hvniz://xexplain?q='+encodeURIComponent(lastQ)+'&hits='+encodeURIComponent(b64(JSON.stringify(hits))); }
  function showExplain(html){ explainEl.innerHTML='<div class="xsexh"><span>What these hits say together · on-device</span><span class="xsexx" id="xsexx">✕</span></div><div class="xsexbody">'+html+'</div>';
    explainEl.classList.add('on'); var x=document.getElementById('xsexx'); if(x)x.onclick=hideExplain; }
  function hideExplain(){ if(explainEl){explainEl.classList.remove('on');explainEl.innerHTML='';} }
  function citeHl(t){ return esc(t).replace(/\[([^\]]+)\]/g,'<mark>[$1]</mark>'); }
  window.phkExplain=function(r){
    if(!r){ showExplain('The local AI could not be reached.'); return; }
    if(r.error){ showExplain(esc(r.error)); return; }
    showExplain(citeHl(r.explanation||'No explanation returned.')); };
  window.phkExplainLocked=function(){ showExplain('Explaining selected hits with the on-device AI is part of PageHawk Pro.'); };
  document.addEventListener('keydown',function(e){
    if((e.metaKey||e.ctrlKey)&&e.shiftKey&&(e.key==='f'||e.key==='F')){openCrossSearch();e.preventDefault();e.stopPropagation();}
  },true);
  window.openCrossSearch=openCrossSearch;
})();
"""

# ── File-management layer (Jason 2026-06-27: "make it a Finder replacement") ──
# Self-contained CSS+JS appended on top of the single-mode grid: multi-select
# (click / ⌘-click / shift-click / marquee / ⌘A), a folder-navigation sidebar that
# doubles as drop targets, a selection-aware right-click menu, and REAL file moves
# (drag onto a folder, "Move to…" native picker), new-folder grouping, rename, and
# Trash — all via new hvniz:// bridge endpoints (move/movepick/newfolder/trash/
# rename) handled in main.swift. Mirrors the search-overlay pattern: it reuses the
# grid's globals (items/order/foc/byId/tileEl/visible/render/setFoc/openLB/esc/
# confirmDialog) and wraps tileEl/visible/render, so GRID_JS itself is untouched.
FM_CSS = """
#workspace{flex:1 1 auto;min-height:0;display:flex}
/* ── the verb rail (spec 09 Part 2) ─────────────────────────────────────────
   "One window. A list of every document in the working set, and a right rail that acts on the
   current selection." The rail is a third flex child of #workspace, so the sidebar and the
   content area keep their existing widths and every view mode (grid, list, columns, filmstrip)
   inherits it without knowing about it.

   It appears only when something is selected. That is deliberate rather than lazy: an always-on
   rail of dead buttons is what teaches people the app is inconsistent, and the whole point of
   these four verbs is that they act on a SELECTION. */
#verbrail{flex:0 0 214px;display:none;flex-direction:column;gap:8px;padding:12px;
  border-left:1px solid var(--border);background:var(--top-bg);overflow:auto}
#verbrail.on{display:flex}
/* Find-in-document mode. Same rail, same width, so switching costs no layout shift — the panel
   the verbs were in becomes the panel the search is in, which is what Jason asked for over a new
   tab. Scrolls internally: a long hit list must not push the close button off the rail. */
#verbrail #vrfind{width:100%;box-sizing:border-box;padding:7px 9px;border-radius:8px;
  border:1px solid var(--border);background:var(--bg);color:var(--fg);font:13px inherit}
#verbrail #vrfind:focus{outline:2px solid var(--accent);outline-offset:1px}
#verbrail .vrfs{font-size:11.5px;color:var(--mut);margin:2px;line-height:1.4}
#verbrail .vrfr{display:flex;flex-direction:column;gap:2px;overflow:auto;min-height:0;flex:1 1 auto}
#verbrail .vrfh{display:flex;gap:8px;align-items:baseline;text-align:left;border:0;background:transparent;
  border-radius:8px;padding:7px 8px;cursor:pointer;color:var(--fg);font:12px inherit;width:100%}
#verbrail .vrfh:hover{background:var(--hover,rgba(127,127,127,.12))}
#verbrail .vrfp{flex:0 0 auto;font-weight:700;font-size:11px;color:var(--mut);min-width:2.2em}
#verbrail .vrfx{flex:1 1 auto;overflow-wrap:anywhere;line-height:1.4}
#verbrail .vrhd{font-size:11.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--mut);
  margin:2px 2px 4px}
#verbrail .vrct{font-size:13px;font-weight:650;color:var(--fg);margin:0 2px 8px}
/* Name the document the verbs will act on. "1 selected" is true and tells you nothing about WHICH,
   which is the ambiguity that makes a rail beside a preview read as though it acts on the page.
   Muted and clamped to two lines so a long filename names the target without growing the rail. */
#verbrail .vrct:has(+ .vrnm){margin-bottom:2px}
#verbrail .vrnm{font-size:11.5px;color:var(--mut);margin:0 2px 8px;overflow-wrap:anywhere;
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
#verbrail .vb{display:block;width:100%;text-align:left;font-family:var(--font);font-size:13px;
  padding:9px 11px;border:1px solid var(--border);border-radius:9px;background:var(--chip);
  color:var(--fg);cursor:pointer;line-height:1.25}
#verbrail .vb:hover:not(:disabled){border-color:var(--accent)}
#verbrail .vb .vbn{font-weight:600;display:block}
/* A verb whose arity is unmet is VISIBLY DISABLED WITH THE REASON ON IT, never hidden and never
   silently inert. Hiding it teaches people the app is inconsistent; disabling it with the reason
   teaches them the rule in one glance. The reason is a real line of text on the button, not a
   tooltip, because a tooltip is invisible on the surface where it matters most. */
#verbrail .vb .vbw{display:block;font-size:11.5px;color:var(--mut);margin-top:2px}
#verbrail .vb:disabled{opacity:.55;cursor:default}
#verbrail .vb.pri:not(:disabled){background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}
#verbrail .vb.pri:not(:disabled) .vbw{color:var(--accent-fg);opacity:.72}
#verbrail .vrx{margin-top:auto;font-size:12px;color:var(--mut);background:none;border:0;
  cursor:pointer;text-align:left;padding:6px 2px;font-family:var(--font)}
#verbrail .vrx:hover{color:var(--fg)}
@media (max-width:860px){ #verbrail{flex-basis:170px} }
#grid{min-width:0}
#fsidebar{flex:0 0 210px;overflow:auto;border-right:1px solid var(--border);background:var(--top-bg);padding:4px 0 12px;font-size:12.5px;-webkit-user-select:none;user-select:none}
#fsidebar .fsh{display:flex;align-items:center;justify-content:space-between;font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);padding:9px 8px 6px 14px}
#fsidebar .fsadd{flex:0 0 auto;width:21px;height:21px;border:1px solid var(--border);border-radius:6px;background:var(--chip);color:var(--accent-dark);font-size:15px;line-height:1;cursor:pointer;padding:0;display:flex;align-items:center;justify-content:center}
#fsidebar .fsadd:hover{border-color:var(--accent);color:var(--accent)}
#fsidebar .fsrow.fsroot .fsn{font-weight:600}
#fsidebar .fsacts{display:flex;flex-direction:column;gap:5px;margin-top:7px}
#fsidebar .fsopen{color:var(--mut)}
.fsmenu{position:fixed;z-index:70;background:var(--top-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border:1px solid var(--border);border-radius:10px;padding:6px;min-width:214px;box-shadow:var(--sh-pop,0 12px 34px rgba(0,0,0,.34))}
.fsmenu .fsmi{display:flex;flex-direction:column;gap:1px;padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12.5px;color:var(--fg)}
.fsmenu .fsmi:hover{background:var(--chip)}
.fsmenu .fsmsub{font-size:10.5px;color:var(--mut)}
#fsidebar .fsrow{display:flex;align-items:center;gap:7px;padding:6px 10px;cursor:pointer;color:var(--fg);border:2px solid transparent;border-radius:8px;margin:1px 6px}
#fsidebar .fsrow .fsi{flex:0 0 auto;opacity:.92}
#fsidebar .fsrow .fsn{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#fsidebar .fsrow .fsc{flex:0 0 auto;font-size:11px;color:var(--mut)}
#fsidebar .fsrow:hover{background:rgba(150,150,150,.13)}
#fsidebar .fsrow.on{background:var(--accent);color:var(--accent-fg)}
#fsidebar .fsrow.on .fsc{color:var(--accent-fg)}
#fsidebar .fsrow.drophot{border-color:var(--accent);background:rgba(200,168,114,.2)}
#fsidebar .fsnew{margin:9px 12px 2px;padding:7px 10px;font-size:12px;color:var(--accent-dark);border:1px dashed var(--border);border-radius:8px;cursor:pointer;text-align:center}
#fsidebar .fsnew:hover{border-color:var(--accent);color:var(--accent)}
/* Libraries (pinned folders) — rendered at the TOP of #fsidebar, above the folder tree,
   reusing the same row language (.fsrow/.fsi/.fsn/.on) so it reads as part of one sidebar. */
#fsidebar .fslibs .fsrow .fspin{flex:0 0 auto;opacity:0;cursor:pointer;font-size:12px;padding:2px 3px;border-radius:5px}
#fsidebar .fslibs .fsrow:hover .fspin,#fsidebar .fslibs .fsrow.on .fspin{opacity:.7}
#fsidebar .fslibs .fsrow .fspin:hover{opacity:1!important;background:rgba(150,150,150,.18)}
#fsidebar .fslibs-div{height:1px;background:var(--border);margin:8px 10px 6px;opacity:.7}
.tile.selp .tinner{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent)}
.tile.selp::after{content:'\\2713';position:absolute;top:8px;left:8px;width:18px;height:18px;border-radius:50%;background:var(--accent);color:var(--accent-fg);font-size:11px;font-weight:bold;display:flex;align-items:center;justify-content:center;z-index:3;box-shadow:0 1px 4px rgba(0,0,0,.3)}
#marquee{position:fixed;z-index:55;border:1px solid var(--accent);background:rgba(200,168,114,.16);pointer-events:none;border-radius:2px}
.dragghost{position:fixed;top:-1000px;left:-1000px;background:var(--accent);color:var(--accent-fg);font:600 12px var(--font);padding:6px 12px;border-radius:20px;box-shadow:0 4px 14px rgba(0,0,0,.3)}
/* Selection action bar: docked INTO the top toolbar (#seltop), not a floating overlay, so it
   never covers content and lives where there is space next to Size / the layout button. */
#seltop{display:inline-flex;align-items:center}
/* To-do bar (spec 09 Part 2): the review pass's count, verdict and cursor, in the header where
   you are already looking rather than in a panel you have to go and check. Branded like the rest
   of the app: the engine's own keep/trash colours, chip surfaces, accent for Done. */
#todobar{flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:8px 14px;
  border-bottom:1px solid var(--border);background:var(--top-bg);font-size:13px}
#todobar .tdct{font-weight:650;color:var(--fg);white-space:nowrap}
#todobar .tdnm{color:var(--mut);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:46%}
#todobar .tdpos{color:var(--mut);font-variant-numeric:tabular-nums;white-space:nowrap}
#todobar .tdsp{flex:1}
#todobar .tdb{font-family:var(--font);font-size:12.5px;padding:6px 11px;border:1px solid var(--border);
  border-radius:8px;background:var(--chip);color:var(--fg);cursor:pointer;white-space:nowrap}
#todobar .tdb b{font-weight:700;opacity:.6;margin-left:5px}
#todobar .tdb:hover{border-color:var(--accent)}
/* The CURRENT verdict is filled, so the bar answers "what did I already say about this one?"
   without you having to remember. */
#todobar .tdb.tdk.on{background:var(--keep);border-color:var(--keep);color:#fff}
#todobar .tdb.tdx.on{background:var(--trash);border-color:var(--trash);color:#fff}
#todobar .tdb.tdu.on{background:var(--chip);border-color:var(--accent);color:var(--fg)}
#todobar .tdb.tdk.on b,#todobar .tdb.tdx.on b{opacity:.85}
#todobar .tdd{background:var(--accent);border-color:var(--accent);color:var(--accent-fg);font-weight:600}
@media (max-width:860px){ #todobar .tdnm{display:none} }
#selbar{display:none;align-items:center;gap:6px;background:var(--chip);border:1px solid var(--border);border-radius:10px;padding:4px 5px 4px 11px}
#selbar #selct{font-size:12.5px;font-weight:600;color:var(--fg);margin-right:4px;white-space:nowrap}
#selbar .sba{font-family:var(--font);font-size:12px;padding:6px 12px;border:1px solid var(--border);border-radius:8px;background:var(--chip);color:var(--fg);cursor:pointer;white-space:nowrap}
/* Compare is the reason two things get selected, so it reads as the primary action once it is
   available — and is visibly, honestly dead until exactly two are picked rather than silently
   doing nothing. */
#selbar .sbcmp:not(:disabled){background:var(--gold,#C8A872);border-color:var(--gold,#C8A872);color:#231C0E;font-weight:600}
#selbar .sba:disabled{opacity:.42;cursor:default}
#selbar .sba:hover{border-color:var(--accent)}
#selbar .sbx{padding:6px 10px}
.ctx .cisep{height:1px;background:var(--border);margin:5px 4px}
.ctx .ci.cimini{font-size:11px;color:var(--mut)}
.ctx .ci.ci-strong{font-weight:650}
.cfm .fmin{width:100%;margin-top:15px;padding:9px 11px;font-family:var(--font);font-size:14px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--fg);outline:none}
.cfm .fmin:focus{border-color:var(--accent)}
#fmtoast{position:fixed;left:50%;bottom:88px;transform:translateX(-50%) translateY(8px);z-index:90;background:rgba(40,38,34,.95);color:#F3EAD6;font:13px var(--font);padding:9px 16px;border-radius:999px;box-shadow:0 4px 18px rgba(0,0,0,.3);opacity:0;transition:opacity .18s,transform .18s;pointer-events:none}
#fmtoast.on{opacity:1;transform:translateX(-50%) translateY(0)}
#fmtoast.bad{background:#7a2f2f}
.pvpmenu{z-index:95}
.pvpmenu .fsmi>div:first-child{font-size:12.5px}
"""

FM_JS = r"""
(function(){
  if(typeof items==='undefined'||!document.getElementById('grid'))return;
  function dirOf(p){var i=(p||'').lastIndexOf('/');return i<0?'':p.slice(0,i);}
  function baseOf(p){var i=(p||'').lastIndexOf('/');return i<0?(p||''):p.slice(i+1);}
  // The folders the user actually opened (multi-folder). Source of truth from the engine so
  // UNRELATED folders don't collapse under a bogus common ancestor (e.g. the home folder).
  var ROOTS=(function(){var r=(DATA.roots||[]).filter(function(p){return p&&typeof p==='string';});
    // keep the order given, de-duplicated
    var seen={},out=[];r.forEach(function(p){if(!seen[p]){seen[p]=1;out.push(p);}});return out;})();
  // Which opened root contains a given dir (longest match wins for nested roots).
  function rootOf(dir){var best='';ROOTS.forEach(function(R){if(dir===R||dir.indexOf(R+'/')===0){if(R.length>best.length)best=R;}});return best;}
  // common-ancestor directory of everything under review = the single-folder navigation root
  // (also the fallback "context" for new-folder / breadcrumb when multiple folders are open).
  var ROOT=(function(){
    if(ROOTS.length===1)return ROOTS[0];   // one opened folder = its real path (not a deeper common ancestor)
    var dirs=items.map(function(it){return dirOf(it.path||'');}).filter(Boolean);
    if(!dirs.length)return ROOTS[0]||'';var parts=dirs[0].split('/');
    for(var i=1;i<dirs.length;i++){var q=dirs[i].split('/'),j=0;while(j<parts.length&&j<q.length&&parts[j]===q[j])j++;parts=parts.slice(0,j);}
    return parts.join('/');})();
  function rel(dir){if(!dir||dir===ROOT)return '';return dir.indexOf(ROOT+'/')===0?dir.slice(ROOT.length+1):dir;}
  function labelOf(dir){var r=rel(dir);return r===''?(baseOf(ROOT)||'All Files'):r;}
  var extraFolders={};   // folders created this session (may be empty) — keep them in the tree
  function folderDirs(){var s={};if(ROOT)s[ROOT]=1;ROOTS.forEach(function(R){s[R]=1;});
    items.forEach(function(it){var d=dirOf(it.path||'');if(!d)return;
      // stop at the item's OWN opened root (multi-folder) or the single ROOT.
      var floor=(ROOTS.length?rootOf(d):ROOT)||ROOT||'';
      var cur=d;while(cur&&cur.length>=floor.length){s[cur]=1;if(cur===floor)break;cur=dirOf(cur);}});
    Object.keys(extraFolders).forEach(function(d){s[d]=1;});
    return Object.keys(s).sort();}
  function subtreeCount(dir){var n=0;items.forEach(function(it){var d=dirOf(it.path||'');if(d===dir||d.indexOf(dir+'/')===0)n++;});return n;}

  // ── selection ─────────────────────────────────────────────────────────────
  var sel=new Set(), anchor=null, folderFilter=null;
  function selectOnly(id){sel.clear();sel.add(id);anchor=id;syncSel();}
  function toggle(id){if(sel.has(id))sel.delete(id);else sel.add(id);anchor=id;syncSel();}
  function rangeTo(id){if(anchor==null){selectOnly(id);return;}
    var a=order.indexOf(anchor),b=order.indexOf(id);if(a<0||b<0){selectOnly(id);return;}
    if(a>b){var t=a;a=b;b=t;}for(var i=a;i<=b;i++)sel.add(order[i]);syncSel();}
  function clearSel(){sel.clear();anchor=null;syncSel();}
  function selectAllVisible(){order.forEach(function(id){sel.add(id);});if(order.length)anchor=order[0];syncSel();}
  function selItems(){return Array.from(sel).map(byId).filter(Boolean);}
  function selPaths(){return selItems().map(function(it){return it.path;}).filter(Boolean);}
  // One place that writes the selection bar's state, so the count and the Compare button can never
  // disagree (they were two separate writes before Compare existed, which is how that drifts).
  function selBarSync(){
    var ct=document.getElementById('selct'); if(ct)ct.textContent=sel.size+' selected';
    var cb=document.querySelector('#selbar [data-a=compare]');
    if(cb){var p=selPaths(); var ok=(p.length===2);
      cb.disabled=!ok;
      cb.title=ok?('Compare "'+baseName(p[0])+'" against "'+baseName(p[1])+'"')
                 :(sel.size>2?'Select exactly 2 documents to compare':'Select 2 documents to compare');}
  }
  function baseName(p){return String(p||'').split('/').pop();}
  // Two selected documents -> the redline, through the same native verb the compare intake uses.
  function doSelCompare(){var p=selPaths();
    if(p.length!==2){toast('Select exactly 2 documents to compare');return;}
    fireCompare(p[0],p[1]);}

  // ── Compare as a permanent library verb ────────────────────────────────────────────────────
  // Jason, 2026-08-04, replacing the three-way "you opened two documents" dialog outright:
  // "maybe what we just need to do is make it automatically open when I have two files
  //  automatically open in library format ... If there are only two items, this should just be a
  //  compare button that immediately flips it into compare mode. The compare button basically
  //  would ask me to select which items in the library to, but if there are only two, it would
  //  just automatically compare those two. That is the better path than this pop-up."
  //
  // Three intents, resolved in order, because they really are three different situations:
  //   (1) two already selected              -> compare them, one click, no ceremony;
  //   (2) exactly two comparable documents  -> compare them, no selection needed at all. This is
  //       the case that made the pop-up exist, and it is now answered by a button instead;
  //   (3) anything else                     -> ARM and wait. Arming beats a modal because the
  //       library stays on screen, so the choice is made from the documents themselves rather
  //       than from a dialog that has covered them up.
  // Compare reads TEXT, not the grid's rendered preview, so the accepted set is the compare
  // engine's own (_run_compare: PDF, Word via textutil, RTF, plain text) and NOT the viewable
  // set the grid scan uses — a .txt is invisible to `_scan_files` and compares perfectly well.
  // Single backslash on purpose: GRID_JS is a RAW python string, so this reaches the browser
  // verbatim. A doubled backslash here compiled to a regex meaning "a literal backslash", which
  // matched no filename at all, and the button then silently did nothing on click.
  var CMP_EXT=/\.(pdf|docx?|rtf|txt|md|markdown|pages)$/i;
  var cmpArmed=false;
  function comparableItems(){
    // Scoped to what is ON SCREEN (`order`), not to everything loaded, so a folder filter or a
    // search narrowing the library to two documents counts as "there are only two".
    return order.map(byId).filter(function(it){return it&&it.path&&CMP_EXT.test(it.path);});}
  // A one-line seam so the compare DECISION is observable. WebKit will not let a harness intercept
  // location.href (webkit-verify-compare.js records the same wall), so without this the only
  // checkable thing would be that a button exists — and "which two documents did it choose" is the
  // entire substance of what Jason asked for. The production path is byte-for-byte unchanged.
  function cmpNav(u){if(window.__phkNavHook){window.__phkNavHook(u);return;}location.href=u;}
  function fireCompare(a,b){disarmCompare();
    cmpNav('hvniz://comparego?a='+encodeURIComponent(a)+'&b='+encodeURIComponent(b));}
  function disarmCompare(){cmpArmed=false;var b=document.getElementById('topcmp');
    if(b){b.classList.remove('armed');b.textContent='⇄ Compare';}
    topCmpSync();}
  function armCompare(){cmpArmed=true;var b=document.getElementById('topcmp');
    if(b){b.classList.add('armed');b.textContent='⇄ Pick two…';}
    toast('Pick two documents to compare (Esc to cancel)');}
  // Keeps the button honest about what the next click will do, so it is never a mystery verb.
  function topCmpSync(){var b=document.getElementById('topcmp');if(!b)return;
    if(cmpArmed)return;                       // armed state owns its own label
    var s=selPaths().filter(function(p){return CMP_EXT.test(p||'');});
    var c=comparableItems();
    b.classList.toggle('ready',s.length===2||c.length===2);
    b.title=(s.length===2)?('Compare "'+baseName(s[0])+'" against "'+baseName(s[1])+'"')
           :(c.length===2)?('Compare "'+baseName(c[0].path)+'" against "'+baseName(c[1].path)+'"')
           :'Compare two documents side by side — click, then pick two';}
  // ── Document-set switch, library side ────────────────────────────────────────────────
  // Renders ONLY from what the host pushes (phkSetState). The library cannot know on its own
  // whether these files arrived as one gesture or as a folder, and guessing from the file count
  // would light the switch on every two-file folder — a control that appears for a reason the
  // person did not cause.
  window.phkSetSwitch=function(mode){ location.href='hvniz://multidoc?mode='+mode; };
  window.phkSetState=function(count,mode){
    // State only: the host pushes the current mode and this renders the switch. The double-ask
    // guard that used to live here (host mode standing down the in-library card) retired with
    // the card itself on 2026-08-30, when Jason removed the ask-once question entirely
    // (REQ-20260830-050): sets open together by default and the escape is the right-click's
    // "Open in Separate Tabs".
    var sw=document.getElementById('setsw'); if(!sw) return;
    var on=(count|0)>1; sw.classList.toggle('on',on);
    if(!on) return;
    var sep = mode==='separately';
    var bT=document.getElementById('setswT'), bS=document.getElementById('setswS');
    if(bT) bT.classList.toggle('on',!sep);
    if(bS) bS.classList.toggle('on',sep);
  };
  window.topCompare=function(){
    if(cmpArmed){disarmCompare();return;}      // a second click cancels, so it is never a trap
    var s=selPaths().filter(function(p){return CMP_EXT.test(p||'');});
    if(s.length===2){fireCompare(s[0],s[1]);return;}
    var c=comparableItems();
    if(c.length===2){fireCompare(c[0].path,c[1].path);return;}
    if(c.length<2){toast('This folder has fewer than two documents to compare');return;}
    armCompare();};
  // While armed, the SECOND pick fires immediately. Anything the user does to select counts —
  // plain click, ⌘-click, ⇧-range — because they all land in `sel`.
  function cmpArmCheck(){if(!cmpArmed)return;
    var s=selPaths().filter(function(p){return CMP_EXT.test(p||'');});
    if(s.length===2)fireCompare(s[0],s[1]);}
  window.__cmpDisarm=disarmCompare;
  // Marks the FIRST and LAST row of every contiguous run of selected rows, so the run can be
  // drawn as one solid block. Adjacency is judged on DOM order within each container, which is
  // what "adjacent" means to the person looking at the list: a group header or a folder row
  // sitting between two selections correctly breaks the run, because it is not a `.selp` sibling.
  function markRuns(){
    ['#listtbl','.colpane.colfiles'].forEach(function(csel){
      Array.prototype.forEach.call(document.querySelectorAll(csel),function(box){
        var kids=Array.prototype.filter.call(box.children,function(el){return el.nodeType===1;});
        kids.forEach(function(el,i){
          if(!el.classList.contains('selp')){el.classList.remove('selrun-t','selrun-b');return;}
          var prev=kids[i-1],next=kids[i+1];
          el.classList.toggle('selrun-t',!(prev&&prev.classList.contains('selp')));
          el.classList.toggle('selrun-b',!(next&&next.classList.contains('selp')));
        });
      });
    });
  }
  // ── the verb rail (spec 09 Part 2) ──────────────────────────────────────────
  // Jason's four verbs, in Jason's order, each with its arity. Three of the four already existed
  // and were reachable only from somewhere else; the rail is what makes them act on the SELECTION.
  // `ready(n)` returns null when the verb can run, or the plain-English reason it cannot — that
  // string is rendered ON the button, so an unmet verb explains itself instead of going quiet.
  var VERBS=[
    {id:'draft', name:'Draft', pri:true,
     hint:'Open the reader to mark up and rewrite',
     ready:function(n){return n>=1?null:'Select a document';},
     run:function(){ var docs=selItems().filter(function(it){return it&&it.path;});
       if(!docs.length){toast('Select a document');return;}
       // Opening twenty readers because a ⌘A landed is not a feature. Above five it asks first,
       // and says how many, rather than discovering the answer by doing it.
       if(docs.length>5){ confirmDialog('Open '+docs.length+' readers?',
         'This opens <b>'+docs.length+'</b> separate reader windows, one per document.',
         function(){docs.forEach(openInReader);}); return; }
       docs.forEach(openInReader); }},
    {id:'todo', name:'To-do', pri:false,
     hint:'Walk these one at a time, keeping or cutting',
     ready:function(n){return n>=1?null:'Select a document';},
     run:function(){ var ids=selItems().map(function(it){return it.id;});
       todoStart(ids); clearSel(); }},
    {id:'timeline', name:'Timeline', pri:false,
     hint:'Read these as drafts of one document, oldest first',
     ready:function(n){return n>=3?null:'Timeline needs three or more';},
     run:function(){ var p=selPaths(); if(p.length<3){toast('Timeline needs three or more');return;}
       location.href='hvniz://timelinego?paths='+jenc(p); }},
    {id:'compare', name:'Compare', pri:false,
     hint:'Redline two documents side by side',
     ready:function(n){return n===2?null:'Compare needs exactly two';},
     run:function(){ doSelCompare(); }},
    // SIGN — Jason's decision 2026-08-27, ballot option C built together with B: "you're giving
    // the option to highlight three things that need to be signed. You click Sign, and then it
    // opens them all in a single pane to sign them through documents more easily."
    //
    // It is a RUN, not N windows. Draft (above) deliberately opens one reader per document and
    // asks first past five, because marking up is parallel work you move between. Signing is
    // serial: you do one, then the next, and the thing that made three TX-13s tedious was the
    // open/close round trip between them. So this hands the whole set to ONE reader that walks
    // it, and the reader shows where you are in the set.
    // pri stays FALSE. Draft is the rail's primary because these are "Jason's four verbs, in
    // Jason's order", and a second accent-filled button would silently restyle a rail he chose.
    // One word from him flips it; guessing is not mine to do.
    {id:'sign', name:'Sign', pri:false,
     hint:'Sign these one after another, in one window',
     ready:function(n){
       if(n<1) return 'Select a document';
       // Only documents the reader can open. Saying WHICH ones are not signable beats a generic
       // refusal, and beats silently signing a subset of what is selected.
       var sel=selItems(), bad=sel.filter(function(it){return !it||!it.path||!isDocCat(it);});
       if(bad.length===sel.length) return 'These cannot be signed';
       if(bad.length) return bad.length+' of these cannot be signed';
       return null; },
     run:function(){
       var p=selItems().filter(function(it){return it&&it.path&&isDocCat(it);})
                       .map(function(it){return it.path;});
       if(!p.length){ toast('Select a document to sign'); return; }
       location.href='hvniz://signrun?paths='+jenc(p); }}
  ];
  function openInReader(it){
    if(it.category==='PDFs'||it.category==='Decks'||it.category==='Documents')
      location.href='hvniz://reader?path='+encodeURIComponent(it.path);
    else openItem(it.id);
  }
  // ── "Open in Separate Tabs" (REQ-20260830-050) ──────────────────────────────────────────
  // DECIDED-BY-JASON 2026-08-30: three or more documents open in library format, full stop, and
  // the escape lives on the multi-select right-click. This REPLACED the ask-once card
  // (REQ-20260825-041 pick C): the question is retired, not re-skinned, so the `vt-openpref:`
  // per-folder store and its harness seams (__phkAskOpen / __phkOpenChoice) went with it.
  // One bridge call, not a per-path loop, so the host records the SET (openEachIndividually),
  // which is what keeps the Together toolbar switch able to regroup these tabs afterwards.
  function readerableSel(){
    return selItems().filter(function(it){
      return it&&it.path&&(it.category==='PDFs'||it.category==='Decks'||it.category==='Documents');});
  }
  function openSelectedInTabs(){
    var docs=readerableSel();
    if(docs.length<2){ toast('Select two or more documents'); return; }
    var go=function(){ location.href='hvniz://openseparately?paths='+jenc(docs.map(function(d){return d.path;})); };
    // The same >5 guard the Draft verb uses: a big selection must not explode into twenty tabs
    // on one hurried click.
    if(docs.length>5){
      confirmDialog('Open '+docs.length+' tabs?',
        'This opens <b>'+docs.length+'</b> separate reader tabs, one per document.', go);
      return;
    }
    go();
  }

  function buildVerbRail(){
    var r=document.getElementById('verbrail');
    if(!r){ r=document.createElement('div'); r.id='verbrail';
      var ws=document.getElementById('workspace'); if(!ws)return null; ws.appendChild(r); }
    return r;
  }
  function verbRailSync(){
    var n=sel.size, r=document.getElementById('verbrail');
    // THE RAIL FOLLOWS THE ARROWS. It acts on the FILE LIST, so it stands down the moment the PDF
    // takes the keyboard and returns when the arrows come back. Reported 2026-08-07: reading a
    // 13-page PDF in the preview with four verbs alongside it reads as though they act on the PAGE,
    // because nothing in the rail knew the preview existed — it rendered purely off "is anything
    // selected". Hiding is correct here and clearing would not be: the selection is what you come
    // back TO, so the first Esc hands the arrows back and the rail returns exactly as it was.
    var reading=false; try{reading=document.body.classList.contains('pvkfocus');}catch(e){}
    // Find mode owns this rail while it is open. Without this guard any selection change repaints
    // the verbs over the search box mid-typing, which reads as the panel randomly closing itself.
    if(typeof FIND!=='undefined' && FIND && FIND.on){ return; }
    if(!n||reading){ if(r)r.classList.remove('on'); return; }
    r=r||buildVerbRail(); if(!r)return;
    r.classList.add('on');
    // Say WHICH document, not just how many. The count alone is the other half of the same ambiguity.
    var one=(n===1)?selItems()[0]:null;
    var html='<div class="vrhd">Do this with</div><div class="vrct">'+n+' selected</div>'
           +((one&&one.name)?'<div class="vrnm">'+esc(one.name)+'</div>':'');
    VERBS.forEach(function(v){
      var why=v.ready(n);
      html+='<button class="vb'+(v.pri&&!why?' pri':'')+'" data-v="'+v.id+'"'+(why?' disabled':'')+'>'
          + '<span class="vbn">'+v.name+'</span>'
          + '<span class="vbw">'+(why||v.hint)+'</span></button>';
    });
    html+='<button class="vrx" data-v="__clear">Clear selection · Esc</button>';
    r.innerHTML=html;
    Array.prototype.forEach.call(r.querySelectorAll('[data-v]'),function(b){
      b.onclick=function(){ if(b.dataset.v==='__clear'){clearSel();return;}
        var v=VERBS.filter(function(x){return x.id===b.dataset.v;})[0]; if(v)v.run(); };
    });
  }
  function syncSel(){
    Array.prototype.forEach.call(document.querySelectorAll('.tile'),function(el){el.classList.toggle('selp',sel.has(+el.dataset.id));});
    // List / column / filmstrip rows share the SAME multi-selection model as the grid.
    // Fix: ⌘/⇧-click multi-select works in every view, not only the thumbnail grid.
    Array.prototype.forEach.call(document.querySelectorAll('#listtbl .lrow[data-id],.colfilerow[data-id],#fsstrip .fsth[data-id]'),function(el){el.classList.toggle('selp',sel.has(+el.dataset.id));});
    markRuns();
    var bar=document.getElementById('selbar');
    if(sel.size>0){if(!bar)bar=buildSelBar();bar.style.display='flex';selBarSync();}
    else if(bar){bar.style.display='none';}
    // Hide the centered brand wordmark while the selection bar occupies the toolbar center (no collision).
    var topEl=document.getElementById('top');if(topEl)topEl.classList.toggle('hassel',sel.size>0);
    // The toolbar Compare button tracks the same selection, and fires here when it is armed and
    // the second document lands — so arming is finished by picking, never by pressing again.
    topCmpSync();cmpArmCheck();verbRailSync();}
  // Bridge the multi-selection API to the top-level list/column/filmstrip click handlers,
  // which are defined OUTSIDE this IIFE and otherwise cannot reach `sel`.
  window.__msel={toggle:toggle,rangeTo:rangeTo,selectOnly:selectOnly,clear:clearSel,has:function(id){return sel.has(id);},size:function(){return sel.size;},paths:selPaths,sync:syncSel,
    // The preview engine lives outside this IIFE and needs to re-render the rail when the arrows
    // move between the file list and the PDF. Narrow on purpose: syncSel would re-walk every tile.
    railSync:verbRailSync};

  // ── emphasized vs unemphasized selection (spec 09 Part 3) ───────────────────
  // Finder greys a selection the moment the list is not the active region, and that
  // distinction is half of why it reads so clearly. We cannot use :focus-within because the
  // panes are not focusable and clicking a row focuses nothing, so the active region is
  // tracked from where the pointer last went down, plus window activation.
  // Sets `body.nosfoc`, which the CSS above turns into the grey fill.
  (function(){
    var FILES='#listtbl,#fsstrip,.colpane.colfiles,#grid';
    function inFiles(t){
      // `closest` needs an Element; text nodes and the document itself land here too.
      var el=(t&&t.nodeType===1)?t:(t&&t.parentElement);
      return !!(el&&el.closest&&el.closest(FILES));
    }
    function setFoc(on){document.body.classList.toggle('nosfoc',!on);}
    document.addEventListener('mousedown',function(e){setFoc(inFiles(e.target));},true);
    // A text field or button taking focus outside the panes drops emphasis the same way.
    document.addEventListener('focusin',function(e){if(!inFiles(e.target))setFoc(false);},true);
    // An inactive window shows an unemphasized selection, exactly as Finder does.
    window.addEventListener('blur',function(){setFoc(false);});
    // Expose it so the QA journey can photograph the unemphasized frame without needing a
    // real click somewhere harmless, and so a Swift caller can mirror real window state.
    window.__selFocus=setFoc;
  })();

  // ── folder sidebar (navigation + drop targets) ─────────────────────────────
  // ── Libraries (pinned folders) — rendered at the TOP of #fsidebar ───────────
  // PINNED is live shell state: main.swift pushes it in via phkSetPinned(...) after
  // page load and after every pin/unpin (see refreshPinnedEverywhere in main.swift),
  // rather than baking it into the generated HTML, so the sidebar reflects pin
  // changes made from the welcome screen or the Favorites menu without a reload.
  var PINNED=(typeof PINNED!=='undefined'&&Array.isArray(PINNED))?PINNED:[];
  window.phkSetPinned=function(list){PINNED=Array.isArray(list)?list:[];buildSidebar();};
  function buildLibrariesHTML(){
    if(!PINNED||!PINNED.length)return '';
    var html='<div class="fslibs"><div class="fsh"><span class="fshl">Libraries</span></div>';
    PINNED.forEach(function(p){
      var isCur=(typeof ROOT==='string')&&ROOT&&p.path===ROOT;
      html+='<div class="fsrow fslib'+(isCur?' on':'')+'" data-lib="'+esc(p.path)+'" title="'+esc(p.path)+'">'
        +'<span class="fsi">📚</span><span class="fsn">'+esc(p.name||baseOf(p.path))+'</span>'
        +'<span class="fspin" data-unpin="'+esc(p.path)+'" title="Unpin this library">&#9733;</span></div>';});
    if(typeof ROOT==='string'&&ROOT&&!PINNED.some(function(p){return p.path===ROOT;})){
      html+='<div class="fsrow fslib" data-pincur="1" title="Pin this library"><span class="fsi">☆</span><span class="fsn">Pin this library</span></div>';}
    html+='<div class="fslibs-div"></div></div>';
    return html;}

  // ── folder sidebar (navigation + drop targets) ────────────────────────────────
  function buildSidebar(){var sb=document.getElementById('fsidebar');if(!sb)return;
    var rootName=(typeof ROOT==='string'&&ROOT)?(baseOf(ROOT)||'All Files'):'All Files';
    var html=buildLibrariesHTML();
    // Header reads FOLDERS and carries a + that offers BOTH folder actions (create / open existing).
    html+='<div class="fsh"><span class="fshl">Folders</span><button class="fsadd" data-add="1" title="Add a folder — create a new empty one, or open an existing folder from your Mac">+</button></div>';
    if(ROOTS.length>1){
      // MULTIPLE folders open: an "All" row across everything, then EACH opened folder as its own
      // top-level entry with its subfolders nested beneath it. This is the fix for unrelated folders
      // (Downloads + a Documents subfolder) collapsing under a bogus common ancestor.
      html+='<div class="fsrow fsroot'+(folderFilter==null?' on':'')+'" data-all="1" title="Show files from every open folder"><span class="fsi">🗂</span><span class="fsn">All open folders</span><span class="fsc">'+items.length+'</span></div>';
      ROOTS.forEach(function(R){
        html+='<div class="fsrow fsroot'+(folderFilter===R?' on':'')+'" data-dir="'+esc(R)+'" title="'+esc(R)+'"><span class="fsi">📂</span><span class="fsn">'+esc(baseOf(R)||R)+'</span><span class="fsc">'+subtreeCount(R)+'</span></div>';
        folderDirs().forEach(function(d){
          if(d===R||d.indexOf(R+'/')!==0)return;      // only dirs under THIS opened root
          if(rootOf(d)!==R)return;                     // guard against a nested opened root double-listing
          var depth=d.slice(R.length+1).split('/').length-1;
          html+='<div class="fsrow'+(folderFilter===d?' on':'')+'" data-dir="'+esc(d)+'" title="'+esc(d)+'" style="padding-left:'+(30+depth*14)+'px"><span class="fsi">📁</span><span class="fsn">'+esc(baseOf(d))+'</span><span class="fsc">'+subtreeCount(d)+'</span></div>';});});
    } else {
      // Single opened folder: its real Mac name is the top of the tree; click it to show all its files.
      html+='<div class="fsrow fsroot'+(folderFilter==null?' on':'')+'" data-all="1" title="'+esc(ROOT||'')+'  ·  the folder you opened: click to show all of its files"><span class="fsi">📂</span><span class="fsn">'+esc(rootName)+'</span><span class="fsc">'+items.length+'</span></div>';
      folderDirs().forEach(function(d){if(d===ROOT)return;
        var depth=rel(d).split('/').length-1;
        html+='<div class="fsrow'+(folderFilter===d?' on':'')+'" data-dir="'+esc(d)+'" title="'+esc(d)+'" style="padding-left:'+(16+depth*14)+'px">'
          +'<span class="fsi">📁</span><span class="fsn">'+esc(baseOf(d))+'</span><span class="fsc">'+subtreeCount(d)+'</span></div>';});
    }
    // Two CLEARLY DIFFERENT folder actions, each labelled + tooltipped.
    html+='<div class="fsacts">'
      +'<div class="fsnew" data-newfolder="1" title="Create a brand-new, empty folder inside the current folder">＋ New Empty Folder</div>'
      +'<div class="fsnew fsopen" data-openfolder="1" title="Add another folder alongside this one: its files join the current view, and you can group, sort and filter across all open folders">⤓ Open Folder…</div>'
      +'</div>';
    sb.innerHTML=html;
    // Library rows (Libraries section) get their own click behavior — switch library /
    // pin / unpin — kept separate from the folder-tree rows below (which filter in place).
    Array.prototype.forEach.call(sb.querySelectorAll('.fsrow.fslib'),function(r){
      var pin=r.querySelector('[data-unpin]');
      if(pin)pin.onclick=function(e){e.stopPropagation();location.href='hvniz://unpin?path='+encodeURIComponent(pin.getAttribute('data-unpin'));};
      if(r.dataset.pincur){r.onclick=function(){location.href='hvniz://pin?path='+encodeURIComponent(ROOT);};return;}
      r.onclick=function(){location.href='hvniz://openroot?path='+encodeURIComponent(r.dataset.lib);};});
    Array.prototype.forEach.call(sb.querySelectorAll('.fsrow:not(.fslib)'),function(r){
      r.onclick=function(){folderFilter=r.dataset.all?null:r.dataset.dir;buildSidebar();foc=-1;render();};
      r.addEventListener('dragover',function(e){if(r.dataset.all)return;e.preventDefault();r.classList.add('drophot');});
      r.addEventListener('dragleave',function(){r.classList.remove('drophot');});
      r.addEventListener('drop',function(e){r.classList.remove('drophot');if(r.dataset.all)return;e.preventDefault();dropMove(r.dataset.dir);});});
    var nb=sb.querySelector('[data-newfolder]');if(nb)nb.onclick=function(){promptNewFolder(folderFilter||ROOT,sel.size>0);};
    var ob=sb.querySelector('[data-openfolder]');if(ob)ob.onclick=function(){location.href='hvniz://addfolder';};
    var ab=sb.querySelector('[data-add]');if(ab)ab.onclick=function(e){e.stopPropagation();fsAddMenu(ab);};}
  function fsAddMenu(anchor){var ex=document.getElementById('fsaddmenu');if(ex){ex.remove();return;}
    var m=document.createElement('div');m.id='fsaddmenu';m.className='fsmenu';
    m.innerHTML='<div class="fsmi" data-a="new">＋ New Empty Folder<span class="fsmsub">Create an empty folder here</span></div>'
      +'<div class="fsmi" data-a="open">⤓ Open Folder…<span class="fsmsub">Browse an existing folder from your Mac</span></div>';
    document.body.appendChild(m);
    var r=anchor.getBoundingClientRect(),w=m.offsetWidth||214;
    m.style.left=Math.max(6,Math.min(r.left,innerWidth-w-8))+'px';m.style.top=(r.bottom+5)+'px';
    m.querySelector('[data-a=new]').onclick=function(){m.remove();promptNewFolder(folderFilter||ROOT,sel.size>0);};
    m.querySelector('[data-a=open]').onclick=function(){m.remove();location.href='hvniz://addfolder';};
    setTimeout(function(){document.addEventListener('click',function h(ev){var mm=document.getElementById('fsaddmenu');if(!mm||!mm.contains(ev.target)){if(mm)mm.remove();document.removeEventListener('click',h,true);}},true);},0);}

  // ── bridge calls ───────────────────────────────────────────────────────────
  function jenc(a){return encodeURIComponent(JSON.stringify(a));}
  function dropMove(destDir){var paths=(window.__fmDrag&&window.__fmDrag.length?window.__fmDrag:selPaths()).filter(function(p){return dirOf(p)!==destDir;});
    if(!paths.length){toast('Already in '+labelOf(destDir));return;}moveConfirm(paths,destDir);}
  function moveConfirm(paths,destDir){confirmDialog('Move '+paths.length+' item'+(paths.length===1?'':'s')+'?',
    'Move <b>'+paths.length+'</b> file'+(paths.length===1?'':'s')+' into <b>'+esc(labelOf(destDir))+'</b>.',
    function(){location.href='hvniz://move?dest='+encodeURIComponent(destDir)+'&paths='+jenc(paths);});}
  function movePick(){var p=selPaths();if(p.length)location.href='hvniz://movepick?paths='+jenc(p);}
  function trashSel(){var p=selPaths();if(!p.length)return;
    confirmDialog('Move '+p.length+' to Trash?','Move <b>'+p.length+'</b> item'+(p.length===1?'':'s')+' to the macOS Trash. You can restore from Trash if needed.',
      function(){location.href='hvniz://trash?paths='+jenc(p);});}
  function promptNewFolder(parentDir,withSel){var paths=withSel?selPaths():[];
    inputDialog('New Folder',withSel&&paths.length?('Create a folder with the '+paths.length+' selected item'+(paths.length===1?'':'s')+' in '+esc(labelOf(parentDir))):('New folder in '+esc(labelOf(parentDir))),'untitled folder','Create',
      function(name){if(!name)return;location.href='hvniz://newfolder?parent='+encodeURIComponent(parentDir)+'&name='+encodeURIComponent(name)+(paths.length?('&paths='+jenc(paths)):'');});}
  function renameItem(id){var it=byId(id);if(!it)return;
    inputDialog('Rename','New name for this file',it.name,'Rename',function(name){if(!name||name===it.name)return;location.href='hvniz://rename?path='+encodeURIComponent(it.path)+'&name='+encodeURIComponent(name);});}

  window.fileOpDone=function(res){if(!res)return;
    if(res.op==='move'){var n=0;(res.moved||[]).forEach(function(m){var it=findByPath(m.from);if(!it)return;n++;
        var d=dirOf(m.to);
        if(ROOT&&(d===ROOT||d.indexOf(ROOT+'/')===0)){it.path=m.to;it.name=baseOf(m.to);it.folder=rel(d)||'(root)';}
        else removeItem(it.id);
        sel.delete(it.id);});
      if(res.dest)extraFolders[res.dest]=1;
      afterOp(n?('Moved '+n+' item'+(n===1?'':'s')+(res.dest?(' to '+labelOf(res.dest)):'')):'',res.failed);}
    else if(res.op==='trash'){(res.trashed||[]).forEach(function(p){var it=findByPath(p);if(it){sel.delete(it.id);removeItem(it.id);}});
      afterOp((res.trashed||[]).length?('Moved '+res.trashed.length+' to Trash'):'',res.failed);}
    else if(res.op==='newfolder'){if(res.dest)extraFolders[res.dest]=1;afterOp('Folder created',res.failed);}
    else if(res.op==='rename'){var rn=res.renamed||{};Object.keys(rn).forEach(function(f){var it=findByPath(f);if(it){it.path=rn[f];it.name=baseOf(rn[f]);}});afterOp('Renamed',res.failed);}};
  function findByPath(p){for(var i=0;i<items.length;i++)if(items[i].path===p)return items[i];return null;}
  function removeItem(id){for(var i=0;i<items.length;i++)if(items[i].id===id){items.splice(i,1);return;}}
  function afterOp(msg,failed){if(foc!=null&&foc>=0&&!byId(foc))foc=-1;buildSidebar();render();syncSel();
    if(failed&&failed.length)toast((msg?msg+' · ':'')+failed.length+' failed',true);else if(msg)toast(msg);}

  // ── toast ──────────────────────────────────────────────────────────────────
  var toastT=null;
  function toast(msg,bad){var t=document.getElementById('fmtoast');if(!t){t=document.createElement('div');t.id='fmtoast';document.body.appendChild(t);}
    t.textContent=msg;t.className=bad?'bad on':'on';clearTimeout(toastT);toastT=setTimeout(function(){t.className=t.className.indexOf('bad')>=0?'bad':'';},2200);}
  // The host announces the Night Mode schedule's handover through the same seam the reader
  // exposes, so a library window explains its own relighting too (the 2026-08-30 filing: a
  // schedule that overrides the system needs a visible tell at the moment it does so).
  window.__phkToast = window.__phkToast || function(m){ try{ toast(m); }catch(e){} };

  // ── selection action bar ────────────────────────────────────────────────────
  function buildSelBar(){var b=document.createElement('div');b.id='selbar';
    b.innerHTML='<span id="selct">0 selected</span>'
      +'<button class="sba sbocr" data-a="ocr" title="Make the selected scans searchable — on-device OCR (Apple Vision). Nothing is uploaded.">Make Searchable (OCR)</button>'
      +'<button class="sba" data-a="move">Move to…</button>'
      +'<button class="sba" data-a="newfolder">New Folder with Selection</button>'
      // Compare from the LIBRARY, not just from cross-search. Jason, 2026-08-04: he shift-selected
      // two documents here, found no way to compare them, and remembered asking for exactly this.
      // Cross-search has had the affordance all along (#xscmpbtn, disabled unless exactly 2) —
      // the library, which is where you actually have two versions side by side, never got it.
      // Same rule, same wording, same disabled-until-two behavior, so the two surfaces agree.
      +'<button class="sba sbcmp" data-a="compare" disabled title="Compare the two selected documents — select exactly 2">Compare</button>'
      +'<button class="sba" data-a="trash">Trash</button>'
      +'<button class="sba sbx" data-a="clear" title="Clear selection (Esc)">✕</button>';
    (document.getElementById('seltop')||document.body).appendChild(b);
    b.querySelector('[data-a=ocr]').onclick=function(){var p=selPaths().filter(function(q){return /\\.pdf$/i.test(q||'');});if(!p.length){toast('Select PDF scans to OCR');return;}toast('OCR on '+p.length+' file'+(p.length===1?'':'s')+'… on-device');location.href='hvniz://ocr?paths='+jenc(p);};
    b.querySelector('[data-a=move]').onclick=movePick;
    b.querySelector('[data-a=compare]').onclick=doSelCompare;
    b.querySelector('[data-a=newfolder]').onclick=function(){promptNewFolder(folderFilter||ROOT,true);};
    b.querySelector('[data-a=trash]').onclick=trashSel;
    b.querySelector('[data-a=clear]').onclick=clearSel;
    return b;}

  // ── Tools menu bridge (native Tools menu → run on the current browse selection) ──
  // The native Tools menu posts phkTool('id') here; we read the selection and route it to
  // the same hvniz:// actions the selection bar uses (OCR is the on-device Vision path; the
  // three PDF ops are non-destructive batch actions that write new files).
  window.phkTool=function(id){
    var pdfs=selPaths().filter(function(q){return /\\.pdf$/i.test(q||'');});
    if(id==='ocr'){
      if(!pdfs.length){toast('Select scanned PDFs to make searchable');return;}
      toast('Reading '+pdfs.length+' PDF'+(pdfs.length===1?'':'s')+' on your Mac…');
      location.href='hvniz://ocr?paths='+jenc(pdfs);return;
    }
    if(id==='compress'||id==='split'||id==='blank'){
      if(!pdfs.length){toast('Select PDF files first');return;}
      var v={compress:'Compressing',split:'Splitting',blank:'Checking'}[id];
      toast(v+' '+pdfs.length+' PDF'+(pdfs.length===1?'':'s')+'…');
      location.href='hvniz://toolop?op='+id+'&paths='+jenc(pdfs);return;
    }
    if(id==='rename'||id==='organize'){
      var dir=folderFilter||ROOT||'';
      location.href=dir?('hvniz://organize?dir='+encodeURIComponent(dir)):'hvniz://organize';return;
    }
  };
  window.phkToolProgress=function(p){if(!p)return;
    var v={compress:'Compressing',split:'Splitting',blank:'Checking for blank pages in'}[p.op]||'Working on';
    toast(v+' '+(p.name||'')+' — '+((p.done|0)+1)+' of '+p.total+'…');};
  window.phkToolDone=function(r){if(!r)return;toast(r.msg||'Done.');};
  // Batch OCR from the selection bar: chain the existing (search-box) handler so both the
  // search status line AND a browse toast update; add a completion toast (phkOCRDone is new).
  (function(){var prev=window.phkOCRProgress;
    window.phkOCRProgress=function(p){if(prev){try{prev(p);}catch(e){}}
      try{toast('Recognizing '+((p&&p.name)||'')+' — '+(p&&p.done)+' of '+(p&&p.total)+'…');}catch(e){}};})();
  window.phkOCRDone=function(r){var n=(r&&r.total)||0;toast(n+' document'+(n===1?'':'s')+' now searchable.');};

  // ── input dialog (new folder / rename) ──────────────────────────────────────
  function inputDialog(title,msg,val,ok,onOk){var ov=document.createElement('div');ov.className='cfm';
    ov.innerHTML='<div class="cfmbox"><div class="cfmh">'+esc(title)+'</div><div class="cfmm">'+msg+'</div><input class="fmin" type="text">'
      +'<div class="cfmbtns"><button class="cfmcancel">Cancel</button><button class="cfmok">'+esc(ok||'OK')+'</button></div></div>';
    document.body.appendChild(ov);var inp=ov.querySelector('.fmin');inp.value=val||'';
    setTimeout(function(){inp.focus();inp.select();},10);
    function close(){ov.remove();}
    function go(){var v=inp.value.trim();close();onOk(v);}
    ov.querySelector('.cfmcancel').onclick=close;ov.querySelector('.cfmok').onclick=go;
    inp.addEventListener('keydown',function(e){e.stopPropagation();if(e.key==='Enter')go();else if(e.key==='Escape')close();});
    ov.onclick=function(e){if(e.target===ov)close();};}

  // ── selection-aware context menu ────────────────────────────────────────────
  var ctx2=null,ctxItem=null;
  function hideCtx2(){if(ctx2){ctx2.remove();ctx2=null;}}
  // Copy plain text to the clipboard (Copy name / Copy path) — pure DOM, works
  // in WKWebView with no bridge (mirrors the Reader's copyText helper).
  function phkCopyText(s){try{var t=document.createElement('textarea');t.value=s||'';t.style.position='fixed';t.style.opacity='0';document.body.appendChild(t);t.focus();t.select();document.execCommand('copy');t.remove();toast('Copied');}catch(_e){}}
  // THE canonical single-file actions menu, mirrored from the Reader toolbar
  // (#dtctx in reader.html). Every hvniz:// bridge below already exists in
  // main.swift and is path-based, so it behaves identically from the browse
  // view. Dropbox items appear only for files in Jason's Dropbox member space.
  function fileActionRows(it){
    var readerable=it&&(it.category==='PDFs'||it.category==='Decks'||it.category==='Documents');
    var inDbx=/\/Dropbox-HVNTravelGroup\/Jason Paez\//.test((it&&it.path)||'');
    var rows=[];
    if(readerable){rows.push(['Open in PageHawk Reader','openreader','ci-strong']);rows.push(['sep','']);}
    rows.push(['Get Info','getinfo']);
    rows.push(['Rename…','rename']);
    rows.push(['sep','']);
    rows.push(['Copy name','copyname']);
    rows.push(['Copy path','copypath']);
    rows.push(['Copy','copy']);
    rows.push(['sep','']);
    rows.push(['Show in Finder','reveal']);
    rows.push(['Open in default app ↗','open']);
    rows.push(['Open in…','openmenu']);
    rows.push(['Share…','share']);
    if(inDbx){rows.push(['sep','']);rows.push(['Copy Dropbox link','dropboxlink']);rows.push(['Dropbox version history…','dropboxhistory']);}
    rows.push(['sep','']);
    rows.push(['Duplicate file','duplicate']);
    rows.push(['Compress','compress']);
    return rows;
  }
  function showCtx2(x,y,it){hideCtx2();if(!sel.has(it.id))selectOnly(it.id);
    ctxItem=it;
    var nSel=sel.size,multi=nSel>1,m=document.createElement('div');m.className='ctx';var rows=[];
    if(multi){
      // Multiple files selected: only the library-level batch ops apply.
      // Jason 2026-08-30 (REQ-20260830-050): the right-click on a multi-selection is WHERE the
      // together-by-default design keeps its escape, so the tabs row leads when it applies.
      if(readerableSel().length>1)rows.push(['Open in Separate Tabs','opentabs','ci-strong']);
      rows.push(['Open '+nSel+' items','open']);
      rows.push(['Reveal in Finder','reveal']);rows.push(['Copy','copy']);rows.push(['sep','']);
      rows.push(['Move '+nSel+' to…','move']);
      var others0=folderDirs().filter(function(d){return d!==ROOT;});
      if(others0.length){rows.push(['quick','']);others0.slice(0,8).forEach(function(d){rows.push(['→ '+labelOf(d),'moveto:'+d]);});}
      rows.push(['New Folder with Selection…','newfolder']);
      // Exactly two selected is the compare case, and the right-click menu is where a lot of
      // people look first. Offered ONLY at two, so it never appears as a dead row.
      if(nSel===2){rows.push(['sep','']);rows.push(['Compare these 2 documents','compare']);}
      rows.push(['sep','']);rows.push(['Move '+nSel+' to Trash','trash']);
    } else {
      // Single file: the full canonical menu (Reader parity), then the
      // library-only extras (move into a folder, new folder, trash).
      rows=fileActionRows(it);
      var others=folderDirs().filter(function(d){return d!==ROOT;});
      rows.push(['sep','']);rows.push(['Move to…','move']);
      if(others.length){rows.push(['quick','']);others.slice(0,8).forEach(function(d){rows.push(['→ '+labelOf(d),'moveto:'+d]);});}
      rows.push(['New Folder…','newfolder']);
      rows.push(['sep','']);rows.push(['Move to Trash','trash']);
    }
    var html='';rows.forEach(function(r){if(r[0]==='sep')html+='<div class="cisep"></div>';
      else if(r[0]==='quick')html+='<div class="ci cimini" style="cursor:default">Move to a folder:</div>';
      else html+='<div class="ci'+(r[1].indexOf('moveto:')===0?' cimini':'')+(r[2]?' '+r[2]:'')+'" data-a="'+esc(r[1])+'">'+esc(r[0])+'</div>';});
    m.innerHTML=html;document.body.appendChild(m);
    var mw=m.offsetWidth,mh=m.offsetHeight;if(x+mw>innerWidth)x=Math.max(4,innerWidth-mw-6);if(y+mh>innerHeight)y=Math.max(4,innerHeight-mh-6);
    m.style.left=x+'px';m.style.top=y+'px';ctx2=m;
    // Defer the action one tick: opening a confirm/input dialog on mousedown would
    // otherwise have THIS click's bubbled 'click' land on the dialog backdrop and
    // dismiss it instantly. hideCtx2 now, run after the click fully resolves.
    Array.prototype.forEach.call(m.querySelectorAll('.ci[data-a]'),function(c){c.onmousedown=function(e){e.preventDefault();e.stopPropagation();var a=c.dataset.a;hideCtx2();setTimeout(function(){runCtx(a);},0);};});}
  window.__fileCtx=showCtx2;   // exposed so the list view can share the same menu
  function runCtx(a){
    var it=ctxItem||byId(foc);var p=(it&&it.path)||'';
    var g=function(host){if(p)location.href='hvniz://'+host+'?path='+encodeURIComponent(p);};
    if(a==='open'){if(sel.size>1)selItems().forEach(function(x){location.href='hvniz://opendefault?path='+encodeURIComponent(x.path);});else g('opendefault');}
    else if(a==='reveal'){if(sel.size>1)selPaths().forEach(function(q){location.href='hvniz://reveal?path='+encodeURIComponent(q);});else g('reveal');}
    else if(a==='copy'){if(sel.size>1)selPaths().forEach(function(q){location.href='hvniz://copyfile?path='+encodeURIComponent(q);});else g('copyfile');}
    else if(a==='openreader'){if(p)location.href='hvniz://reader?path='+encodeURIComponent(p);}
    else if(a==='getinfo')g('getinfo');
    else if(a==='copyname')phkCopyText(baseOf(p));
    else if(a==='copypath')phkCopyText(p);
    else if(a==='openmenu')g('openmenu');
    else if(a==='share')g('share');
    else if(a==='dropboxlink')g('dropboxlink');
    else if(a==='dropboxhistory')g('dropboxhistory');
    else if(a==='duplicate')g('duplicatefile');
    else if(a==='compress')g('compressfile');
    else if(a==='move')movePick();
    else if(a.indexOf('moveto:')===0){var d=a.slice(7),paths=selPaths().filter(function(q){return dirOf(q)!==d;});if(!paths.length){toast('Already there');}else moveConfirm(paths,d);}
    else if(a==='newfolder')promptNewFolder(folderFilter||ROOT,true);
    else if(a==='rename')renameItem(foc>=0?foc:(Array.from(sel)[0]));
    else if(a==='compare')doSelCompare();
    else if(a==='opentabs')openSelectedInTabs();
    else if(a==='trash')trashSel();}
  document.addEventListener('click',hideCtx2);document.addEventListener('scroll',hideCtx2,true);

  // ── wrap tileEl: selection visuals + click model + drag source ──────────────
  var _tileEl=tileEl;
  tileEl=function(it){var el=_tileEl(it);el.draggable=true;if(sel.has(it.id))el.classList.add('selp');
    el.onclick=function(e){e.stopPropagation();if(e.metaKey||e.ctrlKey)toggle(it.id);else if(e.shiftKey)rangeTo(it.id);else selectOnly(it.id);setFoc(it.id);};
    el.ondblclick=function(e){e.stopPropagation();e.preventDefault();selectOnly(it.id);setFoc(it.id);openItem(it.id);};
    el.oncontextmenu=function(e){e.preventDefault();e.stopPropagation();setFoc(it.id);showCtx2(e.clientX,e.clientY,it);};
    el.addEventListener('dragstart',function(e){if(!sel.has(it.id))selectOnly(it.id);window.__fmDrag=selPaths();
      try{e.dataTransfer.setData('text/plain',selPaths().join('\n'));e.dataTransfer.effectAllowed='move';}catch(_e){}
      var g=document.createElement('div');g.className='dragghost';g.textContent=sel.size+' item'+(sel.size===1?'':'s');document.body.appendChild(g);
      try{e.dataTransfer.setDragImage(g,12,12);}catch(_e){}setTimeout(function(){g.remove();},0);});
    el.addEventListener('dragend',function(){window.__fmDrag=null;});
    return el;};

  // ── wrap visible: apply the folder filter ───────────────────────────────────
  var _visible=visible;
  visible=function(){var v=_visible();if(folderFilter==null)return v;
    return v.filter(function(it){var d=dirOf(it.path||'');return d===folderFilter||d.indexOf(folderFilter+'/')===0;});};

  // ── wrap render: reapply selection visuals afterward ────────────────────────
  var _render=render;
  render=function(){_render.apply(this,arguments);syncSel();};

  // ── marquee (rubber-band) select on empty grid space ────────────────────────
  var grid=document.getElementById('grid'),mq=null,mqStart=null,mqAdd=false;
  grid.addEventListener('mousedown',function(e){if(e.button!==0)return;if(viewMode!=='grid')return;if(e.target.closest('.tile'))return;
    mqAdd=e.metaKey||e.ctrlKey||e.shiftKey;if(!mqAdd)clearSel();
    mqStart={x:e.clientX,y:e.clientY};mq=document.createElement('div');mq.id='marquee';document.body.appendChild(mq);
    e.preventDefault();document.addEventListener('mousemove',mqMove,true);document.addEventListener('mouseup',mqUp,true);});
  function mqMove(e){if(!mq)return;var x1=Math.min(mqStart.x,e.clientX),y1=Math.min(mqStart.y,e.clientY),x2=Math.max(mqStart.x,e.clientX),y2=Math.max(mqStart.y,e.clientY);
    mq.style.left=x1+'px';mq.style.top=y1+'px';mq.style.width=(x2-x1)+'px';mq.style.height=(y2-y1)+'px';
    Array.prototype.forEach.call(grid.querySelectorAll('.tile'),function(el){var r=el.getBoundingClientRect();
      var hit=!(r.right<x1||r.left>x2||r.bottom<y1||r.top>y2),id=+el.dataset.id;
      if(hit)sel.add(id);else if(!mqAdd)sel.delete(id);el.classList.toggle('selp',sel.has(id));});
    var bar=document.getElementById('selbar');if(sel.size>0){if(!bar)bar=buildSelBar();bar.style.display='flex';selBarSync();}else if(bar)bar.style.display='none';}
  function mqUp(){document.removeEventListener('mousemove',mqMove,true);document.removeEventListener('mouseup',mqUp,true);if(mq){mq.remove();mq=null;}syncSel();}

  // ── keyboard (⌘A select all · Esc clear · Delete→Trash · Shift+arrows extend) ─
  document.addEventListener('keydown',function(e){
    if(document.querySelector('.cfm'))return;
    if(/^(input|textarea)$/i.test(e.target.tagName||''))return;
    var lb=document.getElementById('lb');if(lb&&lb.classList.contains('on'))return;
    // A full-window preview takeover owns the keyboard: don't let grid nav steal arrows/Esc.
    // (This capture handler runs before the preview's; without the bail, arrow nav populated the
    //  grid selection and its Esc→clearSel+stopPropagation swallowed presentation/focus Esc.)
    if(document.body.classList.contains('pv-present')||document.body.classList.contains('pv-focus'))return;
    var k=e.key.toLowerCase();
    if((e.metaKey||e.ctrlKey)&&k==='a'){selectAllVisible();e.preventDefault();return;}
    // Esc cancels an armed Compare BEFORE it clears the selection: arming is the more recent,
    // more surprising state, so it is the one Esc should undo first.
    if(k==='escape'&&cmpArmed){disarmCompare();e.preventDefault();e.stopPropagation();return;}
    if(k==='escape'&&sel.size){clearSel();e.preventDefault();e.stopPropagation();return;}
    if((k==='delete'||k==='backspace')&&sel.size){trashSel();e.preventDefault();return;}
    if(k==='arrowright'||k==='arrowleft'||k==='arrowup'||k==='arrowdown'){
      var shift=e.shiftKey;setTimeout(function(){if(foc!=null&&foc>=0){if(shift){sel.add(foc);syncSel();}else selectOnly(foc);}},0);}
  },true);

  buildSidebar();render();
  // "There are only two" is a statement about what is ON SCREEN, so the Compare button has to be
  // re-read after every re-render (folder filter, search, view switch), not only when the
  // selection changes. Wrapping the existing global render is deliberate: it is one hook that
  // cannot miss a call site, where sprinkling topCmpSync() through the render paths would.
  topCmpSync();
  (function(){var r=window.render;if(typeof r!=='function')return;
    window.render=function(){var v=r.apply(this,arguments);try{topCmpSync();}catch(e){}return v;};})();

  // The boot-time "ask once" pass (applyRememberedOpenPref + maybeAskHowToOpen) retired
  // 2026-08-30 with the card (REQ-20260830-050): a set opens together, nothing asked.
})();
"""


def _folder_default_view(items):
    # Which layout the folder view opens in. The user's last explicit choice wins and is stored
    # in config.json (folder_view) by the Swift `setview` bridge, so it survives the file:// page
    # reload where WKWebView does not reliably persist localStorage. With no saved choice, default
    # to List (the preview pane, where you hold a page and flip it across files) when the folder is
    # document-heavy, and Grid for photo/mixed piles.
    try:
        cfg = json.load(open(os.path.expanduser("~/Library/Application Support/PageHawk/config.json")))
        v = str(cfg.get("folder_view", "")).strip()
        if v in ("grid", "list", "column", "filmstrip"):
            return v
    except Exception:
        pass
    docs = sum(1 for it in items if it.get("category") in ("PDFs", "Decks", "Documents", "Spreadsheets"))
    return "list" if items and docs * 2 >= len(items) else "grid"

def html_grid_mode(task, items, theme="dark", roots=None):
    # roots = the folders the user actually opened (multi-folder). Passed to the JS so the
    # sidebar shows EACH opened folder as its own top-level entry, instead of collapsing
    # unrelated folders under a bogus common ancestor (e.g. the home folder).
    # readerHtml: the absolute path to reader.html, so the preview pane can EMBED the real reader
    # (Jason's ballot option B, 2026-08-27) rather than approximate it. Empty when the reader
    # cannot be located, and the pane then keeps its own fast preview instead of framing a 404.
    data = json.dumps({"task": task, "items": items, "roots": roots or [],
                       "defaultView": _folder_default_view(items),
                       "readerHtml": _reader_html_path()})
    if theme not in THEMES:
        theme = DEFAULT_THEME
    palette = "".join(f"html[data-theme='{k}']{{{v}}}" for k, v in THEMES.items()) + BRAND_CSS
    return f"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>{task}</title>
{_FONTS}<style>{palette}</style><style>{GRID_CSS}</style><style>{SEARCH_CSS}</style><style>{XSEARCH_CSS}</style><style>{FM_CSS}</style>
<script>document.documentElement.dataset.theme=new URLSearchParams(location.search).get('scheme')||localStorage.getItem('vtriage-theme')||((window.matchMedia&&matchMedia('(prefers-color-scheme:dark)').matches)?'dark':'{theme}');</script></head><body>
<div id="top"><div class="brandc">{BRAND_MARK}<div id="brand">{_brand_name()}</div></div>
<div class="tal" id="pos">-</div><div class="chips" id="chips"></div><div id="seltop"></div><div class="sp"></div>
<label class="szl" id="topsize" title="Thumbnail size">Size <input id="tw" class="szr" type="range" min="120" max="380" step="20"></label>
<div class="vdrop" id="topview"><button class="b vbtn" id="viewbtn" onclick="toggleVMenu()" title="Change layout">☰ List ▾</button>
<div id="vmenu" class="vmenu">
<div class="vlbl">View as</div>
<div class="vseg vseg4"><button data-vm="grid" onclick="setViewMode('grid');syncVMenu();" title="Thumbnail grid (⌘1)">▦ Grid</button><button data-vm="list" onclick="setViewMode('list');syncVMenu();" title="Sortable list + preview (⌘2)">☰ List</button><button data-vm="column" onclick="setViewMode('column');syncVMenu();" title="Finder columns: folders ▸ files ▸ preview (⌘3)">◫ Columns</button><button data-vm="filmstrip" onclick="setViewMode('filmstrip');syncVMenu();" title="Gallery: big preview + thumbnail strip (⌘4)">▤ Gallery</button></div>
<div class="vsep"></div>
<div class="vmf"><div class="vrow" data-v="groupfold" onclick="setView('groupfold',!groupByFolder);syncVMenu();"><span class="vck">✓</span>Group by folder</div>
<div class="vrow" data-v="foltags" onclick="setView('foltags',!showFolderTags);syncVMenu();"><span class="vck">✓</span>Show folder tags</div>
<div class="vsep"></div></div>
<div id="fnsection"><div class="vlbl">Filenames</div>
<div class="vseg"><button data-fn="full" onclick="setView('fnmode','full');syncVMenu();">Full</button><button data-fn="short" onclick="setView('fnmode','short');syncVMenu();">Short</button><button data-fn="off" onclick="setView('fnmode','off');syncVMenu();">Off</button></div></div>
<div class="vlbl">Density</div>
<div class="vseg"><button data-dens="cozy" onclick="setDensity('cozy');syncVMenu();" title="Comfortable spacing">Cozy</button><button data-dens="compact" onclick="setDensity('compact');syncVMenu();" title="Tighter spacing — more on screen">Compact</button></div>
</div></div>
<span id="setsw"><button id="setswT" class="on" onclick="phkSetSwitch('together')" title="Show every document in this set in one window">Together</button><button id="setswS" onclick="phkSetSwitch('separately')" title="Open each document in this set in its own window">Separately</button></span><button class="b cmpb" id="topcmp" onclick="topCompare()" title="Compare two documents side by side">⇄ Compare</button><button class="b" id="slidebtn" onclick="toggleSlideshow()" title="Start slideshow (auto-advance)">▷</button><button class="b" id="toprefresh" onclick="location.href='hvniz://recache'" title="Refresh &amp; recache: re-read every file from disk (use after you edit a file)">↻</button><button class="b" id="toptheme" onclick="toggleTheme()" title="Cycle scheme">◐</button></div>
<div id="workspace"><aside id="fsidebar"></aside><div id="grid"></div></div>
<div id="hint"><kbd>click</kbd> select &nbsp; <kbd>⌘</kbd>/<kbd>shift</kbd>+click multi &nbsp; drag to a folder to move &nbsp; <kbd>right-click</kbd> actions &nbsp; <kbd>⌘K</kbd> jump &nbsp; <kbd>dbl-click</kbd>/<kbd>Space</kbd> preview</div>
<div id="gridpath" title="Current folder: click any part to open it in Finder"></div>
<div id="lb"><div id="lbtop"><span class="nm" id="lbnm"></span><span id="lbfold"></span><span id="lbex"></span><span class="ct" id="lbct"></span><span class="ct" id="lbpg" style="display:none"></span><input id="lbjump" class="lbjump" type="text" inputmode="numeric" list="lbjumplist" autocomplete="off" style="display:none" title="Go to page — type a number or pick from the list"><datalist id="lbjumplist"></datalist><span id="lbppv" style="display:none" title="How many pages to show side by side"><svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><rect x="1.5" y="2.5" width="5.4" height="11" rx="1" fill="none" stroke="currentColor" stroke-width="1.3"/><rect x="9.1" y="2.5" width="5.4" height="11" rx="1" fill="none" stroke="currentColor" stroke-width="1.3"/></svg><select id="lbppvsel" onchange="setPPV(parseInt(this.value))"></select></span>
<div id="lbb"><button id="lbtheme" style="display:none" onclick="lbCycleTheme()" title="Reading theme: Day / Night / Sepia (t)">◐</button><button id="lbedit" style="display:none" onclick="openReaderDoc()" title="Edit this document: sign, fill, page tools, annotate (R)">Edit ↗</button><button onclick="revealItem(byId(lbId))" title="Reveal in Finder">Reveal in Finder</button><button onclick="openExternal(byId(lbId))" title="Open in default app">Open ↗</button></div>
<span id="lbx" onclick="closeLB()" title="Close (Esc)">✕</span></div>
<div id="lbpic-wrap"><img id="lbpic" alt=""><span id="lbextra"></span><video id="lbvid" controls playsinline preload="metadata" style="display:none"></video><div id="lbload"><div class="docskel"><span class="skl h"></span><span class="skl m"></span><span class="skl s"></span><span class="skl m"></span><span class="skl q"></span><span class="skl m"></span><span class="skl s"></span></div></div></div>
<div id="lbhint"><kbd>←</kbd> <kbd>→</kbd> files &nbsp; <kbd>↑</kbd> <kbd>↓</kbd> pages &nbsp; <kbd>Enter</kbd>/<kbd>F</kbd>/<kbd>Esc</kbd> back to Grid</div>
<div id="lbpath" title="Click a folder to open it in Finder"></div></div>
<div id="defbar"><span>Open every file this fast: make PageHawk your default for PDFs?</span><button id="defyes">Make default</button><button id="defno">Not now</button><label id="defnever" title="Stop asking about default apps"><input type="checkbox" id="defneverck"><span>Don't ask again</span></label><button id="defx" aria-label="Dismiss" title="Dismiss">&times;</button></div>
{SEARCH_DIV}
<script>{GRID_JS.replace("__DATA__", data)}</script>
<script>{GRID_SEARCH_JS}</script>
<script>{XSEARCH_JS}</script>
<script>{FM_JS}</script></body></html>"""


# ---------------------------------------------------------------------------
# doc mode: compare multi-page document VERSIONS, pick the best one to keep.
# Pre-renders every page (pdftoppm for PDF; soffice->pdf->pdftoppm for PPTX/etc;
# qlmanage page-1 fallback + a warning when no converter). Computes a per-page
# "changed-page map" so the reviewer jumps to what differs.
# ---------------------------------------------------------------------------
def _soffice():
    for c in (shutil.which("soffice"), "/Applications/LibreOffice.app/Contents/MacOS/soffice"):
        if c and os.path.exists(c):
            return c
    return None

def render_pages(path, thumbdir, max_pages=80, dpi=110):
    """Return (list of page file:// urls, needs_converter_bool)."""
    os.makedirs(thumbdir, exist_ok=True)
    # Spreadsheets skip the soffice->PDF render: a grid paginates into unreadable column bands
    # (see SPREADSHEET_EXT). One fast Quick Look thumbnail (first sheet) is enough to identify the
    # file in the grid; opening it hands off to the user's spreadsheet app.
    if is_spreadsheet(path):
        one = thumbnail(path, thumbdir)
        return ([one] if one else []), False
    stem, sib = _cache_name(path, "pg")
    pdir = os.path.join(thumbdir, stem)
    if os.path.isdir(pdir) and glob.glob(pdir + "/p-*.png"):
        return [furl(p) for p in sorted(glob.glob(pdir + "/p-*.png"))[:max_pages]], False
    _prune_stale(thumbdir, sib, stem)   # an edited file re-renders; drop its old pages
    os.makedirs(pdir, exist_ok=True)
    pdf = path
    needs_conv = False
    if os.path.splitext(path)[1].lower() != ".pdf":
        so = _soffice()
        if so:
            subprocess.run([so, "--headless", "--convert-to", "pdf", "--outdir", pdir, path],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=180)
            cand = glob.glob(pdir + "/*.pdf")
            pdf = cand[0] if cand else None
        else:
            needs_conv = True; pdf = None
    if pdf:
        subprocess.run(["pdftoppm", "-png", "-r", str(dpi), "-scale-to", "1600",
                        pdf, os.path.join(pdir, "p")],
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=300)
        pages = sorted(glob.glob(pdir + "/p-*.png"))[:max_pages]
        if pages:
            return [furl(p) for p in pages], needs_conv
    # fallback: single Quick Look page
    one = thumbnail(path, pdir)
    return ([one] if one else []), True

def to_pdf(path, cachedir):
    """Absolute path to a PDF rendering of `path` for the in-app reader: the file itself if it
    is already a PDF, else a LibreOffice conversion (PPTX/Keynote/Word/Excel/…). Content-keyed
    so an edit re-converts; None if conversion isn't possible (no soffice / unsupported)."""
    if os.path.splitext(path)[1].lower() == ".pdf":
        return path
    # Spreadsheets are NOT routed through the in-app PDF reader — a sheet-as-PDF is unreadable
    # (see SPREADSHEET_EXT for the measurement). Callers must not reach this with a sheet: the
    # shell routes them to the user's spreadsheet app BEFORE conversion (Swift openInDefaultApp /
    # the grid's openReaderDoc). This stays as a backstop so a new caller can never render a grid.
    if is_spreadsheet(path):
        return None
    os.makedirs(cachedir, exist_ok=True)
    stem, sib = _cache_name(path, "topdf")
    pdir = os.path.join(cachedir, stem)
    out_pdf = os.path.join(pdir, "doc.pdf")
    if os.path.exists(out_pdf):
        return out_pdf
    _prune_stale(cachedir, sib, stem)
    so = _soffice()
    if not so:
        return None
    os.makedirs(pdir, exist_ok=True)
    try:
        subprocess.run([so, "--headless", "--convert-to", "pdf", "--outdir", pdir, path],
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=240)
    except Exception:
        return None
    cands = glob.glob(pdir + "/*.pdf")
    if not cands:
        return None
    if cands[0] != out_pdf:
        try:
            os.replace(cands[0], out_pdf)
        except OSError:
            out_pdf = cands[0]
    return out_pdf

def _ahash(png_local):
    try:
        from PIL import Image
        im = Image.open(png_local).convert("L").resize((8, 8))
        px = list(im.getdata()); avg = sum(px) / len(px)
        return sum(1 << i for i, v in enumerate(px) if v > avg)
    except Exception:
        return None

def _local(u):
    return u[len("file://"):].replace("%20", " ").replace("%23", "#").replace("%3F", "?") if u.startswith("file://") else u

def _doctor():
    """Report which rendering tools are present and what each enables, with install
    hints. The portability self-check: `triage_gen.py --check`."""
    try:
        import PIL  # noqa: F401
        pil = True
    except Exception:
        pil = False
    checks = [
        ("python3",  bool(sys.executable),        "runs this generator",                    "ships with macOS / python.org"),
        ("qlmanage", bool(shutil.which("qlmanage")), "thumbnails for ANY file type (Quick Look)", "built into macOS (macOS only)"),
        ("sips",     bool(shutil.which("sips")),   "HEIC / TIFF / BMP image conversion",     "built into macOS (macOS only)"),
        ("pdftoppm", bool(shutil.which("pdftoppm")), "render PDF pages",                      "brew install poppler"),
        ("pdfinfo",  bool(shutil.which("pdfinfo")), "count PDF pages",                        "brew install poppler"),
        ("soffice",  bool(_soffice()),             "render PPTX / Keynote pages",            "install LibreOffice (libreoffice.org)"),
        ("Pillow",   pil,                          "changed-page highlight rings (optional)", "pip install Pillow"),
    ]
    print("Triage Review and Compare: tool check\n")
    ok = 0
    for name, present, enables, how in checks:
        ok += 1 if present else 0
        print("  [%s] %-9s %s" % ("OK " if present else "MISSING", name, enables))
        if not present:
            print("           install: %s" % how)
    print("\n  %d/%d present. macOS is required: qlmanage and sips are macOS-only." % (ok, len(checks)))
    print("  Minimum to run at all: python3. PDFs need poppler; PPTX/Keynote need LibreOffice.")
    print("  Web-safe images (jpg/png/gif/webp) need no extra tools.")

def changed_pages(a_urls, b_urls):
    """Indices (1-based) where two versions' same-numbered pages differ perceptually."""
    diff = []
    for i in range(max(len(a_urls), len(b_urls))):
        if i >= len(a_urls) or i >= len(b_urls):
            diff.append(i + 1); continue
        ha, hb = _ahash(_local(a_urls[i])), _ahash(_local(b_urls[i]))
        if ha is None or hb is None or bin(ha ^ hb).count("1") > 6:
            diff.append(i + 1)
    return diff

def prep_doc(groups, thumbdir, max_pages=80):
    """groups: [{group, versions:[{path,label?}], guess?}] -> render pages + diff + pre-flag."""
    warnings = set()
    for gi, g in enumerate(groups):
        g["id"] = gi
        vers = g["versions"]
        for v in vers:
            v.setdefault("label", os.path.basename(v["path"]))
            v.setdefault("name", os.path.basename(v["path"]))
            urls, needs = render_pages(v["path"], thumbdir, max_pages)
            v["pages"] = urls; v["page_count"] = len(urls)
            try:
                st = os.stat(v["path"])
                v["mtime"] = st.st_mtime; v["bytes"] = st.st_size
                v["btime"] = getattr(st, "st_birthtime", st.st_mtime)  # created (macOS)
            except OSError:
                v["mtime"] = v["btime"] = 0; v["bytes"] = 0
            if needs: warnings.add(os.path.splitext(v["path"])[1].lower())
        # common prefix/suffix across version names -> show common part once, bracket the rest
        names = [os.path.splitext(os.path.basename(v["path"]))[0] for v in vers]
        pre = os.path.commonprefix(names)
        suf = os.path.commonprefix([n[::-1] for n in names])[::-1]
        for v, n in zip(vers, names):
            mid = n[len(pre):len(n) - len(suf)] if (len(pre) + len(suf) < len(n)) else n
            v["short"] = mid.strip(" -_.") or n
            v["stem"] = n  # full basename (no ext) so the label can show the WHOLE filename
        g["common"] = ((pre.strip() + " […] " + suf.strip()).strip() if (pre or suf) else "")
        # keep the exact common prefix/suffix so the header can render the FULL filename
        # (dim the shared part, bold the distinguishing part) and always match files on disk
        g["pre"], g["suf"] = pre, suf
        # changed-page map = union of differences across consecutive versions
        cp = set()
        for a, b in zip(vers, vers[1:]):
            cp.update(changed_pages(a["pages"], b["pages"]))
        g["changed"] = sorted(cp)
        # Claude's recommended version. Prefer an EXPLICIT pick passed in the
        # manifest (g["recommend"] = index; Claude's actual reasoned choice).
        # Fall back to a mechanical heuristic only when none is given:
        # highest version token, else newest mtime, else most pages, else largest.
        def vnum(v):
            import re
            m = re.findall(r"v(\d+)", v["label"].lower()); return int(m[-1]) if m else -1
        best = max(range(len(vers)), key=lambda k: (vnum(vers[k]), vers[k]["mtime"],
                                                    vers[k]["page_count"], vers[k]["bytes"]))
        rec = g.get("recommend")
        g["guess_idx"] = rec if isinstance(rec, int) and 0 <= rec < len(vers) else best
        g["rec_explicit"] = isinstance(rec, int) and 0 <= rec < len(vers)
    return groups, sorted(warnings)

DOC_JS = r"""
const DATA=__DATA__; const KEY="vtriage:"+DATA.task;
let gi=0, decisions={}; try{decisions=JSON.parse(localStorage.getItem(KEY)||"{}")}catch(e){}
const groups=DATA.items;
function firstUndecided(){for(let k=0;k<groups.length;k++)if(!decisions[groups[k].id])return k;return groups.length;}
gi=firstUndecided();
let inFocus=false, fV=0, fP=0, sel=0, curG=-1;
let layout=localStorage.getItem('vtriage-doclayout')||'__LAYOUT__';  // default from skill preferred_layout.txt
function g(){return groups[gi];}
function maxPages(){return Math.max(1,...g().versions.map(v=>v.pages.length));}
var SCHEMES=['light','ivory','white','slate','charcoal','dark'];
function toggleTheme(){location.href='hvniz://appearance?toggle';}   // the ◐ button = app-wide light/dark toggle
window.__setScheme=function(s){if(!s)return;document.documentElement.setAttribute('data-theme',s);try{localStorage.setItem('vtriage-theme',s)}catch(e){}};
window.addEventListener('message',function(e){if(e.data&&e.data.type==='jpdev-scheme')window.__setScheme(e.data.scheme);});
// Follow the OS light/dark (set via the app's Appearance pref / system) unless a scheme is pinned.
try{matchMedia('(prefers-color-scheme:dark)').addEventListener('change',function(e){if(!localStorage.getItem('vtriage-theme'))document.documentElement.setAttribute('data-theme',e.matches?'dark':'light');});}catch(_e){}
function toggleLayout(){layout=layout==='cols'?'rows':'cols';localStorage.setItem('vtriage-doclayout',layout);render();}
function fmtD(e){if(!e)return '?';try{return new Date(e*1000).toISOString().slice(0,10);}catch(x){return '?';}}
function ext(v){return (v.name.split('.').pop()||'').toLowerCase();}
// Show the FULL filename (so labels always match files on disk), with the shared
// prefix/suffix dimmed and the distinguishing part bold for a fast visual scan.
function vFullName(grp,v){
  const stem=v.stem||v.label, pre=grp.pre||"", suf=grp.suf||"";
  let p="",s="",mid=stem;
  if(pre.length+suf.length < stem.length){ p=stem.slice(0,pre.length); s=stem.slice(stem.length-suf.length); mid=stem.slice(pre.length,stem.length-suf.length); }
  const dim=x=>x?`<span class="vdim">${x}</span>`:"";
  return `${dim(p)}<b class="vmid">${mid||stem}</b>${dim(s)}<span class="vext">.${ext(v)}</span>`;
}
function metaLine(v){return `${v.page_count}p &nbsp;||&nbsp; ${(v.bytes/1048576).toFixed(1)} MB &nbsp;||&nbsp; Created ${fmtD(v.btime)} &nbsp;||&nbsp; Edited ${fmtD(v.mtime)}`;}
function setSel(k){sel=k;render();}
function vHeader(grp,V,k){const v=V[k];
  const d=decisions[grp.id];
  const isWin=d&&d.decision==='keep'&&d.winner_idx===k;
  const rec=(k===grp.guess_idx)?` <span class="recbadge">★ Claude's pick · Recommended</span>`:'';
  const win=isWin?' <span class="winbadge">WINNER</span>':'';
  return `<div class="vhh ${k===sel?'vsel':''}" onclick="setSel(${k})" title="${v.name}">
  <div class="vname">${vFullName(grp,v)}${rec}${win}</div>
  <div class="vsub">${metaLine(v)}</div>
  <button class="keepbtn" onclick="event.stopPropagation();pick(${k})">Keep ✓ (${k+1})</button></div>`;}
function pLabel(grp,r){const chg=(grp.changed||[]).includes(r+1);
  return `<div class="rl ${chg?'rlchg':''}" onclick="openFocus(sel,${r})" title="open page ${r+1} to compare across versions">p${r+1}${chg?' Δ':''}</div>`;}
function cell(grp,V,k,r){const u=V[k].pages[r];const chg=(grp.changed||[]).includes(r+1);
  return u?`<div class="cell ${chg?'chg':''} ${k===sel?'selv':''}" onclick="openFocus(${k},${r})"><img loading="lazy" src="${u}"></div>`:`<div class="cell empty"></div>`;}
// GRID: versions and pages on two axes (toggle which is which); changed pages ringed; click a page to compare it across versions.
function render(){
  if(gi>=groups.length){showDone();return;}
  if(curG!==gi){curG=gi;sel=g().guess_idx||0;}
  const grp=g(), V=grp.versions; if(sel>=V.length)sel=grp.guess_idx||0;
  const np=maxPages(); let html="";
  const vw=window.innerWidth;
  // Thumbnails as LARGE as reasonable, responsive to how many files are compared.
  // Fewer files -> bigger thumbs; scroll to see the rest rather than shrink them.
  if(layout==='cols'){
    // versions across the top; show ~min(versions, 4) columns at full width, scroll for more
    const showV=Math.min(Math.max(V.length,1),4);
    const cw=Math.max(340, Math.floor((vw-90)/showV));
    const tc=`58px repeat(${V.length},${cw}px)`;
    let head=`<div class="rl corner"></div>`+V.map((v,k)=>vHeader(grp,V,k)).join("");
    let body=""; for(let r=0;r<np;r++){body+=pLabel(grp,r);for(let k=0;k<V.length;k++)body+=cell(grp,V,k,r);}
    html=`<div id="ghead" style="grid-template-columns:${tc}">${head}</div><div id="gbody" style="grid-template-columns:${tc}">${body}</div>`;
  } else {
    // versions down the side; pages across at LARGE fixed width, scroll horizontally.
    // show ~min(versions,3)+0.4 pages across so thumbnails stay big; fewer versions -> bigger.
    const across=Math.min(Math.max(V.length,2),3)+0.4;
    const pw=Math.max(320, Math.floor((vw-240)/across));
    const tc=`210px repeat(${np},${pw}px)`;
    let head=`<div class="rl corner"></div>`; for(let r=0;r<np;r++)head+=pLabel(grp,r);
    let body=""; for(let k=0;k<V.length;k++){body+=vHeader(grp,V,k);for(let r=0;r<np;r++)body+=cell(grp,V,k,r);}
    html=`<div id="ghead" class="rowsmode" style="grid-template-columns:${tc}">${head}</div><div id="gbody" class="rowsmode" style="grid-template-columns:${tc}">${body}</div>`;
  }
  document.getElementById("stage").innerHTML=html;
  document.getElementById("pos").textContent=`group ${gi+1}/${groups.length} · ${V.length} versions · ${np} pages · ${layout==='cols'?'versions across ⤢':'versions down ⤡'}`;
  document.getElementById("meta").innerHTML=`<span class="tag">Variations of: <b>${grp.common||grp.group}</b></span>`+((grp.changed&&grp.changed.length)?`<span class="tag">Δ pages ${grp.changed.join(', ')}</span>`:`<span class="tag">no page differences detected</span>`)+`<span class="tag">selected → <b>${V[sel].short||V[sel].label}</b> · Enter to keep</span>`;
  document.getElementById("fill").style.width=(Object.keys(decisions).length/groups.length*100)+"%";
}
// FOCUS: one page full size; left/right = same page across versions; up/down = change page.
function openFocus(v,p){inFocus=true;fV=v;fP=p;renderFocus();}
function renderFocus(){const grp=g(),V=grp.versions,n=V.length; fV=((fV%n)+n)%n;
  let pg=V[fV].pages; if(fP>=pg.length)fP=pg.length-1; if(fP<0)fP=0;
  const chg=(grp.changed||[]).includes(fP+1);
  const v=V[fV]; const fc=document.getElementById("focus"); fc.style.display="flex";
  fc.innerHTML=`<div id="fbar">
    <div class="fb1">File ${fV+1} of ${n}: <span class="accent">${v.name}</span>${fV===grp.guess_idx?` <span class="star">★ Claude's pick · Recommended</span>`:''}${(decisions[grp.id]&&decisions[grp.id].decision==='keep'&&decisions[grp.id].winner_idx===fV)?' <span class="winbadge">WINNER</span>':''}</div>
    <div class="fb2"><span class="pagenum">Page ${fP+1} of ${pg.length}</span>${chg?'<span class="diffpill">Changed page</span>':'<span class="samepill">Identical across versions</span>'}</div>
    <div class="fb3">${metaLine(v)}</div></div>
    <img id="fimg" src="${pg[fP]}">
    <div id="fhint"><kbd>←</kbd>/<kbd>→</kbd> same page, next/prev file &nbsp; <kbd>↑</kbd>/<kbd>↓</kbd> change page &nbsp; <kbd>1-9</kbd> jump to file &nbsp; <kbd>Enter</kbd> keep this file &nbsp; <kbd>Esc</kbd> back</div>`;
  V.forEach(v=>{const u=v.pages[fP]; if(u){const im=new Image();im.src=u;}});  // preload same page across versions
}
function closeFocus(){inFocus=false;document.getElementById("focus").style.display="none";}
function pick(k){const grp=g();
  decisions[grp.id]={group:grp.group, common:grp.common, decision:"keep", winner_idx:k, winner:grp.versions[k].path,
    winner_label:grp.versions[k].short, my_guess_idx:grp.guess_idx, versions:grp.versions.map(v=>v.path), ts:Date.now()};
  localStorage.setItem(KEY,JSON.stringify(decisions));
  const f=document.getElementById("flash"),match=(k===grp.guess_idx);
  f.textContent=`KEEP "${grp.versions[k].short||grp.versions[k].label}"`+(match?"  ✓ matched Claude's pick":`  ·  Claude recommended "${grp.versions[grp.guess_idx].short}"`);
  f.style.background=match?'rgba(34,168,103,.92)':'rgba(224,146,46,.92)';f.style.opacity=1;setTimeout(()=>f.style.opacity=0,460);
  closeFocus();gi++;render();}
function skip(){const grp=g();decisions[grp.id]={group:grp.group,decision:"skip",winner_idx:null,my_guess_idx:grp.guess_idx,ts:Date.now()};
  localStorage.setItem(KEY,JSON.stringify(decisions));closeFocus();gi++;render();}
function undo(){let j=gi-1;while(j>=0&&!decisions[groups[j].id])j--;if(j<0)return;delete decisions[groups[j].id];
  localStorage.setItem(KEY,JSON.stringify(decisions));gi=j;curG=-1;closeFocus();
  document.getElementById("done").style.display="none";document.getElementById("stage").style.display="block";
  document.getElementById("meta").style.display="block";document.getElementById("controls").style.display="flex";render();}
function showDone(){closeFocus();document.getElementById("stage").style.display="none";document.getElementById("meta").style.display="none";
  document.getElementById("controls").style.display="none";const dn=document.getElementById("done");dn.style.display="flex";
  document.getElementById("donesum").textContent="Decided "+Object.keys(decisions).length+" of "+groups.length+" version-groups";}
function exportD(){const out={task:DATA.task,mode:"doc",exported:new Date().toISOString(),theme:document.documentElement.dataset.theme,
  total:groups.length,reviewed:Object.keys(decisions).length,decisions:Object.values(decisions)};
  const b=new Blob([JSON.stringify(out,null,2)],{type:"application/json"});const a=document.createElement("a");
  a.href=URL.createObjectURL(b);a.download="triage-decisions-"+DATA.task+".json";a.click();}
document.addEventListener("keydown",e=>{const K=e.key;
  if(K==="t"||K==="T"){toggleTheme();e.preventDefault();return;}
  if((K==="o"||K==="O")&&!inFocus){toggleLayout();e.preventDefault();return;}
  if(inFocus){
    if(K==="ArrowLeft"){fV--;renderFocus();}
    else if(K==="ArrowRight"){fV++;renderFocus();}
    else if(K==="ArrowUp"){fP--;renderFocus();}
    else if(K==="ArrowDown"){fP++;renderFocus();}
    else if(K>="1"&&K<="9"){const k=+K-1;if(k<g().versions.length){fV=k;renderFocus();}}
    else if(K==="Enter"||K==="k"||K==="K"){pick(fV);}
    else if(K==="Escape"){closeFocus();}
    else if(K==="e"||K==="E"){exportD();}
    else return; e.preventDefault();
  } else {
    if(K>="1"&&K<="9"){const k=+K-1;if(k<g().versions.length)setSel(k);}
    else if(K==="Enter"||K==="k"||K==="K"){pick(sel);}
    else if(K==="s"||K==="S"){skip();}
    else if(K==="z"||K==="Z"){undo();}
    else if(K==="e"||K==="E"){exportD();}
    else return; e.preventDefault();
  }
});
window.addEventListener('resize',()=>{if(!inFocus)render();});
render();
"""

DOC_CSS = """
#stage{display:block;height:calc(100vh - 156px);overflow:auto;padding:8px 16px}
#ghead{display:grid;gap:10px;position:sticky;top:0;background:var(--bg);padding:8px 0;z-index:3;border-bottom:1px solid var(--border)}
.vhh{text-align:center}
.vhh .vname{font-family:var(--font-head);font-size:15px;color:var(--fg);line-height:1.25;word-break:break-word} .vhh .star{color:var(--accent-dark)}
.vhh .vname .vdim{color:var(--mut);font-weight:400} .vhh .vname .vmid{font-weight:700;color:var(--fg)}
.vhh .vsub{font-size:11px;color:var(--mut);margin:2px 0 6px}
.keepbtn{border:0;border-radius:7px;padding:5px 12px;font-size:12px;font-weight:600;background:var(--keep);color:#fff;cursor:pointer}
.keepbtn:hover{filter:brightness(1.08)}
#gbody{display:grid;gap:10px;padding-top:10px}
.rl{font-size:11px;color:var(--mut);display:flex;align-items:center;justify-content:center;padding:4px;cursor:pointer;border-radius:4px}
.rl:hover{background:var(--chip);color:var(--fg)} .rl.corner{cursor:default} .rl.corner:hover{background:none}
.rl.rlchg{color:var(--unsure);font-weight:600}
.vhh.vsel{background:var(--gold-tint,rgba(200,168,114,.16));border-radius:8px;outline:2px solid var(--accent)}
.winbadge{background:var(--accent);color:var(--accent-fg);font-size:9px;font-weight:700;padding:1px 5px;border-radius:3px;letter-spacing:.4px;vertical-align:middle}
.recbadge{background:transparent;color:var(--accent-dark);border:1px solid var(--accent);font-size:9px;font-weight:700;padding:1px 6px;border-radius:3px;letter-spacing:.3px;vertical-align:middle;white-space:nowrap}
.cell.selv{outline:2px solid var(--accent)}
.rowsmode .vhh{text-align:left;display:flex;flex-direction:column;justify-content:center;padding:4px 8px;border-right:1px solid var(--border)}
.rowsmode .vhh .vsub{margin:3px 0 5px}
.cell{position:relative;background:var(--imgbg);border-radius:6px;overflow:hidden;cursor:pointer;border:2px solid transparent;display:flex;align-items:flex-start;justify-content:center;box-shadow:var(--shadow)}
.cell img{width:100%;display:block}
.cell:hover{border-color:var(--accent)}
.cell.chg{box-shadow:inset 0 0 0 3px var(--unsure)}
.cell.empty{background:transparent;cursor:default;border:none;box-shadow:none}
.vext{color:var(--mut);font-weight:400}
/* Focus overlay is ALWAYS dark (pages pop on it), so its text is fixed light-on-dark
   regardless of theme. HVN palette: gold #C8A872 accent, gold-tint pills. */
#focus{display:none;position:fixed;inset:0;background:#0b0b0d;z-index:30;flex-direction:column;align-items:center;justify-content:flex-start;gap:10px;padding:14px 20px}
#fbar{width:100%;text-align:center;border-bottom:1px solid #2d2a2b;padding-bottom:10px}
.fb1{font-family:var(--font-head);font-size:22px;color:#F4F3F0} .fb1 .accent{color:#D8BC8C} .fb1 .star{color:#D8BC8C;font-size:14px} .fb1 .winbadge{background:#C8A872;color:#1A1A1C;font-size:10px;font-weight:700;padding:1px 7px;border-radius:3px;letter-spacing:.5px;vertical-align:middle}
.fb2{margin-top:8px;display:flex;align-items:center;justify-content:center;gap:12px}
.fb2 .pagenum{font-family:var(--font-head);font-size:19px;font-weight:600;color:#F4F3F0}
.diffpill{background:#C8A872;color:#1A1A1C;font-size:10px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;padding:3px 11px;border-radius:12px}
.samepill{border:1px solid #4a4744;color:#9a978f;font-size:10px;font-weight:600;letter-spacing:.6px;text-transform:uppercase;padding:3px 11px;border-radius:12px}
.fb3{font-size:12px;color:#9a978f;margin-top:7px;letter-spacing:.2px}
#fimg{max-width:94vw;max-height:calc(100vh - 200px);object-fit:contain;border-radius:6px;box-shadow:0 14px 60px rgba(0,0,0,.85);background:#fff}
#fhint{color:#8a8784;font-size:12px;margin-top:auto;padding-bottom:6px}
"""

def html_doc_mode(task, groups, warnings, theme="dark"):
    if theme not in THEMES:
        theme = DEFAULT_THEME
    palette = "".join(f"html[data-theme='{k}']{{{v}}}" for k, v in THEMES.items()) + BRAND_CSS
    data = json.dumps({"task": task, "items": groups})
    warn = ""
    if warnings:
        warn = (f'<div id="warn">Heads up: native multi-page rendering for {", ".join(warnings)} '
                'needs LibreOffice (soffice). Showing page 1 only for those. '
                'Install: <code>brew install --cask libreoffice</code></div>')
    controls = ('<button class="act b-unsure" onclick="skip()">⏭ Skip group (s)</button>'
                '<button class="act b-undo" onclick="undo()">↩︎ Undo (z)</button>')
    hint = ('Gold-ringed = changed page. &nbsp; <kbd>click a page</kbd> → compare it across all versions '
            '(then <kbd>←</kbd>/<kbd>→</kbd> flips that page across versions). &nbsp; '
            'select a winner: click its header or <kbd>1-9</kbd>, then <kbd>Enter</kbd> (or its <kbd>Keep ✓</kbd>). &nbsp; '
            '<kbd>o</kbd> rows/columns &nbsp; <kbd>s</kbd> skip &nbsp; <kbd>z</kbd> undo &nbsp; <kbd>e</kbd> export &nbsp; <kbd>t</kbd> theme')
    return f"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>{task}</title>
{_FONTS}<style>{palette}</style><style>{CSS}{DOC_CSS}
#warn{{background:var(--unsure);color:#1a1a1a;font-size:12px;padding:6px 16px;text-align:center}}
#warn code{{background:rgba(0,0,0,.15);padding:1px 5px;border-radius:3px}}</style>
<script>document.documentElement.dataset.theme=new URLSearchParams(location.search).get('scheme')||localStorage.getItem('vtriage-theme')||'{theme}';</script></head><body>
<div id="top">{BRAND_MARK}<div id="brand">{_brand_name()}</div><div class="tally" id="pos">0 / 0</div>
<div id="bar"><div id="fill"></div></div>
<button id="layouttog" onclick="toggleLayout()" title="Rows / Columns (o)">⤢⤡</button>
<button id="recachetog" onclick="location.href='hvniz://recache'" title="Refresh &amp; recache — re-read every file from disk (use after you edit a file)">↻</button><button id="themetog" onclick="toggleTheme()" title="Cycle scheme (t)">◐</button>
<button class="act b-exp" onclick="exportD()">Export ⤓</button></div>
{warn}<div id="flash"></div><div id="stage"></div><div id="focus"></div><div id="meta"></div>
<div id="controls">{controls}</div><div id="hint">{hint}</div>
<div id="done"><h1>Done 🎉</h1><p id="donesum"></p><button class="act b-exp" onclick="exportD()">Export decisions ⤓</button></div>
<script>{DOC_JS.replace("__DATA__", data).replace("__LAYOUT__", DEFAULT_LAYOUT)}</script></body></html>"""

# ---------------------------------------------------------------------------
# flip mode: browse a SET of multi-page documents (NOT versions of one).
# ←/→ = prev/next FILE (wraps; keeps the page index so you can compare the same
# page across files); ↑/↓ = page within the file; Enter/F = toggle full ⇄ thumbnails;
# Esc = thumbnails; k/u/x = mark ready/tweak/hold (optional, feeds the learning loop).
# Bespoke by type+count: starts in full view (flip-through); 1 file opens straight to it.
# ---------------------------------------------------------------------------
def prep_flip(items, thumbdir, max_pages=40):
    warnings = set()
    for i, it in enumerate(items):
        it["id"] = i
        it.setdefault("name", os.path.basename(it["path"]))
        urls, needs = render_pages(it["path"], thumbdir, max_pages)
        it["pages"] = urls
        it["page_count"] = len(urls)
        if needs:
            warnings.add(os.path.splitext(it["path"])[1].lower())
    return items, sorted(warnings)

FLIP_CSS = """
#warn{background:var(--unsure);color:#1a1a1a;font-size:12px;padding:6px 16px;text-align:center}
#warn code{background:rgba(0,0,0,.15);padding:1px 5px;border-radius:3px}
body{display:flex;flex-direction:column}
#main{flex:1 1 auto;min-height:0;position:relative}
#full{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;gap:6px;padding:9px 20px 2px}
#fbar{flex:0 0 auto;width:100%;text-align:center}
#full .fb1{font-family:var(--font-head);font-size:18px;color:var(--fg);line-height:1.2} #full .fb1 .accent{color:var(--accent-dark)}
#fmeta{font-size:10.5px;color:var(--mut);margin-top:2px;line-height:1.5}
.ftag{white-space:nowrap;margin:0 6px}
#fpage{margin-top:3px;display:flex;align-items:center;justify-content:center;gap:8px}
.pagenum{font-size:11.5px;font-weight:600;color:var(--mut)}
.pdots{display:inline-flex;gap:5px}.pdot{width:8px;height:8px;border-radius:50%;background:var(--track);cursor:pointer}.pdot.on{background:var(--accent)}
#fimg{flex:1 1 auto;min-height:0;max-width:100%;object-fit:contain;border-radius:6px;box-shadow:var(--shadow);background:#fff}
#grid{position:absolute;inset:0;display:none;overflow:auto;padding:4px 18px 16px}
.gsec-head{font-family:var(--font-head);font-size:16px;color:var(--accent-dark);font-weight:600;padding:12px 4px 7px;border-bottom:1px solid var(--border);margin:6px 0 12px;position:sticky;top:0;background:var(--top-bg);backdrop-filter:blur(6px);z-index:2}
.gsec-head .n{color:var(--mut);font-size:13px;font-weight:400}
.gsec{display:grid;grid-template-columns:repeat(auto-fill,minmax(212px,1fr));gap:14px;margin-bottom:8px}
.card{background:var(--chip);border-radius:10px;overflow:hidden;cursor:pointer;border:2px solid transparent;box-shadow:var(--shadow);display:flex;flex-direction:column}
.card:hover{border-color:var(--accent)} .card.csel{outline:2px solid var(--accent);border-color:var(--accent)}
.cthumb{background:var(--imgbg);height:230px;display:flex;align-items:flex-start;justify-content:center;overflow:hidden}
.cthumb img{width:100%;object-fit:cover;object-position:top}
.cname{font-size:12px;font-weight:600;color:var(--fg);padding:7px 9px 2px;word-break:break-word}
.cfeat{padding:0 9px 9px;font-size:10px;color:var(--mut);line-height:1.6}
#footer{flex:0 0 auto;text-align:center;padding:5px 16px 7px}
#hint{font-size:10.5px;color:var(--mut);line-height:1.5}
#hint kbd{font-size:9.5px;padding:0 4px;background:var(--chip);border:1px solid var(--border);border-radius:3px;color:var(--fg)}
#fpath{font-size:9.5px;line-height:1.5;color:var(--mut);margin-top:4px;word-break:break-all}
#fpath a{color:var(--accent-dark);text-decoration:none} #fpath a:hover{text-decoration:underline}
#bar{flex:1;min-width:140px;height:11px;background:var(--track);border-radius:6px;overflow:hidden;cursor:pointer;position:relative}
#bar .seg{position:absolute;top:0;bottom:0}
#bar #caret{position:absolute;top:0;bottom:0;width:3px;border-radius:2px;background:var(--fg);box-shadow:0 0 0 1px rgba(0,0,0,.25);z-index:3}
#bartip{position:fixed;display:none;background:#0b0b0d;color:#f4f3f0;font-size:11px;padding:3px 9px;border-radius:6px;z-index:50;pointer-events:none;white-space:nowrap;transform:translateX(-50%);box-shadow:0 6px 24px rgba(0,0,0,.5)}
#search{display:none;position:fixed;inset:0;background:rgba(10,10,12,.5);z-index:40;align-items:flex-start;justify-content:center}
#searchbox{margin-top:12vh;width:min(560px,92vw);background:var(--top-bg);border:1px solid var(--border);border-radius:12px;box-shadow:0 18px 70px rgba(0,0,0,.5);overflow:hidden}
#sinput{width:100%;border:0;outline:0;background:transparent;color:var(--fg);font-family:var(--font);font-size:17px;padding:14px 18px;border-bottom:1px solid var(--border)}
#sresults{max-height:48vh;overflow:auto}
.srow{padding:9px 18px;font-size:14px;color:var(--fg);cursor:pointer;display:flex;justify-content:space-between;gap:12px;align-items:center}
.srow.folder>span:first-child{font-weight:600;color:var(--accent-dark)}
.srow.cmd>span:first-child{color:var(--accent-dark);font-weight:500}
.srow .sfeat{color:var(--mut);font-size:11px;white-space:nowrap}
.srow.son{background:var(--accent);color:var(--accent-fg)}.srow.son .sfeat,.srow.son.folder>span:first-child{color:var(--accent-fg)}
.sempty{padding:14px 18px;color:var(--mut);font-size:13px}
"""

# flip = a pure REVIEW/COMPARE viewer (Jason 2026-06-13): navigate + search only. No
# marking / undo / export / decisions here (that is single/pair/doc). Thumbnail grid is
# grouped by folder; search jumps to a file OR a folder.
FLIP_JS = r"""
const DATA=__DATA__; const CARDW=226;
const files=DATA.items;
let view='full', fi=0, fp=0, sel=0;
function cur(){return files[fi];}
function clampPage(){const n=cur().pages.length;if(fp>=n)fp=n-1;if(fp<0)fp=0;}
function groupOf(f){return f.group || (f.features&&f.features.folder) || "";}
const GROUPS=(function(){const seen={},out=[];files.forEach((f,k)=>{const g=groupOf(f)||"(ungrouped)";
  if(!(g in seen)){seen[g]={name:g,first:k,count:0};out.push(seen[g]);}seen[g].count++;});return out;})();
function featTags(f){const x=f.features||{};return Object.entries(x).map(([k,v])=>`<span class="ftag">${k}: ${v}</span>`).join("");}
var SCHEMES=['light','ivory','white','slate','charcoal','dark'];
function toggleTheme(){location.href='hvniz://appearance?toggle';}   // the ◐ button = app-wide light/dark toggle
window.__setScheme=function(s){if(!s)return;document.documentElement.setAttribute('data-theme',s);try{localStorage.setItem('vtriage-theme',s)}catch(e){}};
window.addEventListener('message',function(e){if(e.data&&e.data.type==='jpdev-scheme')window.__setScheme(e.data.scheme);});
// Follow the OS light/dark (set via the app's Appearance pref / system) unless a scheme is pinned.
try{matchMedia('(prefers-color-scheme:dark)').addEventListener('change',function(e){if(!localStorage.getItem('vtriage-theme'))document.documentElement.setAttribute('data-theme',e.matches?'dark':'light');});}catch(_e){}
function renderFull(){
  view='full'; clampPage();
  const f=cur(), n=files.length, pg=f.pages, np=pg.length;
  document.getElementById("grid").style.display="none";
  const st=document.getElementById("full"); st.style.display="flex";
  const dots=pg.map((_,r)=>`<span class="pdot ${r===fp?'on':''}" onclick="fp=${r};renderFull()"></span>`).join("");
  st.innerHTML=`<div id="fbar">
      <div class="fb1">File ${fi+1} of ${n}: <span class="accent">${f.name}</span></div>
      <div id="fmeta">${featTags(f)}</div>
      <div id="fpage"><span class="pagenum">Page ${fp+1} of ${np}</span>${np>1?`<span class="pdots">${dots}</span>`:''}</div></div>
    <img id="fimg" src="${pg[fp]||''}">`;
  [fi-1,fi+1].forEach(j=>{const ff=files[((j%n)+n)%n];const u=ff.pages[Math.min(fp,ff.pages.length-1)];if(u){const im=new Image();im.src=u;}});
  const enc=f.path.replace(/ /g,'%20').replace(/#/g,'%23');
  document.getElementById("fpath").innerHTML=`<a href="file://${enc}" title="Open this file">${f.path}</a>`;
  updateBar();
}
function renderGrid(){
  view='grid';
  document.getElementById("full").style.display="none";
  const gr=document.getElementById("grid"); gr.style.display="block";
  let html="", lastG=null;
  files.forEach((f,k)=>{const g=groupOf(f)||"(ungrouped)";
    if(g!==lastG){ if(lastG!==null)html+="</div>"; const gc=(GROUPS.find(x=>x.name===g)||{count:0}).count;
      html+=`<div class="gsec-head">${g} <span class="n">· ${gc} file${gc===1?'':'s'}</span></div><div class="gsec">`; lastG=g; }
    html+=`<div class="card ${k===sel?'csel':''}" onclick="fi=${k};renderFull()">
      <div class="cthumb"><img loading="lazy" src="${f.pages[0]||''}"></div>
      <div class="cname">${f.name}</div>
      <div class="cfeat">${featTags(f)}</div></div>`;});
  if(lastG!==null)html+="</div>";
  gr.innerHTML=html;
  const c=gr.querySelector(".card.csel"); if(c)c.scrollIntoView({block:'nearest'});
  document.getElementById("fpath").innerHTML="";
  updateBar();
}
function toggleView(){if(view==='full'){sel=fi;renderGrid();}else{fi=sel;renderFull();}}
function fileStep(d){const n=files.length;fi=((fi+d)%n+n)%n;renderFull();}   // wrap, keep page index
function pageStep(d){fp+=d;renderFull();}
// Top bar is a DYNAMIC scrubber (Jason 2026-06-13): folder-colored segments, a caret at the
// current file, click to jump, hover to preview which file is under the cursor.
const SEGCOLORS=['rgba(31,157,87,.55)','rgba(200,168,114,.55)','rgba(199,127,26,.6)','rgba(208,69,63,.6)','rgba(68,114,196,.55)','rgba(120,120,128,.5)'];
function renderBar(){const bar=document.getElementById("bar");const n=files.length;let html="",acc=0;
  GROUPS.forEach((g,gi)=>{const w=g.count/n*100;
    html+=`<div class="seg" style="left:${acc}%;width:${w}%;background:${SEGCOLORS[gi%SEGCOLORS.length]}" title="${g.name}"></div>`;acc+=w;});
  html+=`<div id="caret" style="left:calc(${((fi+0.5)/n*100)}% - 1px)"></div>`;
  bar.innerHTML=html;}
function updateBar(){
  document.getElementById("pos").textContent=(view==='full'?`file ${fi+1} of ${files.length}`:`${files.length} files · ${GROUPS.length} folders`);
  renderBar();
}
function barFrac(e){const r=document.getElementById("bar").getBoundingClientRect();return Math.min(.999,Math.max(0,(e.clientX-r.left)/r.width));}
function barJump(e){fi=Math.floor(barFrac(e)*files.length);renderFull();}
function barHover(e){const k=Math.floor(barFrac(e)*files.length);const tip=document.getElementById("bartip");
  tip.textContent=files[k].name+(groupOf(files[k])?` · ${groupOf(files[k])}`:"");tip.style.display="block";
  tip.style.left=e.clientX+"px";const r=document.getElementById("bar").getBoundingClientRect();tip.style.top=(r.bottom+7)+"px";}
function barOut(){document.getElementById("bartip").style.display="none";}
// ── jump to a FILE or a FOLDER (⌘F / ⌘K): essential with many files ──
let sIdx=0, sMatches=[];
function searchOpen(){return document.getElementById("search").style.display==="flex";}
function openSearch(){const s=document.getElementById("search");s.style.display="flex";
  const inp=document.getElementById("sinput");inp.value="";renderSearch("");inp.focus();}
function closeSearch(){document.getElementById("search").style.display="none";}
function renderSearch(q){q=(q||"").trim().toLowerCase();
  const fol=GROUPS.filter(g=>!q||g.name.toLowerCase().includes(q))
    .map(g=>({type:'folder',label:g.name,sub:g.count+' file'+(g.count===1?'':'s'),k:g.first}));
  const fil=files.map((f,k)=>({f,k})).filter(o=> !q || o.f.name.toLowerCase().includes(q)
    || (o.f.features && Object.values(o.f.features).join(" ").toLowerCase().includes(q)))
    .map(o=>({type:'file',label:o.f.name,sub:(o.f.features&&o.f.features.status)||'',k:o.k}));
  sMatches=fol.concat(fil); sIdx=0;
  const res=document.getElementById("sresults");
  if(!sMatches.length){res.innerHTML='<div class="sempty">No file or folder matches that</div>';return;}
  res.innerHTML=sMatches.map((m,i)=>`<div class="srow ${m.type} ${i===0?'son':''}" onclick="jumpSearch(${m.k})"><span>${m.type==='folder'?'📁 '+m.label:m.label}</span><span class="sfeat">${m.sub}</span></div>`).join("");}
function moveSearch(d){if(!sMatches.length)return;sIdx=(sIdx+d+sMatches.length)%sMatches.length;
  const rows=document.querySelectorAll("#sresults .srow");rows.forEach((r,i)=>r.classList.toggle("son",i===sIdx));
  if(rows[sIdx])rows[sIdx].scrollIntoView({block:'nearest'});}
function jumpSearch(k){closeSearch();fi=k;renderFull();}
document.addEventListener("keydown",e=>{const K=e.key;
  // BOTH handlers move together. This file's own note warns there are TWO of these and that
  // leaving either one keeps swallowing the key — which is how Cmd-F stayed wrong through an
  // earlier fix. Cmd-K = the library palette, Cmd-F = find inside the document.
  if((e.metaKey||e.ctrlKey)&&!e.shiftKey&&(K==='k'||K==='K')){openSearch();e.preventDefault();return;}
  if((e.metaKey||e.ctrlKey)&&(K==='f'||K==='F')){findInDocument();e.preventDefault();return;}
  if(searchOpen())return;   // the search box handles its own keys
  if(K==="t"||K==="T"){toggleTheme();e.preventDefault();return;}
  if(K==="Enter"||K==="f"||K==="F"){toggleView();e.preventDefault();return;}
  if(K==="Escape"){if(view==='full'){sel=fi;renderGrid();}e.preventDefault();return;}
  if(view==='full'){
    if(K==="ArrowLeft")fileStep(-1);
    else if(K==="ArrowRight")fileStep(1);
    else if(K==="ArrowUp")pageStep(-1);
    else if(K==="ArrowDown")pageStep(1);
    else if(K==="Home"){fi=0;renderFull();}
    else if(K==="End"){fi=files.length-1;renderFull();}
    else return;
  }else{
    const cols=Math.max(1,Math.floor((document.getElementById("grid").clientWidth-36)/CARDW));
    if(K==="ArrowLeft"){sel=Math.max(0,sel-1);renderGrid();}
    else if(K==="ArrowRight"){sel=Math.min(files.length-1,sel+1);renderGrid();}
    else if(K==="ArrowUp"){sel=Math.max(0,sel-cols);renderGrid();}
    else if(K==="ArrowDown"){sel=Math.min(files.length-1,sel+cols);renderGrid();}
    else if(K==="Home"){sel=0;renderGrid();}
    else if(K==="End"){sel=files.length-1;renderGrid();}
    else return;
  }
  e.preventDefault();
});
(function(){const inp=document.getElementById("sinput");
  inp.addEventListener("input",ev=>renderSearch(ev.target.value));
  inp.addEventListener("keydown",ev=>{const K=ev.key;
    if(K==="ArrowDown"){moveSearch(1);ev.preventDefault();}
    else if(K==="ArrowUp"){moveSearch(-1);ev.preventDefault();}
    else if(K==="Enter"){if(sMatches[sIdx])jumpSearch(sMatches[sIdx].k);ev.preventDefault();}
    else if(K==="Escape"){closeSearch();ev.preventDefault();}
    ev.stopPropagation();});
  document.getElementById("search").addEventListener("click",ev=>{if(ev.target.id==="search")closeSearch();});
  const bar=document.getElementById("bar");
  bar.addEventListener("click",barJump);
  bar.addEventListener("mousemove",barHover);
  bar.addEventListener("mouseleave",barOut);
})();
renderFull();
"""

def html_flip_mode(task, items, warnings, theme="dark"):
    if theme not in THEMES:
        theme = DEFAULT_THEME
    palette = "".join(f"html[data-theme='{k}']{{{v}}}" for k, v in THEMES.items()) + BRAND_CSS
    data = json.dumps({"task": task, "items": items})
    warn = ""
    if warnings:
        warn = (f'<div id="warn">Heads up: multi-page rendering for {", ".join(warnings)} '
                'needs LibreOffice (soffice); showing page 1 only for those. '
                'Install: <code>brew install --cask libreoffice</code></div>')
    # Pure viewer: keyboard shortcuts small at the very bottom, only on-screen control is the
    # Thumbnails-view toggle under them. No marking/undo/export here (Jason 2026-06-13).
    hint = ('<kbd>←</kbd>/<kbd>→</kbd> file &nbsp; <kbd>↑</kbd>/<kbd>↓</kbd> page &nbsp; '
            '<kbd>Enter</kbd> or <kbd>F</kbd> full ⇄ thumbnails &nbsp; '
            '<kbd>⌘F</kbd>/<kbd>⌘K</kbd> jump to a file or folder &nbsp; <kbd>t</kbd> theme')
    return f"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>{task}</title>
{_FONTS}<style>{palette}</style><style>{CSS}{FLIP_CSS}</style>
<script>document.documentElement.dataset.theme=new URLSearchParams(location.search).get('scheme')||localStorage.getItem('vtriage-theme')||'{theme}';</script></head><body>
<div id="top">{BRAND_MARK}<div id="brand">{_brand_name()}</div><div class="tally" id="pos">0 / 0</div>
<div id="bar" title="Click to jump · hover to preview"></div>
<button id="recachetog" onclick="location.href='hvniz://recache'" title="Refresh &amp; recache — re-read every file from disk (use after you edit a file)">↻</button><button id="themetog" onclick="toggleTheme()" title="Cycle scheme (t)">◐</button></div>
{warn}<div id="bartip"></div>
<div id="main"><div id="full"></div><div id="grid"></div></div>
<div id="search"><div id="searchbox"><input id="sinput" type="text" placeholder="Jump to a file or folder… type a name, ↑/↓ to choose, Enter to go"><div id="sresults"></div></div></div>
<div id="footer"><div id="hint">{hint}</div><div id="fpath"></div></div>
<script>{FLIP_JS.replace("__DATA__", data)}</script></body></html>"""

BASE_DIR = os.path.expanduser("~/Triage Review and Compare")

def build_tool(mode, task, items, out=None, thumbs=None, theme=None, sweep_downloads=True,
               copy_dir=None, progress=None, roots=None):
    """Render a triage/compare tool from in-memory items and return a result dict.

    Shared by the CLI (main) and the standalone drag-and-drop app (app/triage_app.py)
    so both produce identical output, naming, cache, and Downloads behavior.
    Returns {"out","downloads","note","mode","theme","kb"}.
    """
    import datetime
    theme = theme if theme in THEMES else DEFAULT_THEME
    global BRAND_MARK                       # honor the Settings logo choice on every render
    BRAND_MARK = _make_mark(_stored_logo())
    base = BASE_DIR
    os.makedirs(base, exist_ok=True)
    # Default output: a STABLE folder with a date-stamped name so tools sort
    # chronologically (newest last), are never lost, and survive reboots.
    # Format: "YYYY-MM-DD at HH.MMam/pm <task>.html" (date leads; hour 24h
    # zero-padded so files sort within a day; am/pm is a readability hint).
    # NO mode/type token (Jason 2026-06-08: "(doc)" misread as a Word file).
    now = datetime.datetime.now()
    ts = now.strftime("%Y-%m-%d at %H.%M") + ("am" if now.hour < 12 else "pm")
    out = out or os.path.join(base, f"{ts} {task}.html")
    # Shared, content-keyed thumbnail cache: persistent and reused across runs.
    thumbdir = thumbs or os.path.join(base, ".cache")
    if mode == "doc":
        groups, warnings = prep_doc(items, thumbdir)
        html = html_doc_mode(task, groups, warnings, theme)
        note = f"groups={len(groups)} warnings={warnings}"
    elif mode == "flip":
        items, warnings = prep_flip(items, thumbdir)
        html = html_flip_mode(task, items, warnings, theme)
        note = f"files={len(items)} warnings={warnings}"
    elif mode == "single":
        items = prep_grid(items, thumbdir, progress=progress)  # fast lazy grid; reports progress
        html = html_grid_mode(task, items, theme, roots=roots)
        note = f"items={len(items)}"
    else:  # pair
        items = prep_pair(items, thumbdir)
        html = html_doc(task, mode, items, theme)
        note = f"items={len(items)}"
    # Files stay LEAN: the HTML carries the framework + file:// references to the
    # local thumbnail cache, never embedded image bytes (Jason 2026-06-08).
    open(out, "w").write(html)
    # Optional convenience copy of the LATEST tool into a folder (sweeping older
    # triage-named HTMLs out of it first; the full archive always stays in `base`).
    #   copy_dir=None  -> default: Jason Workspace root / desk (~/Library/CloudStorage/Dropbox-HVNTravelGroup/Jason Paez/Claude Workspace/Jason Workspace/) — only if sweep_downloads
    #   copy_dir=""    -> no copy anywhere (the app default; tool auto-opens anyway)
    #   copy_dir=path  -> copy there (e.g. a Claude Workspace folder)
    dl = None
    target = copy_dir
    if target is None:
        target = "~/Library/CloudStorage/Dropbox-HVNTravelGroup/Jason Paez/Claude Workspace/Jason Workspace" if sweep_downloads else ""
    if target:
        dl_root = os.path.expanduser(target)
        if os.path.isdir(dl_root) and \
                os.path.abspath(os.path.dirname(out)) != os.path.abspath(dl_root):
            try:
                pat = re.compile(r"^\d{4}-\d{2}-\d{2} at \d{2}\.\d{2}(am|pm) .*\.html$")
                for fn in os.listdir(dl_root):
                    if pat.match(fn):
                        try:
                            os.remove(os.path.join(dl_root, fn))
                        except OSError:
                            pass
                dl = os.path.join(dl_root, os.path.basename(out))
                shutil.copy(out, dl)
            except OSError:
                dl = None
    kb = os.path.getsize(out) / 1024
    return {"out": out, "downloads": dl, "note": note, "mode": mode, "theme": theme, "kb": kb}

def main():
    # --check needs none of the required args: handle it before argparse.
    if "--check" in sys.argv:
        _doctor()
        return
    # Lazy per-file page render (used by the grid lightbox for ↑/↓ paging within a
    # multi-page PDF/deck): --render-pages PATH OUT  -> writes {"pages":[file://...]} to OUT.
    if "--render-pages" in sys.argv:
        i = sys.argv.index("--render-pages")
        path = sys.argv[i + 1]; out = sys.argv[i + 2]
        cache = os.path.expanduser("~/Triage Review and Compare/pagecache")
        try:
            pages, needs = render_pages(path, cache)
        except Exception:
            pages, needs = [], True
        json.dump({"path": path, "pages": pages, "needs_converter": needs}, open(out, "w"))
        return
    # Reader capability, as JSON on stdout: --caps -> {"reader_exts": [...], "handoff_exts": [...]}.
    # The engine is the source of truth; the Swift shell mirrors these and the build asserts the
    # mirror matches, so readerExts() can never again claim a type to_pdf refuses.
    if "--caps" in sys.argv:
        json.dump({"reader_exts": sorted(e.lstrip(".") for e in READER_EXT),
                   "handoff_exts": sorted(e.lstrip(".") for e in SPREADSHEET_EXT),
                   "grid_exts": sorted(e.lstrip(".") for e in GRID_EXT),
                   # editable_exts joined the guard in 0.63.7. It had already drifted: the engine
                   # listed txt/md in EDITABLE_EXT while the shell's editorProbeExts said only
                   # docx/rtf, so plain text could never have shown the Read|Edit switch even once
                   # the reader learned to open it. Same failure shape as the 0.53.0 sheet drift,
                   # in the one mirror the guard did not yet cover.
                   "editable_exts": sorted(e.lstrip(".") for e in EDITABLE_EXT)}, sys.stdout)
        return
    # Delimited text -> grid JSON for the native grid view: --grid-json PATH OUT.
    if "--grid-json" in sys.argv:
        i = sys.argv.index("--grid-json")
        path = sys.argv[i + 1]; out = sys.argv[i + 2]
        try:
            r = grid_json(path)
        except Exception as e:
            r = {"error": "This file could not be read (%s)." % type(e).__name__}
        r["src"] = path
        json.dump(r, open(out, "w"))
        return
    # Editor Mode gate: --edit-probe PATH OUT -> writes {"editable": bool, "why": [...]}.
    # See edit_probe(); the Swift shell calls this before offering the Edit switch.
    if "--edit-probe" in sys.argv:
        i = sys.argv.index("--edit-probe")
        path = sys.argv[i + 1]; out = sys.argv[i + 2]
        try:
            r = edit_probe(path)
        except Exception:
            r = {"editable": False, "why": ["unreadable"]}
        r["src"] = path
        json.dump(r, open(out, "w"))
        return
    # Convert a document to PDF for the in-app reader: --to-pdf PATH OUT -> writes {"pdf": "..."}.
    if "--to-pdf" in sys.argv:
        i = sys.argv.index("--to-pdf")
        path = sys.argv[i + 1]; out = sys.argv[i + 2]
        cache = os.path.expanduser("~/Triage Review and Compare/pdfcache")
        try:
            pdf = to_pdf(path, cache)
        except Exception:
            pdf = None
        json.dump({"src": path, "pdf": pdf or ""}, open(out, "w"))
        return
    ap = argparse.ArgumentParser()
    ap.add_argument("--mode", choices=["single", "pair", "doc", "flip"], required=True)
    ap.add_argument("--task", required=True)
    ap.add_argument("--out", default=None)
    ap.add_argument("--manifest", required=True)
    ap.add_argument("--thumbs", default=None)
    ap.add_argument("--theme", choices=list(THEMES), default=DEFAULT_THEME)
    ap.add_argument("--check", action="store_true",
                    help="Report which rendering tools are installed, then exit.")
    a = ap.parse_args()
    items = json.load(open(a.manifest))["items"]
    r = build_tool(a.mode, a.task, items, out=a.out, thumbs=a.thumbs, theme=a.theme)
    print(f"mode={r['mode']} theme={r['theme']} {r['note']} lean {r['kb']:.0f} KB -> {r['out']}"
          + (f"\n  + Jason Workspace root (latest only): {r['downloads']}" if r["downloads"] else ""))

if __name__ == "__main__":
    main()
