/* Pagehawk desktop WELCOME — the SINGLE SOURCE for the calm landing page, rendered by both
 * shells (MAC-034, closes v4 defect-6). One file, two shells, zero drift.
 *
 * The app does NOT land on the web reading home: it lands on the welcome screen. As of v0.67 that
 * screen is VARIANT F, which Jason chose off the 2026-08-21 ballot: a quiet Home/Tools tab strip,
 * a smaller identity block, ONE jump bar that resolves either a document or a function, the drop
 * zone, and a short Recent LIST with a date column. The thirteen capability cards that used to sit
 * on the landing below the fold now live behind the Tools tab; nothing was dropped in the move.
 * The chip pile the Recent list replaces, and the folded/stacked shape machinery F supersedes, are
 * both still in this file and unused, on purpose: if F does not survive contact they are the
 * cheapest way back. They come out in a follow-up commit once F is confirmed, not in the one that
 * introduces it. This surface used to exist TWICE: once as the Mac shell's Swift constant
 * (kWelcomeHTML + welcomeMark/welcomeDropSub/welcomeTaskCards in src/app/viewer/main.swift) and
 * once, byte-faithfully re-ported, as this module for Windows (PC-032). Two copies of the first
 * thing a user sees is exactly how the landing pages quietly drifted apart (the miss Jason called
 * out 2026-07-10: "the entire landing page is totally different"). MAC-034 collapses them: the
 * CSS / markup / mark SVG / task cards live HERE, once; the Mac shell now consumes this file (it
 * inlines it into the WKWebView host page and injects PHK_SHELL='mac' + brand + pins/recents),
 * the same way it already consumes web/redact.js and the other shared web surfaces.
 *
 * ONE action interface (data-act / data-open* attributes), routed per shell behind act():
 *
 *                          MAC (PHK_SHELL='mac')            WIN / WEB (default)
 *   open/browse/rank/sort  hvniz://<id>                     PagehawkLibrary.open()
 *   read                   hvniz://read                     PagehawkReader.pick()
 *   ask                    hvniz://ask                      pro.html?tool=ai
 *   organize               hvniz://organize                 pro.html?tool=ai-organize
 *   redact                 hvniz://redact                   pro.html?tool=redact
 *   compare/versions/      hvniz://<id>                     pro.html?tool=compare
 *     pickvisualcompare
 *   winnowphotos/          hvniz://<id>                     PagehawkLibrary.open() (photo brands)
 *     winnowcollectlib
 *   openroot/openrecent/   hvniz://<id>?path=<enc>          library + phk_pins/phk_recents stores
 *     pin/unpin
 *   FDA tip                shown when PHK_FDA_TIP===true     omitted (mac_only — no FDA on Windows)
 *   Open accelerator hint  PHK_ACCEL_OPEN (⌘O)              PHK_ACCEL_OPEN default 'Ctrl+O'
 *
 * Injected shell globals (all optional; sensible defaults keep the browser/PWA working):
 *   PHK_SHELL   'mac' | 'win' | 'web'   — routing + pin/recent source (default: win if PHK_DESKTOP)
 *   PHK_BRAND   { name, tagline, modules{} }  — brand identity + module gating
 *   PHK_PINS / PHK_RECENTS  [{name, path}]    — Mac shell pushes these (mac reads them, not localStorage)
 *   PHK_FDA_TIP true                          — Mac shows the Full Disk Access tip row
 *   PHK_ACCEL_OPEN  '⌘O' | 'Ctrl+O'           — the "or press …" hint
 *
 * Gate: shell desktop (PHK_SHELL==='mac' | PHK_DESKTOP===true) or ?welcome=1 (harness/QA). The
 * browser/PWA keeps its reading home — matching the desktop shells, where this is app-only.
 */
(function (global) {
  'use strict';

  // Which shell is rendering us. Mac injects PHK_SHELL='mac'; the PC shell pushes PHK_DESKTOP=true
  // (kept working verbatim); a bare browser/PWA is 'web'.
  var SHELL = global.PHK_SHELL || (global.PHK_DESKTOP === true ? 'win' : 'web');
  var IS_MAC = SHELL === 'mac';

  function gated() {
    try { if (new URLSearchParams(location.search).get('welcome') === '1') return true; } catch (_) {}
    return IS_MAC || global.PHK_DESKTOP === true;
  }

  // Brand identity (brands/pagehawk.toml; the Mac shell pushes PHK_BRAND from kBrand, PC when
  // variants ship). Default is the direct PageHawk build.
  var BRAND = global.PHK_BRAND || {
    name: 'PageHawk',
    tagline: 'One window, your whole library.',
    modules: { sort: true, read: true, compare: true, ai: true, sync: true, redact: true, dataroom: true, dedupe: false }
  };
  function has(m) { return BRAND.modules[m] === true; }
  var ACCEL_OPEN = global.PHK_ACCEL_OPEN || (IS_MAC ? '⌘O' : 'Ctrl+O');

  // ── kWelcomeHTML CSS, carried verbatim (main.swift:203-247) ──
  var CSS = ''
    // --mut darkened in the three LIGHT themes (2026-07-31): as 9-13.5px muted label text it
    // measured 3.10:1 (ivory), 3.13:1 (light) and 2.85:1 (white) against each theme's worst
    // ground — all below the AA 4.5:1 floor. This completes the 2026-07-20 --ink-3 darkening
    // (commit 3e319b9), which fixed web/theme.css + web/tokens but never reached this file's
    // own local theme block. `light` reuses the already-decided --ink-3 #716C61 so one value
    // holds across theme.css, tokens and welcome.js; ivory/white are hue-preserving darkenings
    // solved to the same floor. The two dark themes already passed (5.85:1 / 4.60:1) — untouched.
    + 'html[data-theme="ivory"]{--bg1:#FBF7EE;--bg2:#EFE7D5;--card:#fff;--fg:#2A2D30;--mut:#6E6759;--line:#E7DEC9;--dropbg:rgba(255,255,255,.5);--accent:#C8A872;--accent-dark:#B8860B;--mut2:#6A6355}'
    + 'html[data-theme="light"]{--bg1:#FFFFFF;--bg2:#F3EEE4;--card:#fff;--fg:#11100D;--mut:#716C61;--line:rgba(168,133,79,.22);--dropbg:rgba(255,255,255,.55);--accent:#C8A872;--accent-dark:#B8860B;--mut2:#6C685D}'
    + 'html[data-theme="white"]{--bg1:#FFFFFF;--bg2:#EEF0F1;--card:#fff;--fg:#16181A;--mut:#686D74;--line:#E6E8EB;--dropbg:rgba(255,255,255,.6);--accent:#C8A872;--accent-dark:#B8860B;--mut2:#656A71}'
    + 'html[data-theme="charcoal"]{--bg1:#33373B;--bg2:#1E2123;--card:rgba(48,52,56,.6);--fg:#F3EAD6;--mut:#A6A08F;--line:rgba(255,255,255,.1);--dropbg:rgba(255,255,255,.04);--accent:#C8A872;--accent-dark:#D8BC8C;--mut2:#B0AA9B}'
    + 'html[data-theme="dark"]{--bg1:#1b1b1d;--bg2:#070708;--card:rgba(24,21,16,.5);--fg:#F0EBE1;--mut:#9A9690;--line:rgba(255,255,255,.09);--dropbg:rgba(255,255,255,.035);--accent:#CEB06B;--accent-dark:#D8BC8C;--mut2:#9A9690}'
    + 'html[data-theme="slate"]{--bg1:#222A33;--bg2:#141A20;--card:rgba(32,40,50,.6);--fg:#E8ECF2;--mut:#93A0AE;--line:rgba(255,255,255,.1);--dropbg:rgba(255,255,255,.04);--accent:#C8A872;--accent-dark:#D8BC8C;--mut2:#93A0AE}'
    + '#phkWelcome{position:fixed;inset:0;z-index:800;display:flex;flex-direction:column;align-items:center;overflow-y:auto;padding:44px 22px 52px;box-sizing:border-box;background:linear-gradient(165deg,var(--bg1),var(--bg2));color:var(--fg);font-family:\'Inter\',-apple-system,system-ui,sans-serif;user-select:none;-webkit-font-smoothing:antialiased}'
    + '#phkWelcome .c{text-align:center;width:600px;max-width:90vw;margin:auto;animation:phkRise .42s cubic-bezier(.22,.7,.3,1) both}'
    + '#phkWelcome .caps{display:grid;grid-template-columns:repeat(2,1fr);gap:11px;margin:11px 0 4px;text-align:left}'
    + '#phkWelcome .cap{display:flex;gap:13px;align-items:center;padding:14px 15px;border:1px solid var(--line);border-radius:13px;background:var(--card);text-decoration:none;color:var(--fg);transition:border-color .14s,transform .12s,box-shadow .2s;cursor:pointer}'
    + '#phkWelcome .cap:hover{border-color:var(--accent);transform:translateY(-1px);box-shadow:0 6px 18px -8px rgba(160,130,70,.42)}'
    + '#phkWelcome .cap .ci{width:34px;height:34px;flex:none;border-radius:9px;background:rgba(200,168,114,.15);display:flex;align-items:center;justify-content:center;color:var(--accent-dark)}'
    + '#phkWelcome .cap .ci svg{width:18px;height:18px;display:block}'
    + '#phkWelcome .cap .cb{display:flex;flex-direction:column;min-width:0}'
    + '#phkWelcome .cap .ct{font-size:14px;font-weight:600;line-height:1.2}'
    + '#phkWelcome .cap .cd{font-size:11px;color:var(--mut);margin-top:3px;line-height:1.35}'
    + '@keyframes phkRise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}'
    + '#phkWelcome .mk{width:84px;height:84px;margin:0 auto 16px}#phkWelcome .mk svg{width:100%;height:100%;display:block;overflow:visible;filter:drop-shadow(0 9px 16px rgba(0,0,0,.30))}#phkWelcome .mk .b1{fill:#B49A66;animation:phkHrise .55s ease both}#phkWelcome .mk .b2{fill:#CCB484;animation:phkHrise .55s .06s ease both}#phkWelcome .mk .front{fill:#F8F3E9;stroke:#E4D8BD;stroke-width:1.5;animation:phkHrise .5s .12s cubic-bezier(.22,.7,.3,1) both}#phkWelcome .mk .ln{stroke:#3A3F45;stroke-width:7;stroke-linecap:round;opacity:.82;animation:phkHfade .45s .34s ease both}#phkWelcome .mk .badge{transform-box:fill-box;transform-origin:center;animation:phkHpop .5s .46s cubic-bezier(.34,1.56,.64,1) both}@keyframes phkHrise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}@keyframes phkHfade{from{opacity:0}to{opacity:.82}}@keyframes phkHpop{0%{opacity:0;transform:scale(.2)}70%{opacity:1;transform:scale(1.12)}100%{opacity:1;transform:scale(1)}}'
    // V4: the drop zone is now the brand block too, so it carries the identity's spacing and the
    // mark sits inside it. Perry's own tile is dark and rounded by construction, so it needs no
    // frame of ours — only a shadow to lift it off the cream.
    // The stacked shape puts the mark on the cream rather than inside a card, so it carries its own
    // size and lift; the folded rules below only apply inside .dropbrand.
    + '#phkWelcome .c > .mk{width:88px;height:88px;margin:0 auto 16px}'
    + '#phkWelcome .c > .mk svg{width:100%;height:100%;display:block;border-radius:21px;'
      + 'box-shadow:0 10px 24px rgba(30,26,18,.24)}'
    + '#phkWelcome .dropbrand{display:block;text-align:center;padding:34px 26px 28px;max-width:560px;margin:0 auto;border-width:2px}'
    + '#phkWelcome .dropbrand .mk{width:88px;height:88px;margin:0 auto 15px}'
    // NO rise animation on the mark. phkHrise starts at opacity 0 with fill-mode both, so for the
    // first ~550ms of every launch the mark is invisible — and a capture taken in that window shows
    // an empty tile, which is exactly what the first side-by-side render did. The old generic glyph
    // could afford the flourish because it was assembled from parts; the brand's own mark cannot
    // afford to be missing, even briefly. The drop zone keeps its own breathe.
    + '#phkWelcome .dropbrand .mk svg{width:100%;height:100%;display:block;border-radius:21px;'
      + 'box-shadow:0 10px 24px rgba(30,26,18,.24)}'
    + '#phkWelcome .dropbrand .logo{margin-bottom:4px}'
    + '#phkWelcome .dropbrand .tag{margin-bottom:18px}'
    + '#phkWelcome .dropbrand .big{margin-top:2px}'
    + '#phkWelcome .dropbrand .btn{display:inline-block;margin-top:17px}'
    + '#phkWelcome .dropbrand .kbd{margin-top:9px}'
    + '#phkWelcome .logo{font-family:Cambria,\'Big Caslon\',\'Hoefler Text\',Georgia,serif;font-size:44px;font-weight:600;letter-spacing:.01em;line-height:1}#phkWelcome .logo b{color:var(--accent-dark);font-weight:600}'
    + '#phkWelcome .tag{font-size:13.5px;color:var(--mut);margin-top:8px;letter-spacing:.2px}'
    // Handoff note: why a file you just opened is now in another app. Explanatory, not an error —
    // the file did open, just somewhere else. Sits above the drop zone so it is read first. Every
    // transient notice carries its own exit (Jason 2026-07-15: "those notices need to be
    // dismissible if they are temporary"); it is per-welcome and never outlives the window, so the
    // x is a pure client-side hide with nothing to persist. .nx is flex-none so a long message
    // never squeezes the button out of reach.
    + '#phkWelcome .note{margin:18px auto 0;padding:11px 15px;max-width:520px;border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:9px;background:var(--card);color:var(--fg);font-size:12.5px;line-height:1.5;text-align:left;animation:phkRise .42s cubic-bezier(.22,.7,.3,1) both;display:flex;align-items:flex-start;gap:10px}'
    + '#phkWelcome .note .ntx{flex:1;min-width:0}'
    + '#phkWelcome .note .nx{flex:none;appearance:none;background:transparent;border:0;color:var(--mut,#8A8270);font-size:15px;line-height:1;padding:1px 2px;margin:-1px -4px 0 0;cursor:pointer;opacity:.7;border-radius:5px}'
    + '#phkWelcome .note .nx:hover{opacity:1;background:rgba(128,128,128,.12)}'
    + '#phkWelcome .note.out{animation:phkNoteout .18s ease both}'
    + '@keyframes phkNoteout{to{opacity:0;transform:translateY(-4px)}}'
    + '#phkWelcome .drop{display:block;margin:30px auto 0;padding:30px 26px;border:1px solid var(--line);border-radius:16px;background:var(--dropbg);animation:phkBreathe 4s ease-in-out infinite;text-decoration:none;color:var(--fg);cursor:pointer}'
    + '@keyframes phkBreathe{0%,100%{border-color:var(--line)}50%{border-color:var(--accent)}}'
    + '#phkWelcome .drop .dmk{width:30px;height:30px;margin:0 auto 11px;color:var(--accent);opacity:.55}#phkWelcome .drop .dmk svg{width:100%;height:100%;display:block}'
    + '#phkWelcome .drop .big{font-size:16px;color:var(--fg);font-weight:500}#phkWelcome .drop .sub{font-size:12px;color:var(--mut);margin-top:6px}'
    + '#phkWelcome .btn{margin-top:24px;display:inline-block;font-size:14px;font-weight:600;color:#23262A;background:var(--accent);border:0;padding:12px 30px;border-radius:11px;text-decoration:none;cursor:pointer;box-shadow:0 4px 18px -4px rgba(160,130,70,.5)}'
    + '#phkWelcome .btn:hover{filter:brightness(1.04)}'
    + '#phkWelcome .kbd{margin-top:14px;font-size:11.5px;color:var(--mut)}#phkWelcome .kbd b{background:var(--card);border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-weight:500}'
    + '#phkWelcome .recent{margin-top:28px}#phkWelcome .recent .rl{font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--mut);margin-bottom:9px;opacity:.85}'
    + '#phkWelcome .chips{display:flex;flex-wrap:wrap;gap:7px;justify-content:center}'
    + '#phkWelcome .chip{font-size:11.5px;color:var(--accent-dark);background:var(--card);border:1px solid var(--line);border-radius:999px;padding:4px 12px;text-decoration:none;cursor:pointer;display:inline-flex;align-items:center;gap:6px}#phkWelcome .chip:hover{border-color:var(--accent)}'
    + '#phkWelcome .chip .pn{cursor:pointer;opacity:.55;font-size:12px;line-height:1}#phkWelcome .chip .pn:hover{opacity:1;color:var(--accent-dark)}'
    // A recent whose file has moved stays listed and reads as unavailable. Dashed border +
    // muted ink, NOT reduced opacity on the text: --mut is a themed token that holds its
    // contrast in both themes, where an opacity fade would quietly fail AA in one of them.
    + '#phkWelcome .chip.missing{color:var(--mut);border-style:dashed}'
    + '#phkWelcome .fda{flex:none;margin:26px auto 0;text-align:center;font-size:10.5px;color:var(--mut);opacity:.8}#phkWelcome .fda a{color:var(--accent-dark);text-decoration:none;cursor:pointer}#phkWelcome .fda a:hover{text-decoration:underline}'
    // ── F ──────────────────────────────────────────────────────────────────────────────────
    // Jason chose F off the 2026-08-21 ballot: a quiet Home/Tools strip, the identity smaller than
    // it was, ONE bar that jumps to either a document or a function, the drop zone, and a short
    // Recent LIST with a date column in place of the wrapping chip pile. The thirteen capability
    // cards leave the landing entirely and live behind Tools.
    //
    // Every rule below spends only the tokens the six themes above already define (--fg, --mut,
    // --card, --line, --accent, --accent-dark, --dropbg). Nothing here introduces a literal ink or
    // ground, so the contrast work already done on those tokens carries to this screen instead of
    // being re-litigated in a seventh palette.
    + '#phkWelcome{padding:0;overflow:hidden}'
    // The strip spans the window, so it sits OUTSIDE the padded, centered column.
    + '#phkWelcome .tabs{flex:none;display:flex;align-items:center;gap:2px;width:100%;padding:0 14px;'
      + 'box-sizing:border-box;border-bottom:1px solid var(--line);background:transparent}'
    + '#phkWelcome .tabs button{appearance:none;background:transparent;border:0;border-bottom:2px solid transparent;'
      + 'padding:9px 15px;margin-bottom:-1px;font:inherit;font-size:12.5px;color:var(--mut);cursor:pointer;border-radius:7px 7px 0 0}'
    + '#phkWelcome .tabs button:hover{color:var(--fg)}'
    + '#phkWelcome .tabs button.on{color:var(--fg);border-bottom-color:var(--accent)}'
    + '#phkWelcome .tabs button:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}'
    + '#phkWelcome .tabs .bn{margin-left:auto;padding-right:5px;font-size:9.5px;letter-spacing:.2em;'
      + 'text-transform:uppercase;color:var(--mut);font-weight:700;opacity:.85}'
    + '#phkWelcome .pane{flex:1;min-height:0;width:100%;overflow-y:auto;display:flex;'
      + 'padding:38px 22px 52px;box-sizing:border-box}'
    // F's identity: 56px mark, 27px wordmark. Deliberately smaller than the 88/44 the landing had
    // ("a little bit less obtrusive"), and still the first thing read.
    + '#phkWelcome .idf{text-align:center}'
    + '#phkWelcome .idf .mk{width:56px;height:56px;margin:0 auto}'
    + '#phkWelcome .idf .logo{font-size:27px;margin-top:12px}'
    + '#phkWelcome .idf .tag{margin-top:4px}'
    // The jump bar. Gold border because it is the one thing on this screen asking to be typed in.
    + '#phkWelcome .jump{position:relative;width:100%;max-width:560px;margin:22px auto 0;text-align:left}'
    + '#phkWelcome .jump .jf{display:flex;align-items:center;gap:9px;padding:0 12px;border:1px solid var(--accent);'
      + 'border-radius:10px;background:var(--card);box-shadow:0 2px 10px -6px rgba(160,130,70,.5)}'
    + '#phkWelcome .jump .jf:focus-within{box-shadow:0 0 0 3px rgba(200,168,114,.25)}'
    + '#phkWelcome .jump .ji{flex:none;width:15px;height:15px;color:var(--accent-dark);opacity:.85}'
    + '#phkWelcome .jump .ji svg{width:100%;height:100%;display:block}'
    + '#phkWelcome .jump input{flex:1;min-width:0;appearance:none;background:transparent;border:0;outline:0;'
      + 'color:var(--fg);font:inherit;font-size:13px;padding:11px 0}'
    + '#phkWelcome .jump input::placeholder{color:var(--mut);opacity:1}'
    + '#phkWelcome .jump .sk{flex:none;font:600 11px ui-monospace,Menlo,monospace;background:var(--dropbg);'
      + 'border:1px solid var(--line);border-radius:5px;padding:2px 7px;color:var(--mut)}'
    + '#phkWelcome .jres{position:absolute;left:0;right:0;top:calc(100% + 6px);z-index:6;background:var(--card);'
      + 'border:1px solid var(--line);border-radius:11px;box-shadow:0 14px 34px -12px rgba(20,16,8,.45);'
      + 'max-height:334px;overflow-y:auto;padding:4px 0}'
    + '#phkWelcome .jres .jh{font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--mut);'
      + 'padding:8px 13px 5px;opacity:.85}'
    + '#phkWelcome .jr{display:flex;align-items:center;gap:10px;width:100%;box-sizing:border-box;padding:8px 13px;'
      + 'appearance:none;background:transparent;border:0;text-align:left;color:var(--fg);font:inherit;cursor:pointer}'
    + '#phkWelcome .jr.sel,#phkWelcome .jr:hover{background:rgba(200,168,114,.16)}'
    + '#phkWelcome .jr .jk{flex:none;font:700 9px/1.6 Inter,system-ui,sans-serif;letter-spacing:.09em;'
      + 'text-transform:uppercase;color:var(--fg);background:var(--dropbg);border:1px solid var(--accent);'
      + 'border-radius:5px;padding:1px 6px}'
    + '#phkWelcome .jr .jt{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}'
    + '#phkWelcome .jr .jd{flex:none;max-width:42%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
      + 'font-size:11px;color:var(--mut)}'
    + '#phkWelcome .jnote{padding:7px 13px 9px;margin-top:3px;border-top:1px solid var(--line);'
      + 'font-size:11px;color:var(--mut);line-height:1.45}'
    // F's drop zone: dashed and gold, the way the ballot drew it, so the target reads as a target
    // rather than as another card.
    + '#phkWelcome .dropf{display:block;margin:16px auto 0;padding:30px 20px;border:1.5px dashed var(--accent);'
      + 'border-radius:14px;background:var(--dropbg);text-align:center;text-decoration:none;color:var(--fg);cursor:pointer}'
    + '#phkWelcome .dropf .big{font-size:15px;font-weight:600}'
    + '#phkWelcome .dropf .sub{font-size:12px;color:var(--mut2,var(--mut));margin-top:4px}'
    + '#phkWelcome .dropf .row{margin-top:16px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap}'
    + '#phkWelcome .dropf .btn{margin-top:0}'
    // Recent, as a list with a date column. Striping lives on a dedicated row wrapper so the
    // section heading cannot throw the odd/even off by one.
    + '#phkWelcome .lst{margin-top:22px;text-align:left}'
    + '#phkWelcome .lst .lh{font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--mut);'
      + 'margin:0 0 7px;opacity:.85}'
    + '#phkWelcome .lrow{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;'
      + 'font-size:12.5px;color:var(--fg);text-decoration:none;cursor:pointer}'
    + '#phkWelcome .lrows .lrow:nth-child(odd){background:rgba(128,128,128,.05)}'
    + '#phkWelcome .lrow:hover{background:rgba(200,168,114,.16)}'
    + '#phkWelcome .lrow em{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-style:normal}'
    + '#phkWelcome .lrow u{flex:none;text-decoration:none;color:var(--mut2,var(--mut));font-size:11px;font-variant-numeric:tabular-nums}'
    + '#phkWelcome .lrow .pn{flex:none;opacity:.5;font-size:12px;line-height:1;cursor:pointer;padding:0 2px}'
    + '#phkWelcome .lrow .pn:hover{opacity:1;color:var(--accent-dark)}'
    // A recent whose file has moved keeps its row and says so in themed muted ink rather than by
    // fading, which would quietly fail AA in one theme or the other.
    + '#phkWelcome .lrow.missing em{color:var(--mut);font-style:italic}'
    + '#phkWelcome .empty{font-size:12px;color:var(--mut);padding:2px 10px}'
    // Tools. The same cards the landing used to carry, now one tab away.
    + '#phkWelcome .tools{width:700px;max-width:92vw;margin:auto;text-align:left}'
    + '#phkWelcome .tools .th{font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--mut);'
      + 'margin:0 0 12px;opacity:.85}'
    + '#phkWelcome .tools .caps{margin:0}'
    + '@media(prefers-reduced-motion:reduce){#phkWelcome .c,#phkWelcome .drop{animation:none}#phkWelcome .mk *{animation:none!important;opacity:1!important;transform:none!important}#phkWelcome .mk .ln{opacity:.82!important}}';

  // ── welcomeMark(), carried verbatim (document brand: three text lines + gold check badge) ──
  // PERRY. The confirmed PageHawk mark (fleet-logo-registry.json rung 1, "Perry Bold C",
  // confirmed by Jason twice on 2026-07-25). Lifted verbatim from src/app/icon/pagehawk-icon.html
  // — the same art the build already ships as the app icon — rather than copied in as a second
  // asset that could drift from it. Its gradient ids are namespaced because this markup lands in
  // the app's own document alongside whatever else is on the page.
  //
  // BRAND-GATED ON PURPOSE. web/welcome.js is shared: build_brand.sh builds Winnow Photos from
  // this same file, and a falcon is PageHawk's mark, not a generic one. A new brand therefore
  // inherits the neutral mark below rather than silently inheriting somebody else's bird.
  var PERRY_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><title>PageHawk app icon concept: C perry bold</title><defs> <linearGradient id="phkPerry_bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#202327"/><stop offset="1" stop-color="#101113"/></linearGradient> <linearGradient id="phkPerry_goldv" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#E7CD93"/><stop offset="0.55" stop-color="#C8A872"/><stop offset="1" stop-color="#A9863F"/></linearGradient> <radialGradient id="phkPerry_halo" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="#C8A872" stop-opacity="0.18"/><stop offset="1" stop-color="#C8A872" stop-opacity="0"/></radialGradient></defs><rect width="1024" height="1024" rx="228" fill="url(#phkPerry_bg)"/><rect x="1" y="1" width="1022" height="1022" rx="227" fill="none" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="2"/><circle cx="512" cy="512" r="430" fill="url(#phkPerry_halo)"/><circle cx="512" cy="512" r="330" fill="none" stroke="#F6F1E7" stroke-opacity="0.22" stroke-width="14"/><g transform="translate(512 512) scale(0.6532) translate(-212.5 -347.5)"><path d="M352.3,665.1C345.4,661.3 343.8,660.0 327.7,645.2C311.0,630.0 307.5,625.0 294.0,597.5C281.8,572.7 274.1,555.3 259.3,519.5C245.7,486.6 243.6,482.1 236.2,471.5C228.1,459.8 224.7,456.0 222.5,456.0C221.4,456.0 219.0,455.1 217.1,453.9L213.7,451.9L211.0,454.5C207.5,457.8 203.4,458.0 198.4,455.0L194.5,452.8L191.9,456.0C187.6,461.1 182.0,472.9 182.0,476.7C182.0,484.0 184.5,486.1 197.1,490.0C204.1,492.1 208.3,498.9 207.9,507.1L207.6,511.8L215.1,517.7C234.1,532.9 250.4,554.6 258.5,575.3C263.0,587.2 266.1,597.8 265.0,598.5C264.1,599.0 264.0,598.7 260.3,588.5C251.8,564.7 231.4,536.8 210.4,520.0C197.8,509.9 173.4,497.7 164.1,496.8C161.0,496.5 160.6,496.9 157.9,501.6C156.2,504.5 154.7,509.0 154.3,511.9C153.9,515.5 153.1,517.4 151.9,518.1C147.5,520.4 151.8,525.0 158.2,525.0C162.3,525.0 163.1,526.3 160.1,528.0C154.1,531.2 146.0,526.9 141.6,518.3C139.1,513.4 139.6,508.0 143.0,501.5C144.7,498.3 145.9,495.6 145.7,495.4C145.0,494.7 136.0,501.2 133.5,504.2C131.5,506.6 131.0,508.2 131.0,512.6C131.0,518.6 132.5,522.2 136.4,525.9C139.5,528.8 138.9,530.2 135.1,528.9C131.2,527.5 127.8,523.6 124.0,516.4C120.2,509.0 119.5,500.6 122.3,495.2L124.1,492.0L119.8,492.0C107.2,492.0 102.8,505.8 113.1,512.7C117.7,515.7 117.9,516.7 114.4,518.0C102.7,522.5 90.8,511.7 95.4,500.7C97.2,496.4 95.8,495.7 92.2,499.0C89.2,501.8 88.5,505.3 90.0,510.6C90.7,513.0 91.0,515.3 90.7,515.6C90.4,516.0 88.6,515.4 86.9,514.4C84.1,512.8 80.2,507.2 78.7,502.8C78.4,501.8 77.5,501.8 74.4,502.9C67.2,505.4 49.6,513.7 43.5,517.3C33.7,523.3 35.1,519.7 45.4,512.7C52.1,508.2 66.0,501.1 73.5,498.4C77.9,496.9 78.8,496.0 80.8,491.8C83.9,485.4 89.2,482.3 98.5,481.4C102.4,481.1 106.8,480.0 108.5,479.0C110.2,478.0 114.2,477.0 117.4,476.7C124.3,476.0 127.5,473.4 134.0,463.5L138.0,457.4L136.4,448.6C134.4,436.8 131.1,426.8 123.0,407.0C116.6,391.3 112.4,379.5 110.0,370.3C109.3,367.3 107.2,364.2 102.8,359.8C86.2,342.6 70.0,321.9 62.1,307.5C56.1,296.6 53.7,290.4 45.5,263.8C35.6,231.8 34.1,220.6 37.9,205.7C40.3,196.6 42.9,191.2 47.5,186.1C50.8,182.5 51.0,181.8 52.0,171.1C53.1,159.0 54.8,150.1 58.1,137.5C59.3,133.0 60.3,126.4 60.3,122.5C60.3,116.1 59.9,114.8 55.7,106.7C52.4,100.5 51.0,96.6 51.0,93.9L51.0,90.0L45.8,90.0C42.9,90.0 39.6,89.7 38.5,89.4C36.9,89.0 35.9,89.8 34.1,93.1L31.7,97.3L29.9,95.1C28.9,93.9 27.6,90.5 26.9,87.5C24.6,77.1 29.7,64.5 39.2,56.9C42.5,54.3 44.9,51.5 45.6,49.3C48.6,40.6 60.5,32.8 76.6,28.9C88.2,26.0 110.1,25.7 120.0,28.3C132.3,31.5 142.6,37.5 151.5,46.4C163.6,58.7 171.0,72.8 176.5,94.0C180.3,108.6 184.3,116.5 194.7,130.7C199.7,137.4 205.5,146.0 207.6,149.7C213.9,161.1 221.4,171.9 232.9,186.3C273.3,236.8 294.7,283.6 304.4,343.0C306.2,353.6 307.9,360.2 310.8,367.5C313.0,373.0 317.1,384.7 320.0,393.5C324.5,407.2 325.3,411.0 325.9,420.5L326.6,431.5L342.8,464.0C351.6,481.9 362.1,502.1 366.1,509.0C385.6,542.8 399.6,578.0 393.7,578.0C391.9,578.0 381.8,567.4 350.1,532.3C335.5,516.2 323.5,503.1 323.3,503.3C323.2,503.5 324.8,508.8 327.0,515.1C329.1,521.4 337.9,547.2 346.6,572.5C355.2,597.8 365.3,627.5 369.1,638.5C376.2,659.3 377.1,663.8 374.4,666.1C373.0,667.2 372.2,667.1 368.8,665.2L364.8,663.0L362.8,665.5C360.3,668.7 358.7,668.6 352.3,665.1Z" fill="url(#phkPerry_goldv)"/></g></svg>';
  function isPageHawk() { return BRAND.name === 'PageHawk'; }

  // TWO SHAPES, AND THIS IS NOT A USER SETTING (Jason, 2026-08-20: "I don't think the user should
  // be able to choose this, but I do like how [V1] has the pitch"). He picked V4 off the ballot and
  // then could not separate it from V1, so both ship and he flips between them in the running app
  // until one wins. It is deliberately absent from Settings ▸ Surfaces: a half-decided taste call
  // is a thing for him to live with, not a preference to hand to everyone who installs this.
  //
  //   'folded'  — V4: the mark, the name, the pitch and the way in are ONE object
  //   'stacked' — V1: identity above, drop zone below, the shape the app always had
  //
  // The flip is ⌥⌘W on this screen, with no visible control, and it persists through the same
  // setcfg door every other preference uses so it survives a relaunch.
  var SHAPES = ['folded', 'stacked'];
  function shape() {
    var s = global.PHK_WELCOME_SHAPE;
    return SHAPES.indexOf(s) >= 0 ? s : SHAPES[0];
  }

  function mark() {
    var cards = '<rect class="b1" x="50" y="58" width="72" height="98" rx="11"/><rect class="b2" x="78" y="54" width="72" height="100" rx="11"/><rect class="front" x="64" y="44" width="76" height="104" rx="12"/>';
    var badge = '<g class="badge"><circle cx="126" cy="136" r="19" fill="#C8A872"/><path d="M117 136 l6 6 l12 -13" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></g>';
    var inner = has('read')
      ? '<line class="ln" x1="80" y1="72" x2="124" y2="72"/><line class="ln" x1="80" y1="92" x2="124" y2="92"/><line class="ln" x1="80" y1="110" x2="110" y2="110"/>'
      : '<g class="ln" fill="none" stroke="#C8A872" stroke-width="7"><circle cx="102" cy="94" r="13"/><circle cx="102" cy="94" r="26"/><circle cx="102" cy="94" r="39"/></g>';
    return cards + inner + badge;
  }
  function dropSub() {
    return has('read') ? 'PDFs, Word, decks, photos &middot; one file or a whole folder'
                       : 'Photos &amp; RAW &middot; one image or a whole library';
  }

  // ── welcomeTaskCards(), carried verbatim; href="hvniz://x" -> data-act="x" ──
  // c[6] === 1 marks a card whose surface only the Mac shell ships today (same mac_only idea as the
  // FDA tip row). Draft Timeline is a native Mac surface (drafttimeline.html + the Swift handshake),
  // so showing it on win/web would render a card act() has no route for. Drop the flag the moment
  // the surface lands on the other shell.
  var CARDS = [
      ['read',    'ask',              '<path d="M21 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2z"/><path d="M9.5 9.5h6M9.5 12.5h4"/>', 1.7, 'Ask a document or folder', 'Private on-device AI · summarize, pull clauses, ask anything'],
      ['read',    'organize',         '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M15 10.2l.9 1.9 1.9.9-1.9.9-.9 1.9-.9-1.9-1.9-.9 1.9-.9z"/>', 1.7, 'Organize with AI', 'Rename & sort a messy folder by what each file says · on-device'],
      ['redact',  'redact',           '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 16h4"/><rect x="7.5" y="11" width="9" height="2.6" rx=".5" fill="currentColor" stroke="none"/>', 1.7, 'Redact & clean', 'Burn out text & strip metadata · true redaction, on-device'],
      ['compare', 'compare',          '<rect x="3" y="4" width="7" height="16" rx="1.2"/><rect x="14" y="4" width="7" height="16" rx="1.2"/><path d="M11.5 9l1.2 3-1.2 3" stroke-linecap="round" stroke-linejoin="round"/>', 1.8, 'Compare documents', 'Redline two PDFs or Word docs, side by side'],
      ['read',    'rank',             '<path d="M4 6h16M4 12h10M4 18h5"/>', 1.9, 'Rank by focus', 'Score a folder on the clause or term that matters'],
      ['read',    'browse',           '<rect x="3" y="4" width="12" height="16" rx="1.4"/><path d="M18 7v13H7"/>', 1.8, 'Browse a set', 'Flip through many PDFs, one page across all'],
      ['compare', 'versions',         '<path d="M12 3l8 4-8 4-8-4 8-4zM4 12l8 4 8-4M4 16.5l8 4 8-4"/>', 1.6, 'Compare versions', 'Line up drafts and keep the best one'],
      ['compare', 'drafttimeline',    '<rect x="2.5" y="7" width="5" height="10" rx="1.2"/><rect x="9.5" y="7" width="5" height="10" rx="1.2"/><rect x="16.5" y="7" width="5" height="10" rx="1.2"/><path d="M7.5 12h2M14.5 12h2"/>', 1.7, 'Draft Timeline', 'Drop every draft · reorder them · redline between each, live', 1],
      ['compare', 'pickvisualcompare','<rect x="3" y="4" width="8" height="16" rx="1.2"/><rect x="13" y="4" width="8" height="16" rx="1.2"/><path d="M15.4 9.2l1.4 1.4-1.4 1.4M16.8 10.6h-2.6" stroke-linecap="round"/>', 1.7, 'Visual page diff', 'See exactly what changed on each page, highlighted'],
      ['read',    'read',             '<rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 8h6M9 12h6M9 16h4" stroke-linecap="round"/>', 1.8, 'Read a document', 'Open one PDF in the fast reader'],
      ['sort',    'sort',             '<path d="M5 13l4 4L19 7"/>', 1.9, 'Sort a pile', 'Keep or trash, keyboard-fast'],
      ['dedupe',  'winnowphotos',     '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.6"/><path d="M21 15l-5-5L5 21"/>', 1.8, 'Sort your Photos library', 'Find near-dupes & blurry shots, on-device'],
      ['dedupe',  'winnowcollectlib', '<path d="M12 21s-7-4.35-9.5-8.5C.8 9.5 2.2 6 5.5 6 7.5 6 9 7.2 12 10c3-2.8 4.5-4 6.5-4 3.3 0 4.7 3.5 3 6.5C19 16.65 12 21 12 21z"/>', 1.7, 'Collect into albums', 'Browse by year, batch-pick favorites into albums'],
    ];

  // The brand + shell gate, applied ONCE. Everything that renders a capability (the Tools tab,
  // the jump bar) goes through this, so a capability can never be offered on a shell act() has
  // no route for, and can never be present in one surface and missing from the other.
  function visibleCards() {
    return CARDS.filter(function (c) { return has(c[0]) && (c[6] !== 1 || IS_MAC); });
  }

  function taskCards() {
    // c[4]/c[5] are PLAIN TEXT and are escaped here. They used to carry HTML entities and be
    // injected raw, which worked only because this was their single render point; the moment the
    // jump bar became a second one, "&middot;" started appearing on screen as those nine literal
    // characters. c[2] is genuinely SVG path markup and stays raw.
    return visibleCards().map(function (c) {
      return '<a class="cap" data-act="' + c[1] + '"><span class="ci"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="' + c[3] + '" stroke-linecap="round" stroke-linejoin="round">' + c[2] + '</svg></span><span class="cb"><span class="ct">' + esc(c[4]) + '</span><span class="cd">' + esc(c[5]) + '</span></span></a>';
    }).join('');
  }

  // ── DEMO MODE, on the welcome screen ───────────────────────────────────────────────────────
  // web/app.js has had a demo overlay for a while, but it overlays the READING HOME, and this file
  // never consulted it. That left the app's FIRST screen as the one surface that could not be
  // photographed: every capture of the welcome carried whatever real documents the owner last
  // opened, which on Jason's own Mac is live deal paper. A product that cannot show its own front
  // door is the gap marketing-self-capture.md exists to close.
  //
  // Same key, same profiles and the same READ-THROUGH CONTRACT as app.js: demo substitutes at read
  // time and NEVER writes phk_pins or phk_recents, so the real library is untouched and turning it
  // off restores the real lists instantly.
  //   'qa'         obviously synthetic TEST names, honest on a shared machine and in the corpus
  //   'marketing'  plausible invented documents for store shots and the website
  var DEMO_H = 3600000, DEMO_D = 86400000;
  var DEMO = {
    qa: {
      pins: [{ name: 'TEST Sample Matters', path: '/Users/test/TEST Sample Matters' }],
      recents: [
        ['TEST Sample Agreement - A. Sample - 700000.pdf', 2 * DEMO_H],
        ['TEST Sample Agreement - A. Sample (redline).pdf', 5 * DEMO_H],
        ['TEST Sample Statement 2026-07.pdf', 1 * DEMO_D],
        ['TEST Sample Services Terms v4.docx', 1 * DEMO_D],
        ['TEST Sample Board Memo 2026-08.pdf', 3 * DEMO_D],
        ['TEST Sample Working Paper 01.pdf', 4 * DEMO_D],
        ['TEST Sample Lease Renewal 2026.pdf', 40 * DEMO_D]
      ]
    },
    // Titles kept in step with app.js DEMO_MARKETING on purpose: one product photographed twice
    // should not appear to hold two different libraries.
    marketing: {
      pins: [{ name: 'Board Papers', path: '/Users/demo/Board Papers' }],
      recents: [
        ['Quarterly Business Review.pdf', 1 * DEMO_H],
        ['Annual Report.pdf', 6 * DEMO_H],
        ['Design Specification.pdf', 1 * DEMO_D],
        ['Services Agreement v4.docx', 2 * DEMO_D],
        ['Services Agreement v4 (redline).pdf', 2 * DEMO_D],
        ['Onboarding Handbook.pdf', 4 * DEMO_D],
        ['Field Research Notes.pdf', 6 * DEMO_D],
        ['Product Roadmap.pdf', 30 * DEMO_D]
      ]
    }
  };
  // Resolution order matches app.js exactly: URL param, then whatever the shell injected, then the
  // persisted flag last, so a URL can override for one load without changing stored state.
  function demoProfile() {
    try {
      var q = new URLSearchParams(location.search).get('demo');
      if (q === 'off') return '';
      if (q === 'qa' || q === 'marketing') return q;
      if (q === '1') return 'qa';
    } catch (_) {}
    if (global.PHK_DEMO === 'marketing' || global.PHK_DEMO === 'qa') return global.PHK_DEMO;
    if (global.PHK_DEMO === true) return 'qa';
    try {
      var v = localStorage.getItem('phk_demo') || '';
      return (v === 'qa' || v === 'marketing') ? v : '';
    } catch (_) { return ''; }
  }
  function demoSet() {
    var p = demoProfile();
    return p ? DEMO[p] : null;
  }

  function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;'); }
  function asArr(v) { return Array.isArray(v) ? v : []; }
  // Pins/recents source: Mac shell PUSHES PHK_PINS / PHK_RECENTS (it owns the recents store in
  // UserDefaults); win/web read the localStorage store the PC shell keeps in sync.
  function pins() {
    var d = demoSet();
    if (d) return d.pins.slice();                 // read-through: the real store is not consulted
    if (IS_MAC) return asArr(global.PHK_PINS);
    try { return asArr(JSON.parse(localStorage.getItem('phk_pins') || '[]')); } catch (_) { return []; }
  }
  function savePins(a) {
    if (IS_MAC) return;  // Mac mutates via hvniz://pin|unpin (the shell rewrites UserDefaults + reloads)
    try { localStorage.setItem('phk_pins', JSON.stringify(a)); } catch (_) {} pushPins(a);
  }
  function pushPins(a) {
    var inv = global.__TAURI_INTERNALS__ && global.__TAURI_INTERNALS__.invoke;
    if (inv) { try { inv('phk_update_pins', { items: a }); } catch (_) {} }
  }
  function recents() {
    var d = demoSet();
    if (d) {
      var t = Date.now();
      return d.recents.map(function (r, i) {
        return { name: r[0], path: '/Users/demo/Documents/' + r[0], kind: 'file',
                 // One row shows the moved-file state, because an all-healthy list never
                 // photographs the case a real library always eventually has.
                 missing: i === d.recents.length - 1 ? '1' : '', ts: t - r[1] };
      });
    }
    if (IS_MAC) return asArr(global.PHK_RECENTS);
    try { return asArr(JSON.parse(localStorage.getItem('phk_recents') || '[]')); } catch (_) { return []; }
  }

  function chipRows() {
    var html = '';
    var ps = pins();
    if (ps.length) {
      html += '<div class="recent"><div class="rl">Pinned</div><div class="chips">' + ps.map(function (p, i) {
        var name = p.name || String(p.path || '').split(/[\\/]/).pop();
        return '<a class="chip" data-openroot="' + i + '" title="' + esc(p.path || '') + '">' + esc(name) + '<span class="pn" title="Unpin" data-unpin="' + i + '">&#9733;</span></a>';
      }).join('') + '</div></div>';
    }
    // 12, not 5: this slice is the SECOND cap on the same list (the Mac shell applies its own
    // before pushing PHK_RECENTS), and a 5-row landing page is how a handful of stray entries
    // pushed the document Jason wanted off the screen entirely. Raised in both places together.
    var rs = recents().slice(0, 12);
    if (rs.length) {
      html += '<div class="recent"><div class="rl">Recent</div><div class="chips">' + rs.map(function (r, i) {
        // title = full path, so two files sharing a name are still tellable apart; a moved
        // file stays listed and says so rather than vanishing from the history.
        var pth = String(r.path || r.id || '');
        var tip = r.missing ? pth + ' (not found at this path)' : pth;
        return '<a class="chip' + (r.missing ? ' missing' : '') + '" data-openrecent="' + i + '" title="' + esc(tip) + '">'
             + esc(r.name || r.id || 'Document') + '<span class="pn" title="Pin" data-pinrecent="' + i + '">&#9734;</span></a>';
      }).join('') + '</div></div>';
    }
    return html;
  }

  // ── Recent dates ───────────────────────────────────────────────────────────────────────────
  // The two shells store the timestamp in DIFFERENT UNITS and neither says so: the Mac pushes
  // RecentItem.ts, which is Foundation seconds-since-1970, and the web/PC store writes Date.now(),
  // which is milliseconds. 1e11 separates them for every date this app can ever be handed (1e11
  // seconds is the year 5138; 1e11 milliseconds is 1973), so ONE formatter serves both rather than
  // each shell growing its own and drifting. A record with no usable stamp yields '', and the row
  // simply renders without a date instead of printing a wrong one.
  function tsMs(r) {
    var v = Number(r && r.ts);
    if (!isFinite(v) || v <= 0) return 0;
    return v < 1e11 ? v * 1000 : v;
  }
  var DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
  var MONS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
  function fmtDate(r) {
    var ms = tsMs(r);
    if (!ms) return '';
    var d = new Date(ms), now = new Date();
    function midnight(x) { return new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); }
    var days = Math.round((midnight(now) - midnight(d)) / 86400000);
    if (days <= 0) return 'Today';
    if (days === 1) return 'Yesterday';
    if (days < 7) return DAYS[d.getDay()];
    if (d.getFullYear() === now.getFullYear()) return MONS[d.getMonth()] + ' ' + d.getDate();
    return MONS[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear();
  }

  // ── The jump bar ───────────────────────────────────────────────────────────────────────────
  // ONE bar answers both questions Jason named: go straight to a file, go straight to a function.
  //
  // WHY A MODEL LEADS HERE, AND WHAT THE RULES ARE FOR. Before any of this matcher was written the
  // question asked was what a Luna-class model would do with "strip the metadata off this", and the
  // rules were then MEASURED against it rather than shipped on faith. On a corpus written
  // alongside the synonym table below, the rules look excellent: 88% correct at rank 1. On a
  // held-out set of paraphrases written to avoid that table's vocabulary they collapse to 47%, and
  // they fail the expensive way rather than the cheap one: "give everything a sensible title based
  // on content" resolves, confidently and at rank 1, to Redact and clean. A lexical rule cannot
  // know that giving files sensible titles is Organize; a model knows it at a glance.
  //
  // So: the rules run on every keystroke because they are instant and work with the network and
  // the disk cold, a model re-ranks them when one is reachable, and when no model is reachable AND
  // the rules are not confident the screen SAYS so instead of pretending. The rules are the
  // instant pass, the offline answer and the verifier. They are not the intelligence.
  //
  // SYN carries the intent words a capability's own copy does not contain. It is the rules-side
  // lever and it is honestly a patch over the gap above, not a solution to it.
  var SYN = {
    ask:               'summarize summary explain question answer chat what does it say clause clauses pull extract find out tell me about brief digest',
    organize:          'tidy clean cleanup messy mess rename renaming title titles naming filing arrange declutter downloads desktop disaster sensible content',
    redact:            'anonymous anonymize black blackout blank censor conceal hide mask obscure private remove sanitize scrub strip metadata exif identity confidential sensitive personal names numbers publicly underneath highlighting',
    compare:           'diff difference differences redline redlines versus against side by side two contracts markup track changes moved language counsel wording',
    rank:              'rank ranking score scoring which mentions contains term keyword relevance important priority narrow down across all forty arbitration termination',
    browse:            'browse flip through skim scan stack pile many multiple set batch page across fast',
    versions:          'version versions draft drafts revision revisions history lineup line up latest newest which one best',
    drafttimeline:     'timeline every all drafts order reorder sequence chronological between each successive evolved over time',
    pickvisualcompare: 'diff difference changed changes page pages visual visually see what highlighted between drafts move moved',
    read:              'read open view reader look at just show single',
    sort:              'keep trash delete discard triage set aside quick quickly keyboard pile through',
    winnowphotos:      'photo photos picture pictures image images shot shots duplicate duplicates dupes near blurry blurred badly library camera roll',
    winnowcollectlib:  'album albums collect collection favorite favorites gather group year batch pick',
    open:              'open folder browse directory choose pick location'
  };

  var STOP = {};
  ('a an and the of to for my me this that these those with on in it is i you your some just off out up all can please want need do').split(' ')
    .forEach(function (w) { STOP[w] = 1; });

  function jnorm(x) { return String(x == null ? '' : x).toLowerCase().replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').replace(/^ | $/g, ''); }
  function jterms(q) {
    return jnorm(q).split(' ').filter(function (t) { return t && !STOP[t]; });
  }
  // TWO matchers, and keeping them apart is load-bearing.
  //
  // jfuzzy allows a subsequence with gaps, so a typo or a truncation still lands ("servcies" finds
  // "services"). That tolerance is only safe against a SHORT haystack: a file name or a capability
  // title. Turned loose on a long description it matches essentially anything, because a five
  // letter term can almost always be spelled out of a sentence. Measured: "lease" matched Compare
  // versions, whose description reads "Line up drafts and keep the best one" — L-in-E-up-dr-A-ft-S
  // -and-k-E-ep — and outranked the document actually named Lease Renewal.
  //
  // jword is the one used on descriptions and on the synonym table: whole words and prefixes only,
  // no spelling-out. Description text is long, so it needs the strict matcher.
  function jfuzzy(t, hay) {
    if (!t || !hay) return 0;
    if (hay.indexOf(t) >= 0) return 1;
    if (t.length < 4 || hay.length > 64) return 0;
    var i = 0, first = -1, last = -1;
    for (var k = 0; k < hay.length && i < t.length; k++) {
      if (hay.charAt(k) === t.charAt(i)) { if (first < 0) first = k; last = k; i++; }
    }
    if (i !== t.length) return 0;
    // A typo match is CONTIGUOUS: the letters of "servcies" all live inside the word "services".
    // A spurious one is spread across the whole string, which is how "score" spelled itself out of
    // "a-S-k a do-C-ument -O-r fold-E-r" and put Ask above Rank for "score a folder on a clause".
    // Bounding the span keeps the typo tolerance and drops the coincidences.
    return (last - first + 1) <= t.length * 2.5 ? 0.55 : 0;
  }
  function jword(t, hay) {
    if (!t || !hay) return 0;
    var ws = hay.split(' ');
    for (var k = 0; k < ws.length; k++) {
      if (ws[k] === t) return 1;
      if (t.length >= 4 && ws[k].indexOf(t) === 0) return 0.85;   // plural / inflection
      if (t.length >= 5 && ws[k].length >= 4 && t.indexOf(ws[k]) === 0) return 0.7;
    }
    return 0;
  }

  // Function targets are the SAME visibleCards() the Tools tab renders, plus Open Folder. Reading
  // one list means the bar can never offer a capability this shell has no route for.
  function jumpFns() {
    var out = visibleCards().map(function (c) {
      return { kind: 'fn', id: c[1], label: c[4], sub: c[5] };
    });
    out.push({ kind: 'fn', id: 'open', label: 'Open Folder', sub: 'Choose a folder or a file to begin' });
    return out;
  }

  function jumpMatch(q) {
    var ts = jterms(q);
    var out = [];
    if (!ts.length) return out;
    var whole = jnorm(q);

    recents().forEach(function (r, i) {
      var name = r.name || r.title || r.id || '';
      var hay = jnorm(name);
      if (!hay) return;
      var sc = 0, hit = 0;
      for (var k = 0; k < ts.length; k++) {
        var f = jfuzzy(ts[k], hay);
        if (f) { hit++; sc += f * (hay.indexOf(ts[k]) === 0 ? 3.2 : 2.6); }
      }
      if (!hit) return;
      sc *= hit / ts.length;
      if (whole && hay.indexOf(whole) >= 0) sc += 2.2;
      out.push({ kind: 'doc', id: 'r' + i, idx: i, label: name, sub: fmtDate(r), score: sc,
                 cover: hit / ts.length, missing: !!r.missing });
    });

    jumpFns().forEach(function (f) {
      var title = jnorm(f.label), desc = jnorm(f.sub), syn = jnorm(SYN[f.id] || '');
      var sc = 0, hit = 0;
      for (var k = 0; k < ts.length; k++) {
        var best = 0, x;
        x = jfuzzy(ts[k], title); if (x) best = Math.max(best, x * 3.0);   // short: fuzzy is safe
        x = jword(ts[k], desc);   if (x) best = Math.max(best, x * 1.5);   // long: words only
        if (syn) { x = jword(ts[k], syn); if (x) best = Math.max(best, x * 1.25); }
        if (best) { hit++; sc += best; }
      }
      if (!hit) return;
      sc *= hit / ts.length;
      if (title === whole) sc += 4;
      // A query that IS a capability's own description has to beat a rival that merely shares one
      // word of its title. Without this, "score a folder on a clause" resolves to Ask rather than
      // Rank, purely because "folder" appears in the words "Ask a document or folder" and title
      // hits outweigh description hits. This mirrors the whole-query bonus documents already get.
      if (whole && desc.indexOf(whole) >= 0) sc += 3;
      out.push({ kind: 'fn', id: f.id, label: f.label, sub: f.sub, score: sc, cover: hit / ts.length });
    });

    out.sort(function (a, b) { return b.score - a.score; });
    return out.slice(0, 8);
  }

  // Confident means a clear winner, not merely a winner. Both halves matter: an absolute floor
  // catches "nothing here really matches", and the margin catches the case the held-out set kept
  // producing, where two capabilities score alike and the rules pick the wrong one by a hair.
  function jumpConfident(res) {
    if (!res.length) return false;
    var top = res[0];
    if (top.score < 3.0) return false;
    // Matching one word out of four is not a match, however high that one word scored. Measured:
    // "make this anonymous before I send it" scored Ask top on a single term and presented it as
    // certain. Half the query has to land before this bar claims to know what you meant.
    if (typeof top.cover === 'number' && top.cover < 0.5) return false;
    if (res.length > 1 && top.score < res[1].score * 1.25) return false;
    return true;
  }

  // ── The smart pass ─────────────────────────────────────────────────────────────────────────
  // Ollama, on localhost, is the seat: local, keyless, free, and already the app's own Tier 1
  // provider (web/ai.js). It is also the only kind of model a document tool should hand file names
  // to without asking first, which is why no cloud call is made from this screen. The probe is
  // fire-and-forget with a short timeout; every failure path lands on 'off' and the rules stand.
  var smart = { state: 'probing', base: 'http://localhost:11434', model: '', gen: 0 };
  function smartProbe() {
    if (typeof global.fetch !== 'function') { smart.state = 'off'; return; }
    var opts, ctl;
    try {
      ctl = new AbortController();
      setTimeout(function () { try { ctl.abort(); } catch (_) {} }, 1500);
      opts = { signal: ctl.signal };
    } catch (_) { opts = undefined; }
    global.fetch(smart.base + '/api/tags', opts).then(function (r) {
      return r && r.ok ? r.json() : null;
    }).then(function (j) {
      var ms = (j && j.models) || [];
      if (!ms.length) { smart.state = 'off'; return; }
      var pref = null;
      for (var i = 0; i < ms.length; i++) {
        if (/llama3|qwen|mistral|gemma|phi/i.test(ms[i].name || '')) { pref = ms[i].name; break; }
      }
      smart.model = pref || ms[0].name;
      smart.state = 'ready';
    }).catch(function () { smart.state = 'off'; });
  }

  function smartRank(q, res, cb) {
    if (smart.state !== 'ready' || !q) return;
    var gen = ++smart.gen;
    var fns = jumpFns();
    var docs = recents().map(function (r, i) { return { i: i, name: r.name || r.title || r.id || '' }; })
                        .filter(function (d) { return d.name; });
    var sys = 'You resolve one search box in a document app. Decide whether the user means a DOCUMENT '
            + 'they already have or a FUNCTION of the app. Reply with ONLY JSON: {"picks":["<id>", ...]} '
            + 'listing at most 3 ids best first. Use a function id exactly as given, or "doc:<number>" '
            + 'for a document.';
    var ctx = 'FUNCTIONS:\n' + fns.map(function (f) { return f.id + ' - ' + f.label + ' - ' + f.sub; }).join('\n')
            + '\n\nDOCUMENTS:\n' + docs.map(function (d) { return 'doc:' + d.i + ' - ' + d.name; }).join('\n')
            + '\n\nQUERY: ' + q;
    var ctl, opts = {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        model: smart.model, stream: false, format: 'json',
        // A hard ceiling on the only axis this call can run away on. It answers with a short id
        // list or it does not answer; there is nothing here worth a long generation.
        options: { num_predict: 120, temperature: 0 },
        messages: [{ role: 'system', content: sys }, { role: 'user', content: ctx }]
      })
    };
    try {
      ctl = new AbortController();
      setTimeout(function () { try { ctl.abort(); } catch (_) {} }, 4000);
      opts.signal = ctl.signal;
    } catch (_) {}
    global.fetch(smart.base + '/api/chat', opts).then(function (r) {
      return r && r.ok ? r.json() : null;
    }).then(function (j) {
      if (gen !== smart.gen) return;              // a newer keystroke already owns the bar
      var txt = j && j.message && j.message.content;
      if (!txt) return;
      var picks;
      try { picks = (JSON.parse(txt) || {}).picks; } catch (_) { return; }
      if (!picks || !picks.length) return;
      cb(reorder(res, picks));
    }).catch(function () { /* the rules already rendered; silence is the correct failure */ });
  }

  // The model's answer is a CANDIDATE ORDER, never a verdict: it may only reorder what the rules
  // found and add a target it recognized that they missed. It cannot invent a row.
  function reorder(res, picks) {
    var byKey = {}, out = [];
    res.forEach(function (r) { byKey[r.kind === 'doc' ? 'doc:' + r.idx : r.id] = r; });
    picks.forEach(function (p) {
      var hit = byKey[String(p)];
      if (hit && out.indexOf(hit) < 0) { out.push(hit); return; }
      var m = /^doc:(\d+)$/.exec(String(p));
      if (m) {
        var i = +m[1], rec = recents()[i];
        if (rec) out.push({ kind: 'doc', id: 'r' + i, idx: i, label: rec.name || rec.title || rec.id || 'Document',
                            sub: fmtDate(rec), score: 99, missing: !!rec.missing });
        return;
      }
      var f = null;
      jumpFns().forEach(function (x) { if (x.id === String(p)) f = x; });
      if (f) out.push({ kind: 'fn', id: f.id, label: f.label, sub: f.sub, score: 99 });
    });
    res.forEach(function (r) { if (out.indexOf(r) < 0) out.push(r); });
    return out.slice(0, 8);
  }

  // The one-off handoff note, when the shell passed one (PHK_NOTE). Empty string = no note, which
  // is the normal case, so the welcome keeps its usual composition.
  function noteRow() {
    var n = global.PHK_NOTE;
    if (!n) return '';
    return '<div class="note" id="phknote"><span class="ntx">' + esc(n) + '</span>'
      + '<button class="nx" aria-label="Dismiss" title="Dismiss" data-notedismiss="1">&times;</button></div>';
  }

  // ── kWelcomeHTML body, carried verbatim (main.swift:249-263); FDA row omitted (mac_only) ──
  // The mark, drawn once, so the two shapes cannot drift apart on the thing they share.
  function markSVG() {
    return isPageHawk() ? PERRY_SVG : '<svg viewBox="0 0 200 200">' + mark() + '</svg>';
  }
  function brandBlock() {
    var id = '<div class="logo">' + esc(BRAND.name) + '</div>'
           + '<div class="tag">' + esc(BRAND.tagline) + '</div>';
    var ask = '<div class="big">Drop files or folders to begin</div>'
            + '<div class="sub">' + dropSub() + '</div>';
    var go  = '<span class="btn" data-act="open">Open Folder&hellip;</span>'
            + '<div class="kbd">or press <b>' + esc(ACCEL_OPEN) + '</b></div>';
    // FOLDED (V4): one object. Both shapes carry the pitch line — the ballot's V4 mock omitted it,
    // the shipped one never did, which is the difference Jason was reading between them.
    if (shape() === 'folded') {
      return '<a class="drop dropbrand" data-act="open">'
           + '<div class="mk">' + markSVG() + '</div>' + id + ask + go + '</a>';
    }
    // STACKED (V1): identity above, the drop zone as its own object below.
    return '<div class="mk">' + markSVG() + '</div>' + id
         + '<a class="drop" data-act="open">' + ask + '</a>'
         + '<a class="btn" data-act="open">Open Folder&hellip;</a>'
         + '<div class="kbd">or press <b>' + esc(ACCEL_OPEN) + '</b></div>';
  }

  // ── F, assembled ───────────────────────────────────────────────────────────────────────────
  // Which tab is showing. Home is the landing; Tools holds the capability cards that used to sit
  // below the fold on it. Deliberately NOT persisted: the app should open on Home every time, the
  // way it always has, rather than remembering that you were last poking at the tool list.
  var tab = 'home';

  // Six rows, where the ballot mock drew four. The mock was a 600px frame; the shipped window is
  // taller. The old landing showed twelve, and that number was itself a fix (2026-07-29) for stray
  // entries pushing the wanted document off the screen. The jump bar is a strictly better fix for
  // that failure than a longer list is, so the list gets to be short again, and the tail stays one
  // keystroke away instead of one scroll away. Six, not four, only because the room is there.
  var RECENT_ROWS = 6;
  var ACCEL_JUMP = IS_MAC ? '⌘K' : 'Ctrl+K';
  var SEARCH_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
    + 'stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.6-3.6"/></svg>';

  function tabStrip() {
    function t(id, label) {
      var on = tab === id;
      return '<button type="button" role="tab" data-tab="' + id + '"'
           + (on ? ' class="on"' : '') + ' aria-selected="' + (on ? 'true' : 'false') + '">' + label + '</button>';
    }
    return '<div class="tabs" role="tablist">' + t('home', 'Home') + t('tools', 'Tools')
         + '<span class="bn">' + esc(BRAND.name) + '</span></div>';
  }

  // The identity, smaller than the landing used to carry it and no longer the drop target. F puts
  // the jump bar between the identity and the drop zone, which is what makes the old folded shape
  // structurally impossible rather than merely unselected.
  function identityBlock() {
    return '<div class="idf"><div class="mk">' + markSVG() + '</div>'
         + '<div class="logo">' + esc(BRAND.name) + '</div>'
         + '<div class="tag">' + esc(BRAND.tagline) + '</div></div>';
  }

  function jumpBar() {
    return '<div class="jump"><div class="jf">'
      + '<span class="ji">' + SEARCH_SVG + '</span>'
      + '<input id="phkJump" type="text" autocomplete="off" autocorrect="off" spellcheck="false" '
      + 'aria-label="Jump to a file, or a function" aria-autocomplete="list" aria-expanded="false" '
      + 'placeholder="Jump to a file, or a function&hellip;">'
      + '<span class="sk">' + esc(ACCEL_JUMP) + '</span>'
      + '</div><div class="jres" id="phkJumpRes" role="listbox" style="display:none"></div></div>';
  }

  function dropZone() {
    return '<a class="dropf" data-act="open">'
      + '<div class="big">Drop files or folders to begin</div>'
      + '<div class="sub">' + dropSub() + '</div>'
      + '<div class="row"><span class="btn" data-act="open">Open Folder&hellip;</span>'
      + '<span class="kbd">or press <b>' + esc(ACCEL_OPEN) + '</b></span></div></a>';
  }

  // Pinned and Recent, as LISTS with a date column, replacing the wrapping chip pile. Pinned is
  // rendered only when it has rows: a heading over nothing is the kind of empty furniture this
  // screen was being cleared of. Pins keep working exactly as they did; only their shape changed.
  function listBlocks() {
    var html = '';
    var ps = pins();
    if (ps.length) {
      html += '<div class="lst"><div class="lh">Pinned</div><div class="lrows">'
        + ps.slice(0, 6).map(function (p, i) {
            var name = p.name || String(p.path || '').split(/[\\/]/).pop();
            return '<a class="lrow" data-openroot="' + i + '" title="' + esc(p.path || '') + '">'
                 + '<em>' + esc(name) + '</em>'
                 + '<span class="pn" title="Unpin" data-unpin="' + i + '">&#9733;</span></a>';
          }).join('') + '</div></div>';
    }
    var rs = recents();
    html += '<div class="lst"><div class="lh">Recent</div>';
    if (!rs.length) {
      return html + '<div class="empty">Nothing here yet. Open a document and it lands in this list.</div></div>';
    }
    html += '<div class="lrows">' + rs.slice(0, RECENT_ROWS).map(function (r, i) {
      var pth = String(r.path || r.id || '');
      var d = fmtDate(r);
      return '<a class="lrow' + (r.missing ? ' missing' : '') + '" data-openrecent="' + i + '" '
           + 'title="' + esc(r.missing ? pth + ' (not found at this path)' : pth) + '">'
           + '<em>' + esc(r.name || r.title || r.id || 'Document') + '</em>'
           + (d ? '<u>' + esc(d) + '</u>' : '')
           + '<span class="pn" title="Pin" data-pinrecent="' + i + '">&#9734;</span></a>';
    }).join('') + '</div>';
    if (rs.length > RECENT_ROWS) {
      html += '<div class="empty" style="margin-top:7px">' + (rs.length - RECENT_ROWS)
            + ' more &middot; press <b>' + esc(ACCEL_JUMP) + '</b> to find any of them</div>';
    }
    return html + '</div>';
  }

  function homePane() {
    return '<div class="c">'
      + identityBlock()
      + noteRow()
      + jumpBar()
      + dropZone()
      + '<span id="phkWelChips">' + listBlocks() + '</span>'
      // The Full Disk Access tip now sits INSIDE the scrolling column rather than floating as a
      // sibling of it. 0.66.7 shipped it overlapping two capability cards, illegible, and every
      // automated check passed because the tip only renders when access is ungranted. In the flow
      // it cannot land on top of anything.
      + (global.PHK_FDA_TIP === true
          ? '<div class="fda">Tip: <a data-act="fda">grant Full Disk Access</a> to skip per-folder permission prompts</div>'
          : '')
      + '</div>';
  }

  function toolsPane() {
    return '<div class="tools"><div class="th">Everything ' + esc(BRAND.name) + ' can do</div>'
         + '<div class="caps">' + taskCards() + '</div></div>';
  }

  function markup() {
    return tabStrip() + '<div class="pane" id="phkPane">'
         + (tab === 'tools' ? toolsPane() : homePane()) + '</div>';
  }

  function setTab(next) {
    if (next !== 'home' && next !== 'tools') return;
    tab = next;
    if (el) el.innerHTML = markup();
  }

  // ⌥⌘W flips the shape and persists it. No visible control, by his instruction; the keystroke is
  // the whole surface. It writes through setcfg like every other preference, so the shell has it
  // on the next launch, and re-renders in place so the comparison is instant rather than a restart.
  function flipShape() {
    var next = SHAPES[(SHAPES.indexOf(shape()) + 1) % SHAPES.length];
    global.PHK_WELCOME_SHAPE = next;
    if (el) el.innerHTML = markup();
    try { location.href = 'hvniz://setcfg?key=welcome_shape&value=' + encodeURIComponent(next); } catch (e) {}
  }
  document.addEventListener('keydown', function (e) {
    if (!e.altKey || !(e.metaKey || e.ctrlKey)) return;
    if (String(e.key).toLowerCase() !== 'w') return;
    if (!el || el.style.display === 'none') return;   // only while the welcome screen is showing
    e.preventDefault();
    flipShape();
  });

  // ── jump bar runtime ───────────────────────────────────────────────────────────────────────
  var jq = '', jres = [], jsel = 0, jtimer = null;

  function jumpEls() {
    return { i: document.getElementById('phkJump'), r: document.getElementById('phkJumpRes') };
  }

  function renderJump() {
    var e = jumpEls();
    if (!e.r) return;
    if (!jq) {
      e.r.style.display = 'none'; e.r.innerHTML = '';
      if (e.i) e.i.setAttribute('aria-expanded', 'false');
      return;
    }
    var confident = jumpConfident(jres), html = '';
    if (!jres.length) {
      html = '<div class="jnote">Nothing matches &ldquo;' + esc(jq) + '&rdquo; in your recent documents or in what this app can do.</div>';
    } else {
      // "Best guesses" is not decoration. When the rules are unsure they are also the most likely
      // to be wrong in a way that reads as certain, so the heading stops claiming a match it has
      // not got, and the row is still one keystroke away for whoever wants it anyway.
      html += '<div class="jh">' + (confident ? 'Jump to' : 'Best guesses') + '</div>'
        + jres.map(function (r, i) {
            return '<button type="button" class="jr' + (i === jsel ? ' sel' : '') + '" role="option" '
                 + 'aria-selected="' + (i === jsel ? 'true' : 'false') + '" data-jump="' + i + '">'
                 + '<span class="jk">' + (r.kind === 'doc' ? 'Doc' : 'Do') + '</span>'
                 + '<span class="jt">' + esc(r.label) + '</span>'
                 + (r.sub ? '<span class="jd">' + esc(r.sub) + '</span>' : '')
                 + '</button>';
          }).join('');
    }
    // Said only where it is BOTH true and useful: the rules are unsure, and no model is reachable
    // to do better. A confident match says nothing; a running model says nothing. That keeps this
    // from turning into a permanent banner nagging every user who never asked for a model.
    if (!confident && smart.state !== 'ready') {
      html += '<div class="jnote">Matching names and keywords only. A local model in Ollama reads what you meant, and never leaves this Mac.</div>';
    }
    e.r.innerHTML = html;
    e.r.style.display = '';
    if (e.i) e.i.setAttribute('aria-expanded', 'true');
  }

  function runJump(q) {
    jq = q; jsel = 0;
    jres = jumpMatch(q);          // instant, always, network and disk cold
    renderJump();
    if (jtimer) { clearTimeout(jtimer); jtimer = null; }
    if (!q || q.length < 3 || smart.state !== 'ready') return;
    jtimer = setTimeout(function () {
      var asked = q;
      smartRank(q, jres, function (ranked) {
        if (jq !== asked) return;    // the query moved on while the model was thinking
        jres = ranked; jsel = 0; renderJump();
      });
    }, 260);
  }

  function jumpGo(i) {
    var r = jres[i];
    if (!r) return;
    if (r.kind === 'doc') {
      var rr = recents()[r.idx];
      if (IS_MAC) return macNav('openrecent', rr && (rr.path || rr.id));
      hide();
      if (rr && global.PagehawkReader && global.PagehawkReader.openRecent) return global.PagehawkReader.openRecent(rr);
      return;
    }
    return act(r.id);              // the SAME routing every capability card uses
  }

  function clearJump() {
    var e = jumpEls();
    if (e.i) e.i.value = '';
    jq = ''; jres = []; jsel = 0;
    renderJump();
  }

  function focusJump() {
    if (tab !== 'home') setTab('home');
    var e = jumpEls();
    if (e.i) { e.i.focus(); e.i.select(); }
  }

  // Cmd-K / Ctrl-K puts the cursor in the bar from anywhere on this screen. Checked against the
  // Mac shell's menu table first: nothing there claims "k", so this steals no existing accelerator.
  document.addEventListener('keydown', function (e) {
    if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;
    if (String(e.key).toLowerCase() !== 'k') return;
    if (!el || el.style.display === 'none') return;
    e.preventDefault();
    focusJump();
  });

  var el = null;
  function hide() { if (el) el.style.display = 'none'; }
  function show() {
    if (!el) return;
    el.style.display = 'flex';
    var c = document.getElementById('phkWelChips');
    if (c) c.innerHTML = listBlocks();   // only present on Home; Tools has no list to refresh
  }

  // The Mac shell intercepts every hvniz:// navigation in its WKWebView nav delegate (unchanged by
  // MAC-034 — these are the exact URLs the old kWelcomeHTML anchors used), optionally with a
  // ?path= query for the chip actions.
  function macNav(id, path) {
    location.href = 'hvniz://' + id + (path != null ? '?path=' + encodeURIComponent(path) : '');
  }
  function openLibrary(dir) {
    hide();
    if (global.PagehawkLibrary && global.PagehawkLibrary.open) return global.PagehawkLibrary.open(dir);
    if (global.PagehawkReader && global.PagehawkReader.pick) return global.PagehawkReader.pick();
  }
  function act(id) {
    if (IS_MAC) return macNav(id);  // open/read/ask/organize/redact/compare/versions/rank/browse/sort/fda/...
    switch (id) {
      case 'open': case 'browse': case 'rank': case 'sort':
      case 'cull':  // legacy alias: pre-2026-08 links still say cull
        return openLibrary();
      case 'read': hide(); return global.PagehawkReader && global.PagehawkReader.pick();
      case 'ask': location.href = 'pro.html?tool=ai'; return;
      case 'organize': location.href = 'pro.html?tool=ai-organize'; return;
      case 'redact': location.href = 'pro.html?tool=redact'; return;
      case 'compare': case 'versions': case 'pickvisualcompare': location.href = 'pro.html?tool=compare'; return;
      case 'winnowphotos': case 'winnowcollectlib': return openLibrary();  // photo brands only
    }
  }

  function boot() {
    // PC deep-open: review.html stashes phk_open_path then navigates to index.html; read it back
    // through the Tauri fileop bridge and hand the bytes to the reader. Win/web only (the Mac shell
    // has no __TAURI_INTERNALS__ and opens via hvniz://). Runs before the gate so it fires even when
    // the welcome overlay itself is suppressed.
    if (!IS_MAC) {
      try {
        var qp = sessionStorage.getItem('phk_open_path');
        if (qp) {
          sessionStorage.removeItem('phk_open_path');
          var inv2 = global.__TAURI_INTERNALS__ && global.__TAURI_INTERNALS__.invoke;
          if (inv2) {
            inv2('phk_fileop_read', { path: qp }).then(function (r) {
              var b64 = r && (r.dataBase64 || r.data_base64); if (!b64) return;
              var bin = atob(b64); var buf = new Uint8Array(bin.length);
              for (var i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
              hide();
              var nm = qp.split(/[\\/]/).pop();
              if (global.PagehawkReader) global.PagehawkReader.openArrayBuffer(buf.buffer, { name: nm, path: qp });
            }).catch(function () {});
          }
        }
      } catch (_) {}
    }
    if (!gated() || document.getElementById('phkWelcome')) return;
    var st = document.createElement('style'); st.textContent = CSS; document.head.appendChild(st);
    el = document.createElement('div'); el.id = 'phkWelcome'; el.innerHTML = markup();
    document.body.appendChild(el);
    el.addEventListener('click', function (e) {
      // A click anywhere that is not the jump bar dismisses its results, so the panel cannot be
      // left hanging over the screen after attention has moved on.
      if (jq && e.target.closest && !e.target.closest('.jump')) clearJump();
      var t = e.target.closest ? e.target.closest('[data-act],[data-jump],[data-tab],[data-openroot],[data-openrecent],[data-unpin],[data-pinrecent],[data-notedismiss]') : null;
      if (!t) return;
      e.preventDefault(); e.stopPropagation();
      if (t.dataset.tab) return setTab(t.dataset.tab);
      if (t.dataset.jump != null) return jumpGo(+t.dataset.jump);
      if (t.dataset.notedismiss != null) {
        var nt = document.getElementById('phknote');
        if (nt) { nt.classList.add('out'); setTimeout(function () { nt.remove(); }, 180); }
        return;
      }
      if (t.dataset.unpin != null) {
        var up = pins()[+t.dataset.unpin];
        if (IS_MAC) return macNav('unpin', up && up.path);
        var a = pins(); a.splice(+t.dataset.unpin, 1); savePins(a); show(); return;
      }
      if (t.dataset.pinrecent != null) {
        var r = recents()[+t.dataset.pinrecent];
        if (IS_MAC) return macNav('pin', r && (r.path || r.id));
        if (r) { var a2 = pins(); a2.push({ name: r.name || r.id, path: r.path || r.id }); savePins(a2); show(); } return;
      }
      if (t.dataset.openroot != null) {
        var p = pins()[+t.dataset.openroot];
        if (IS_MAC) return macNav('openroot', p && p.path);
        return openLibrary(p && p.path);
      }
      if (t.dataset.openrecent != null) {
        var rr = recents()[+t.dataset.openrecent];
        if (IS_MAC) return macNav('openrecent', rr && (rr.path || rr.id));
        hide(); if (rr && global.PagehawkReader && global.PagehawkReader.openRecent) return global.PagehawkReader.openRecent(rr); return;
      }
      if (t.dataset.act) return act(t.dataset.act);
    });
    el.addEventListener('input', function (e) {
      if (e.target && e.target.id === 'phkJump') runJump(e.target.value);
    });
    el.addEventListener('keydown', function (e) {
      if (!e.target || e.target.id !== 'phkJump') return;
      var k = String(e.key);
      if (k === 'ArrowDown' || k === 'ArrowUp') {
        if (!jres.length) return;
        e.preventDefault();
        jsel = (jsel + (k === 'ArrowDown' ? 1 : jres.length - 1)) % jres.length;
        renderJump();
        return;
      }
      if (k === 'Enter') { if (jres.length) { e.preventDefault(); jumpGo(jsel); } return; }
      if (k === 'Escape') { e.preventDefault(); clearJump(); e.target.blur(); }
    });
    // Fire and forget. Every failure path lands on 'off' and the rules simply stand.
    smartProbe();
        // The drop zone rides the app's existing document-level drag-drop handling (drag-drop-open,
    // shipped); dropping anywhere on the welcome therefore opens the payload — hide on drop.
    el.addEventListener('drop', function () { setTimeout(hide, 50); });
  }

  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();

  global.PagehawkWelcome = {
    show: show, hide: hide, isGated: gated, boot: boot,
    _shell: SHELL, _cards: taskCards, _markup: markup, _mark: mark, _css: CSS, _brand: BRAND,
    // Exposed so the matcher can be scored against a corpus outside a browser. A jump bar that is
    // only testable by typing into it is a jump bar whose accuracy nobody ever measures again.
    _jump: jumpMatch, _fns: jumpFns, _visible: visibleCards, _date: fmtDate,
    _confident: jumpConfident, _setTab: setTab, _smart: smart, _demo: demoProfile
  };
})(window);
