#!/usr/bin/env python3
# HVN app panels — branded About / Help / Settings windows, generated as self-contained
# HTML (stdlib only). Reusable across the JP software suite: one CSS, three panels.
#   panels.py KIND OUT [--config PATH] [--version V] [--fda 0|1] [--app "Name"]
# KIND in {about, help, settings}. Settings posts each change back via
# hvniz://setcfg?key=..&value=.. (instant-apply); buttons use hvniz://<host>.
import sys, json, os, html, base64, re, urllib.parse

GOLD = "#C8A872"; GOLDD = "#B8860B"; PAPER = "#FBFAF7"; INK = "#23211C"
MUTE = "#8A8270"; LINE = "#E7DEC9"; CARD = "#FFFFFF"

CSS = f"""
*{{box-sizing:border-box}}html,body{{margin:0}}
body{{background:{PAPER};color:{INK};font:14px/1.6 -apple-system,'Inter',system-ui,sans-serif;-webkit-user-select:none}}
.wrap{{max-width:640px;margin:0 auto;padding:30px 34px 44px}}
.mark{{font-family:Cambria,'Big Caslon','Hoefler Text',Georgia,serif;font-size:30px;letter-spacing:.5px;color:{INK};text-align:center}}.mark b{{color:{GOLD};font-weight:600}}.markdv{{text-align:center;font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:{GOLD};margin-top:3px}}
.mark b{{color:{GOLDD};font-weight:400}}
.sub{{text-align:center;font-size:11px;letter-spacing:5px;color:{MUTE};margin-top:2px}}
.rule{{height:1px;background:{LINE};margin:22px 0}}
h2{{font-family:Cambria,'Hoefler Text',Georgia,serif;font-weight:500;font-size:18px;color:{INK};margin:26px 0 12px;letter-spacing:.2px}}
p{{margin:8px 0;color:#4A463C}}
a{{color:{GOLDD};text-decoration:none}}a:hover{{text-decoration:underline}}
.ver{{text-align:center;color:{MUTE};font-size:13px;margin-top:10px}}
.ver b{{color:{INK};font-weight:600}}
.card{{background:{CARD};border:1px solid {LINE};border-radius:13px;padding:4px 18px;margin:14px 0;box-shadow:0 1px 14px rgba(160,130,70,.06)}}
.row{{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 0;border-bottom:1px solid {LINE}}}
.row:last-child{{border-bottom:none}}
.row .lab{{font-size:14px;color:{INK}}}.row .hint{{font-size:12px;color:{MUTE};margin-top:2px}}
select,input[type=text],input[type=time]{{font:13px -apple-system,sans-serif;padding:7px 10px;border:1px solid #D8CFB8;border-radius:8px;background:#fff;color:{INK};min-width:170px}}
input[type=time]{{min-width:0;width:auto;font-variant-numeric:tabular-nums}}
input[type=time]::-webkit-calendar-picker-indicator{{filter:none;opacity:.55;cursor:pointer}}
input[type=text]{{min-width:230px}}
.sw{{position:relative;width:44px;height:26px;flex:none}}
.sw input{{opacity:0;width:0;height:0}}
.sl{{position:absolute;inset:0;background:#D8CFB8;border-radius:26px;transition:.16s;cursor:pointer}}
.sl:before{{content:"";position:absolute;height:20px;width:20px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.16s;box-shadow:0 1px 3px rgba(0,0,0,.2)}}
.sw input:checked + .sl{{background:{GOLD}}}.sw input:checked + .sl:before{{transform:translateX(18px)}}
/* nowrap: the row is a flex box whose control side is free to shrink, so a two-word button
   ('Sign out', 'Show code...') broke across two lines the moment a long description sat
   beside it. Measured in the 2026-08-28 Account capture before it shipped. */
.btn{{display:inline-block;white-space:nowrap;font-size:13px;font-weight:600;color:#23262A;background:{GOLD};border:1px solid #C0A05F;padding:9px 18px;border-radius:9px;cursor:pointer;text-decoration:none}}
.btn:hover{{filter:brightness(1.04);text-decoration:none}}
.btn.ghost{{background:#fff;color:#5A5341;border-color:#D8CFB8}}
.pill{{font-size:11px;font-weight:600;padding:3px 11px;border-radius:20px}}
.pill.on{{background:#E7F3EC;color:#2E6B47}}.pill.off{{background:#F6ECDD;color:#7F5E1D}}
/* pill.off was #9A7322 on #F6ECDD = 3.70:1, under the AA 4.5:1 floor for 11px text (it is not
   "large text" at any weight). #7F5E1D is the same amber at 5.10:1. Measured by
   tests/sealhawk_panel.test.mjs, which pulls these two declarations out of the emitted CSS. */
.igrp{{margin:2px 0 4px}}
.igh{{font-family:Cambria,'Hoefler Text',Georgia,serif;font-size:14px;color:{INK};border-bottom:1px solid {LINE};padding:0 4px 6px;margin:10px 0 2px}}
.igh em{{font-style:italic;color:{MUTE};font-size:11.5px;font-family:-apple-system,Helvetica,sans-serif}}
.swgrid{{display:flex;flex-wrap:wrap;gap:13px;padding:12px 4px 6px}}
.swatch{{cursor:pointer;text-align:center;width:74px;position:relative}}
.swatch img,.swph{{width:56px;height:56px;border-radius:14px;border:2px solid transparent;display:block;margin:0 auto;filter:grayscale(.62) opacity(.5);transition:filter .15s ease,border-color .15s ease,box-shadow .15s ease}}
.swph{{background:#EDE7D8;filter:none}}
.swatch:hover img{{filter:grayscale(0) opacity(.92);border-color:#E2D2A8}}
.swatch.sel img{{filter:none;border-color:{GOLD};box-shadow:0 0 0 4px rgba(200,168,114,.36),0 3px 12px rgba(39,42,45,.18)}}
.swatch .ck{{display:none;position:absolute;top:-7px;right:7px;width:22px;height:22px;border-radius:50%;background:{GOLD};color:#23211C;font-size:12px;line-height:21px;text-align:center;border:2px solid #fff;box-shadow:0 1px 4px rgba(39,42,45,.32)}}
.swatch.sel .ck{{display:block}}
.swatch span{{display:block;font-size:10.5px;color:{MUTE};margin-top:5px;line-height:1.22}}
.swatch span em{{font-style:normal;color:{MUTE};font-size:9.5px}}
.swatch.sel span{{color:{GOLDD};font-weight:600}}.swatch.sel span em{{color:{GOLDD};opacity:.85}}
/* Two cascade accidents, both caught by MEASURING rather than by looking (the swatch grid is the
   densest thing on the surface and the eye reads a gold-on-gold tick as "styled"):
   1. `.swatch.sel span` matches `.ck` too and, at equal specificity, wins by coming later -- so
      the selected tick was GOLDD on a GOLD disc, 1.44:1. Restated here, after it.
   2. The selected swatch's LABEL is GOLDD on paper, 3.12:1. #856000 is the same gold the light
      block already uses for links and the wordmark, at 5.48:1. Dark keeps GOLDD (5.28:1). */
.swatch.sel .ck{{color:#23211C}}

/* ── Settings navigation rail (2026-08-28) ──────────────────────────────────────────────────
   A SIDEBAR, not a tab strip. Jason 2026-08-07, after seeing three built candidates side by
   side: "Atelier (sidebar) is generally my preferred for full native Mac applications, both here
   and in general." That is a fleet default (memory feedback_mac_settings_sidebar_default), so
   this pane does not re-run the comparison. The grammar is read off the champion implementation
   (cachet-contacts SettingsAtelier) and, behind it, a capture of Wispr Flow's own pane
   (app-atlas/captures/wispr-flow/16-settings-general.png): named groups in the rail, a large
   serif page title, one card per pane, hairline rows, a plain-English description under every
   row title, and a delicate version footnote at the foot of the rail.

   The nine tab buttons this replaces were a single undifferentiated row in which "Editing & PDF
   Tools" wrapped onto two lines, and in which the only way to reach the account was to scroll
   past nine unrelated rows of General. A rail can carry a GROUP HEADING, which is the whole
   reason ACCOUNT is now findable at all. */
.setwrap{{display:flex;align-items:stretch;min-height:100vh}}
.srail{{width:208px;flex:none;display:flex;flex-direction:column;background:rgba(90,70,30,.045);border-right:1px solid {LINE}}}
.srailtop{{padding:20px 16px 4px}}
.srailtop .mark{{font-size:20px;text-align:left;letter-spacing:.3px}}
.srailtop .markdv{{text-align:left;font-size:8.5px;letter-spacing:.19em;margin-top:1px}}
.srailtop .sub{{display:none}}
.sgrp{{font-size:10px;font-weight:700;letter-spacing:.13em;text-transform:uppercase;color:{MUTE};padding:20px 18px 7px}}
.snav{{appearance:none;background:none;border:none;cursor:pointer;text-align:left;display:flex;align-items:center;gap:9px;
  width:calc(100% - 16px);margin:1px 8px;padding:7px 9px;border-radius:7px;
  font:13.5px -apple-system,'Inter',system-ui,sans-serif;color:{INK};transition:background .1s}}
.snav:hover{{background:rgba(90,70,30,.075)}}
.snav.on{{background:rgba(200,168,114,.26);font-weight:600}}
.snav svg{{width:15px;height:15px;flex:none;stroke:{MUTE};fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}}
.snav.on svg{{stroke:{GOLDD}}}
.srailfoot{{margin-top:auto;padding:16px 18px 18px;font-size:11px;line-height:1.65;color:{MUTE}}}
.srailfoot a{{color:{MUTE};text-decoration:underline;text-underline-offset:2px}}
.srailfoot a:hover{{color:{GOLDD}}}
.spane{{flex:1;min-width:0;padding:24px 32px 46px}}
.ptitle{{font-family:Cambria,'Big Caslon','Hoefler Text',Georgia,serif;font-weight:400;font-size:29px;color:{INK};letter-spacing:.2px;margin:2px 0 2px}}
.psub{{font-size:12.5px;color:{MUTE};margin:0 0 2px;max-width:60ch}}
/* Inside a rail pane the first h2 is a SECTION inside the page, so it sits closer than it did
   when it was the first thing under a tab strip. */
.spane .tabpanel>h2:first-of-type{{margin-top:18px}}
/* The one banner shape this surface has: the honest answer when this Mac is on its own library
   while the account is on another. Amber, not red — nothing is broken, two things are simply not
   joined up, and the pane offers the join right there. #7F5E1D on #FBF1DF measures 5.55:1. */
.snote{{background:#FBF1DF;border:1px solid #E8D3A8;border-radius:11px;padding:13px 16px;margin:14px 0;color:#5A4415;font-size:13px;line-height:1.55}}
.snote b{{color:#7F5E1D}}
.snote .acts{{margin-top:11px;display:flex;gap:9px;flex-wrap:wrap}}
.sok{{color:#2E6B47;font-weight:600}}
.tabbar{{display:flex;gap:2px;border-bottom:1px solid {LINE};margin:8px 0 6px;padding:0 2px}}
.tab{{appearance:none;background:none;border:none;cursor:pointer;font-family:-apple-system,Helvetica,sans-serif;font-size:13px;color:{MUTE};padding:9px 15px 10px;border-bottom:2px solid transparent;margin-bottom:-1px;letter-spacing:.2px;transition:color .12s}}
.tab:hover{{color:{INK}}}
.tab.on{{color:{INK};font-weight:600;border-bottom-color:{GOLD}}}
.tabpanel{{display:none}}.tabpanel.on{{display:block}}
.tabpanel>h2:first-child{{margin-top:14px}}
.scwrap{{display:flex;align-items:center;gap:8px;flex:none}}
.kbchip{{font-family:ui-monospace,Menlo,monospace;font-size:13px;min-width:56px;text-align:center;background:#fff;border:1px solid #D8CFB8;border-radius:8px;padding:5px 11px;cursor:pointer;color:{INK}}}
.kbchip:hover{{border-color:{GOLD}}}
.kbchip.rec{{border-color:{GOLD};background:#FBF3E0;color:#9A7322;font-family:-apple-system,sans-serif;font-size:12px}}
.screset{{cursor:pointer;color:#B6AB92;font-size:15px;line-height:1}}.screset:hover{{color:{GOLDD}}}
details{{border-bottom:1px solid {LINE};padding:4px 0}}
summary{{cursor:pointer;list-style:none;padding:12px 2px;font-size:14px;color:{INK};font-weight:500;display:flex;justify-content:space-between;align-items:center}}
summary::-webkit-details-marker{{display:none}}
summary:after{{content:"+";color:{GOLD};font-size:18px;font-weight:400}}
details[open] summary:after{{content:"–"}}
details .a{{padding:0 2px 14px;color:#4A463C;font-size:13.5px}}
.kbd{{display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font-size:13px}}
.kbd b{{font-family:ui-monospace,Menlo,monospace;background:#fff;border:1px solid {LINE};border-radius:5px;padding:1px 8px;color:{INK};justify-self:start;font-weight:500}}
.foot{{text-align:center;color:{MUTE};font-size:11.5px;margin-top:30px;line-height:1.7}}
ul{{margin:8px 0;padding-left:20px;color:#4A463C}}li{{margin:5px 0}}
#toast{{position:fixed;bottom:18px;left:50%;transform:translateX(-50%) translateY(20px);background:{INK};color:#fff;font-size:12px;padding:8px 16px;border-radius:9px;opacity:0;transition:.2s;pointer-events:none}}
#toast.show{{opacity:1;transform:translateX(-50%) translateY(0)}}
@media (prefers-color-scheme:light){{
 /* MEASURED, not eyeballed (contrast-legibility-guardrail.md). The bare MUTE token (#8A8270)
    is 3.40:1 on the rail's tinted ground and 3.65:1 on paper, both under the 4.5 floor for text
    this size. #6E664F is the same warm grey at 5.10:1 (rail) and 5.47:1 (pane). The rail and
    page-subtitle classes are listed HERE rather than given their own colour, because a second
    muted ink is how two greys drift apart. `.screset` and the icon-swatch labels were already
    failing at 2.18 and 3.65 before this pass and are fixed in it. */
 .sub,.row .hint,.ver,.foot,.tab,.markdv,
 .sgrp,.srailfoot,.srailfoot a,.psub,
 .swatch span,.swatch span em,.igh em,.screset{{color:#6E664F}}
 .swatch.sel span,.swatch.sel span em{{color:#856000}}
 .mark b{{color:#856000}}a{{color:#856000}}
}}
@media (prefers-color-scheme:dark){{
 body{{background:#1b1b1d;color:#E8E2D6}}
 p,ul,li{{color:#B9B3A6}}
 .sub,.row .hint,.ver,.foot,.tab{{color:#9A9690}}
 /* .tab.on and .tab:hover set color:INK (#23211C) unconditionally and outrank the .tab rule
    above on specificity, so in dark mode the ACTIVE tab rendered near-black on the #1b1b1d
    panel: measured 1.07:1 against a 4.5 floor, i.e. effectively invisible. Same shape as the
    Hush "Activate Pro Max" defect that produced contrast-legibility-guardrail.md. Re-stated
    here in the dark block using this block's own primary ink: #F0EBE1 on #1b1b1d measures
    14.47:1, against 5.85:1 for the inactive tabs beside it, so the active tab now reads as
    the brightest label in the bar rather than the only invisible one. */
 .tab.on,.tab:hover{{color:#F0EBE1}}
 .rule,.row,.card{{border-color:rgba(255,255,255,.12)}}
 .card{{background:#242019;box-shadow:none}}
 .row .lab,.ver b,h2,.mark{{color:#F0EBE1}}
 select,input[type=text],input[type=time],.kbchip,.kbd b,.btn.ghost{{background:#2A2620;color:#E8E2D6;border-color:rgba(255,255,255,.16)}}
 input[type=time]::-webkit-calendar-picker-indicator{{filter:invert(1);opacity:.6}}
 .sl{{background:rgba(255,255,255,.24)}}
 .swph{{background:#33302A}}
 /* PRE-EXISTING and severe, found by measuring rather than by anyone reporting it: `summary`,
    `details .a` and `.igh` all set a near-black ink in the base sheet and the dark block never
    answered them, so in Noir the Help pane's FAQ and the App-icon group headings rendered at
    1.07:1, 1.83:1 and 1.01:1 -- effectively invisible. Same defect class as the Hush "Activate
    Pro Max" dialog that produced contrast-legibility-guardrail.md. Now 13.64:1, 7.77:1 and
    13.64:1 against the card they sit on. */
 summary,.igh{{color:#F0EBE1}}
 details .a{{color:#B9B3A6}}
 .swatch span,.swatch span em,.igh em,.screset,.srailfoot a{{color:#9A9690}}
 .srail{{background:rgba(255,255,255,.035);border-right-color:rgba(255,255,255,.12)}}
 .snav{{color:#E8E2D6}}
 .snav:hover{{background:rgba(255,255,255,.07)}}
 /* The selected row must be the BRIGHTEST label in the rail in both themes, which is the same
    lesson the .tab.on rule twenty lines up records: #F0EBE1 on the selected pill measures
    12.9:1, against 5.85:1 for its unselected neighbours. */
 .snav.on{{background:rgba(200,168,114,.22);color:#F0EBE1}}
 .snav svg{{stroke:#9A9690}}
 .snav.on svg{{stroke:{GOLD}}}
 .sgrp,.srailfoot,.psub{{color:#9A9690}}
 .ptitle{{color:#F0EBE1}}
 /* Amber-on-amber inverts in Noir rather than keeping a light card on a dark pane: #F0D9A8 on
    #33291A measures 8.9:1. Both sides move together, which is the fix contrast-legibility-
    guardrail.md names for the dark-on-dark class. */
 .snote{{background:#33291A;border-color:rgba(200,168,114,.32);color:#EADFC6}}
 .snote b{{color:{GOLD}}}
 .sok{{color:#7FC79C}}
}}
"""

JS = """
function setcfg(k,v){location.href='hvniz://setcfg?key='+encodeURIComponent(k)+'&value='+encodeURIComponent(v);toast();}
function toast(m){var t=document.getElementById('toast');if(!t)return;if(m)t.textContent=m;t.classList.add('show');clearTimeout(window._tt);window._tt=setTimeout(function(){t.classList.remove('show')},1100);}
function comboSym(c){if(!c)return 'None';if(String(c).toLowerCase()==='none')return 'None';return c.split('+').map(function(p){p=p.trim().toLowerCase();return ({cmd:'\\u2318',command:'\\u2318',shift:'\\u21e7',opt:'\\u2325',alt:'\\u2325',ctrl:'\\u2303',control:'\\u2303','return':'\\u21a9',enter:'\\u21a9',space:'\\u2423'})[p]||p.toUpperCase();}).join('');}
function recKey(el){el.textContent='Press keys\\u2026';el.classList.add('rec');
  function onKey(e){e.preventDefault();var k=(e.key||'').toLowerCase();
    if(['shift','meta','control','alt','command','os','dead'].indexOf(k)>=0)return;
    var parts=[];if(e.metaKey)parts.push('cmd');if(e.shiftKey)parts.push('shift');if(e.altKey)parts.push('opt');if(e.ctrlKey)parts.push('ctrl');
    if(k===' ')k='space';if(k==='enter')k='return';if(k==='escape'){el.classList.remove('rec');el.textContent=comboSym(el.dataset.combo);document.removeEventListener('keydown',onKey,true);return;}
    // Delete STRIPS the shortcut entirely, which is different from the reset arrow: reset restores
    // the DEFAULT, this leaves the action with no key at all. Without it an action that ships with
    // a default could never be unbound (Jason: "configurable by the user or strippable by the user").
    if(k==='backspace'||k==='delete'){el.dataset.combo='none';el.textContent=comboSym('none');el.classList.remove('rec');
      document.removeEventListener('keydown',onKey,true);
      location.href='hvniz://setshortcut?action='+encodeURIComponent(el.dataset.action)+'&combo=none';toast('Shortcut removed');return;}
    parts.push(k);var combo=parts.join('+');
    el.dataset.combo=combo;el.textContent=comboSym(combo);el.classList.remove('rec');
    document.removeEventListener('keydown',onKey,true);
    location.href='hvniz://setshortcut?action='+encodeURIComponent(el.dataset.action)+'&combo='+encodeURIComponent(combo);toast('Shortcut set');}
  document.addEventListener('keydown',onKey,true);}
function resetKey(el){var chip=el.parentNode.querySelector('.kbchip');var d=chip.dataset.default;chip.dataset.combo=d;chip.textContent=comboSym(d);
  location.href='hvniz://setshortcut?action='+encodeURIComponent(chip.dataset.action)+'&combo=';toast('Reset to default');}
function seticon(el,id){location.href='hvniz://seticon?id='+encodeURIComponent(id);var all=document.querySelectorAll('.swatch.sel');for(var i=0;i<all.length;i++)all[i].classList.remove('sel');el.classList.add('sel');toast('Icon set');}
/* Selects a pane. Matches BOTH shapes on purpose: `.tab` is the horizontal strip the Help and
   changelog panels still use, `.snav` is the Settings rail (2026-08-28). One function, so a pane
   cannot be reachable from one navigator and not the other. The content column is scrolled back
   to the top on every switch, because a rail leaves the pane in place and inheriting the previous
   pane's scroll offset lands you halfway down a screen you have never seen. */
function showTab(id){var ts=document.querySelectorAll('.tab,.snav');for(var i=0;i<ts.length;i++)ts[i].classList.toggle('on',ts[i].getAttribute('data-tab')===id);var ps=document.querySelectorAll('.tabpanel');for(var j=0;j<ps.length;j++)ps[j].classList.toggle('on',ps[j].getAttribute('data-panel')===id);var sp=document.querySelector('.spane');if(sp)sp.scrollTop=0;try{localStorage.setItem('vt-settab',id)}catch(e){}}
function _inittabs(){var nav=document.querySelector('.tabbar,.srail');if(!nav)return;var id='general';try{id=localStorage.getItem('vt-settab')||'general'}catch(e){}if(!document.querySelector('[data-tab="'+id+'"]'))id=(document.querySelector('.tab,.snav')||{}).getAttribute?document.querySelector('.tab,.snav').getAttribute('data-tab'):'general';showTab(id);}
(function(){var cs=document.querySelectorAll('.kbchip');for(var i=0;i<cs.length;i++)cs[i].textContent=comboSym(cs[i].dataset.combo);})();
_inittabs();
"""

# Customizable actions (id, label, default combo) — MUST match Swift customizableActions().
SHORTCUTS = [
    ("reload", "Reload & recache", "cmd+r"),
    ("open", "Open folder", "cmd+o"),
    ("newtab", "New tab", "cmd+t"),
    ("newwin", "New window", "cmd+n"),
    # ⇧⌘T, the browser gesture for "undo that close" (Jason 2026-08-08). Free here: ⌘T is New tab
    # and ⌥⌘T is Table of contents. Session-only stack, 20 deep; File history (⌘K) is the answer
    # for anything older, and is what survives a relaunch.
    ("reopentab", "Reopen closed tab", "cmd+shift+t"),
    ("edit", "Open in default app", "cmd+e"),
    ("rotate", "Rotate image", ""),
    ("slideshow", "Slideshow", ""),
    ("theme", "Toggle light / dark", ""),
    # ⌘K IS HISTORY; ⌘O IS OPEN (Jason 2026-08-05, superseding the 2026-08-03 mapping that put
    # History on ⌘O): "we need to separate opening new from opening history, which is something of
    # my preference", plus "a Command-K to jump to anything in recent history with a typeahead".
    # Two keys, two questions: ⌘K jumps to something already opened, ⌘O opens something new, which
    # is what ⌘O means in every Mac app. History keeps its Browse… button, so "open new" is still
    # one click away from inside it. ⌘H is unaffected and stays with Hide.
    ("history", "File history", ""),          # Cmd-K released 2026-08-19; File History is also Cmd-O > Recents
    # ⌘J is the NOUN to ⌘K's verb (spec 09 Part 5): ⌘K takes you to a thing, ⌘J shows you your
    # things. Toggles the library forward and away; falls through to File History when there is no
    # working set to show.
    ("library", "Library", "cmd+j"),
    ("shareonline", "Share online", ""),
    ("myshares", "My online shares", ""),
    ("account", "Epiphani account (sign in / out)", ""),
]
# MUST MIRROR customizableActions() in main.swift, id for id and default for default. The build
# fails on a mismatch (build_pagehawk_full.sh, "shortcut table drift"), because two hand-kept lists
# is exactly how a shortcut ends up unlisted in Settings or listed with the wrong default.

# App icon SET: 3 marks x 3 treatments x 2 backgrounds = 18, rendered by icon/gen_icon_set.py
# to icon/variants/<glyph>_<treatment>_<bg>.png (+ _thumb). The picker groups by mark.
# Goldenrod + Gold Field dropped (too intense). This is the single source of truth — Swift
# only locates the PNGs and applies the chosen id. Default = the light-gold Slate Gold stack.
ICON_DEFAULT = "stack_slate_light"
ICON_TREATMENTS = [("slate", "Slate Gold"), ("charcoal", "Charcoal Gold"), ("mono", "Monochrome Gold")]
ICON_BACKDROPS = [("light", "Light gold"), ("dark", "Dark")]
ICON_GROUPS = [
    ("stack", "Stack", "review &amp; sort"),
    ("compare", "Compare", "compare two"),
    ("lens", "Lens", "inspect the pile"),
]

def head(sub="", app="PageHawk", category="Document Studio"):
    # sub/app/category are internal brand literals that may carry intended HTML entities
    # (e.g. &middot;), so they are NOT escaped here — never pass user input.
    cat = f'<div class="markdv">{category}</div>' if category else ""
    return (f'<div class="mark">{app}</div>{cat}'
            f'<div class="sub">{sub or "REVIEW &middot; COMPARE &middot; SORT"}</div>')

def shell(title, body):
    return ('<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + html.escape(title) +
            '</title><style>' + CSS + '</style></head><body><div class="wrap">' + body +
            '</div><div id="toast">Saved</div><script>' + JS + '</script></body></html>')


def shell_rail(title, body):
    """Settings' own shell: full-bleed, because the rail must reach both edges of the window.

    `shell()` centres everything inside `.wrap` (max-width 640), which is right for About and Help
    and wrong for a two-column surface -- a centred rail with paper either side of it reads as a
    box that fell into the window rather than as part of it.
    """
    return ('<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + html.escape(title) +
            '</title><style>' + CSS + '</style></head><body>' + body +
            '<div id="toast">Saved</div><script>' + JS + '</script></body></html>')


def about(version, appname="PageHawk", category="Document Studio"):
    body = head(app=appname, category=category) + f"""
    <div class="ver">Version <b>{html.escape(version)}</b></div>
    <div class="rule"></div>
    <p style="text-align:center">See your whole library at once: review, compare, and sort photos,
    videos, PDFs, and decks from the keyboard, then keep what matters.</p>
    <div class="card"><div class="row"><div><div class="lab">What's new</div>
      <div class="hint">Release notes and the full changelog</div></div>
      <a class="btn ghost" href="hvniz://help?tab=changelog">View changelog</a></div>
      <div class="row"><div><div class="lab">Help &amp; FAQ</div>
      <div class="hint">How it works and how to use it</div></div>
      <a class="btn ghost" href="hvniz://help">Open Help</a></div></div>
    <div class="foot">A {html.escape(appname)} tool, built by Jason with Claude.<br>
    Private &amp; internal &middot; your library in one window, at speed.</div>"""
    return shell(f"About {appname}", body)


FAQ = [
    ("How do I open a folder?",
     "Drag any folder onto the window, drop it on the welcome screen, or press ⌘O. "
     "You can also right-click a folder in Finder and choose “Open in PageHawk”, "
     "or drag a folder onto the app icon."),
    ("How do I compare two or more PDFs, decks, or versions?",
     "Drop them in together. PageHawk lays them out side by side so you can flip through and "
     "pick the best. For a multi-page file, press ↑ / ↓ to page through it right in the preview."),
    ("What does “Smart Select” do?",
     "It pre-selects the obvious set-aside candidates (burst near-duplicates, screenshots, and noise in a "
     "photo pile) as a SELECTION you can review and undo. Nothing is deleted: it only marks decisions, "
     "and any actual cleanup asks you to confirm first."),
    ("Why does it say “right-click → Open” the first time?",
     "The app is signed for personal use but not notarized through Apple, so on first launch macOS asks "
     "you to confirm. Right-click the app in Applications, choose Open, then Open. Only needed once."),
    ("How do I stop the per-folder permission prompts?",
     "Grant Full Disk Access in Settings (⌘,) or from the banner. It is optional and reversible: "
     "without it you can still open any folder, macOS just asks once per folder."),
    ("Where do my decisions and thumbnails go?",
     "Reviews and a thumbnail cache live in “~/Triage Review and Compare”. Your keep / trash / "
     "unsure decisions are saved per folder and never touch the original files until you confirm a cleanup."),
    ("Do I need a SealHawk account to use PageHawk?",
     "No. Every part of PageHawk works on its own, and nothing is held back if you never connect one. "
     "If you do sign agreements through SealHawk, connecting its contract library in Settings adds two "
     "things here: “Agreements Needing Attention” (each renewal or notice deadline your library is "
     "watching) and “Archive to SealHawk” (send the open PDF into that library). The connection reads "
     "your agreements and cannot send anything in your name, and either product is complete alone."),
    ("It is slow on a Dropbox folder.",
     "Files that live only in the cloud have to download before they can be previewed. That is Dropbox, "
     "not the app. Right-click the folder in Finder and choose “Make Available Offline” first."),
]

def help_(version="1.0.0", appname="PageHawk", category="Document Studio"):
    faq = "".join(f"<details><summary>{html.escape(q.replace('PageHawk', appname))}</summary>"
                  f"<div class='a'>{a.replace('PageHawk', html.escape(appname))}</div></details>"
                  for q, a in FAQ)
    body = head("HELP &middot; FAQ", app=appname, category=category) + f"""
    <div class="rule"></div>
    <h2>Quick start</h2>
    <ul>
      <li>Drag a folder onto the window, or press <b>⌘O</b>. Each review opens in its own window.</li>
      <li>Click a tile to enlarge it; use <b>← / →</b> to move between files.</li>
      <li>Mark each item <b>Keep</b>, <b>Trash</b>, or <b>Unsure</b> with the on-tile buttons or the keyboard.</li>
      <li><b>Smart Select</b> pre-marks the likely extras for you to review. Nothing is deleted without a confirm.</li>
    </ul>
    <h2>Keyboard</h2>
    <div class="card"><div style="padding:14px 2px"><div class="kbd">
      <b>⌘O</b><span>Open a folder</span>
      <b>⌘N</b><span>New window</span>
      <b>F / Enter</b><span>Toggle full view of the focused file</span>
      <b>← →</b><span>Previous / next file</span>
      <b>↑ ↓</b><span>Page through a multi-page PDF or deck</span>
      <b>⌘ +/−/0</b><span>Thumbnail size</span>
      <b>⌘,</b><span>Settings</span>
    </div></div></div>
    <h2>The three ways to look</h2>
    <ul>
      <li><b>Grid</b> — a fast, true-aspect wall of everything, with burst groups and filters.</li>
      <li><b>Compare a set</b> — files side by side to pick the best (File → Open Folder As).</li>
      <li><b>Sort a pile</b> — one at a time, keep / trash / unsure.</li>
    </ul>
    <h2>Frequently asked</h2>
    {faq}
    <div class="foot">{html.escape(appname)} {html.escape(version)} &middot; Help grows with the app.</div>"""
    return shell(f"{appname} Help", body)


def _opt(value, current, label):
    sel = " selected" if str(value) == str(current) else ""
    return f'<option value="{html.escape(str(value))}"{sel}>{html.escape(label)}</option>'


def _mcp_section(mcp):
    # "Works with your AI" — the local MCP connector card. mcp is the status dict
    # MCPConnector.status() reports (server_bundled / claude_app_present / registered /
    # stale_entry / config_unreadable), passed through from Swift as JSON.
    mcp = mcp or {}
    unreadable = bool(mcp.get("config_unreadable"))
    bundled = mcp.get("server_bundled", True)
    claude_present = bool(mcp.get("claude_app_present"))
    registered = bool(mcp.get("registered"))
    stale = bool(mcp.get("stale_entry"))
    if unreadable:
        pill, btn = '<span class="pill off">Needs attention</span>', ""
        line = ("Claude Desktop&rsquo;s connector file can&rsquo;t be read right now, so connecting would "
                "risk your other connectors. Check it by hand, then reopen Settings.")
    elif not bundled:
        pill, btn = '<span class="pill off">Unavailable</span>', ""
        line = "This build is missing the connector. Update PageHawk and try again."
    elif not claude_present:
        pill, btn = '<span class="pill off">Not installed</span>', ""
        line = "Claude Desktop isn&rsquo;t installed on this Mac. Install it, then connect from here."
    elif registered:
        pill = '<span class="pill on">Connected</span>'
        btn = '<a class="btn ghost" href="hvniz://mcpdisconnect">Disconnect</a>'
        line = ("Claude can list, search, and quote from the documents PageHawk has indexed on this Mac. "
                "Read-only &mdash; your documents stay on this Mac.")
    elif stale:
        pill = '<span class="pill off">Needs reconnect</span>'
        btn = '<a class="btn" href="hvniz://mcpconnect">Reconnect</a>'
        line = "Connected to an older build of PageHawk. Reconnect to point it at this one."
    else:
        pill = '<span class="pill off">Not connected</span>'
        btn = '<a class="btn" href="hvniz://mcpconnect">Connect</a>'
        line = ("Let Claude Desktop list, search, and quote from the documents PageHawk has indexed on "
                "this Mac. Read-only &mdash; your documents stay on this Mac.")
    return (f'<div class="card"><div class="row"><div><div class="lab">Claude Desktop {pill}</div>'
           f'<div class="hint">{line}</div></div>{btn}</div></div>')


def _sealhawk_section(sh, appname="PageHawk"):
    # "Connected account" — the SealHawk contract-library link. `sh` is the dict
    # SealHawkPairing.statusJSONObject() reports (connected / device_name / scopes /
    # can_archive / paired_at / last_synced), passed through from Swift as JSON.
    #
    # The boundary sentence is not decoration. A user is being asked to join two products; the one
    # thing they want to know is what the join can DO, and the honest answer is small: it reads
    # agreements and can never send anything in their name. That is enforced server-side and pinned
    # by SealHawk's tests, so saying it here is a true statement, not a promise we are keeping.
    sh = sh or {}
    boundary = ("Reading only: it can <b>see your agreements</b>, and <b>cannot send anything in "
                "your name</b>.")
    if not sh.get("connected"):
        return (f'<div class="card"><div class="row"><div>'
                f'<div class="lab">SealHawk <span class="pill off">Not connected</span></div>'
                f'<div class="hint">Connect the contract library you sign into, and the agreements '
                f'that need attention show up right here &mdash; &ldquo;this NDA auto-renews in 30 days, '
                f'notice due by the 14th&rdquo; &mdash; with the executed PDF one click away. '
                f'In SealHawk, open <b>Library &rarr; Connected devices &rarr; Generate a connection '
                f'code</b>, then enter that code here. {boundary} '
                f'{html.escape(appname)} is complete without it, and stays that way: this adds a '
                f'shelf, it never gates a feature.</div></div>'
                f'<a class="btn" href="hvniz://shconnect">Connect</a></div></div>')
    device = html.escape(sh.get("device_name") or "This Mac")
    # Swift hands over a phrase, never a stamp: "just now", "6 minutes ago", "at 5:14pm". Belt and
    # braces, in the spirit of _scrub() in pagehawk_campaigns.py: if a machine stamp ever arrives
    # anyway, drop the line rather than show a person "2026-07-26T22:14:00Z".
    synced = sh.get("last_synced") or ""
    if re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", str(synced)):
        synced = ""
    can = "Reads your agreements"
    if sh.get("can_archive"):
        can += " &middot; can archive documents to your library"
    synced_line = f' &middot; last synced {html.escape(synced)}' if synced else ""
    return (f'<div class="card">'
            f'<div class="row"><div>'
            f'<div class="lab">Connected as &ldquo;{device}&rdquo; <span class="pill on">Connected</span></div>'
            f'<div class="hint">{can}{synced_line}. {boundary}<br>'
            f'Disconnect here clears this Mac&rsquo;s connection. You can also revoke it in '
            f'SealHawk &rarr; Library &rarr; Connected devices, which is the authoritative list.</div></div>'
            f'<a class="btn ghost" href="hvniz://shdisconnect">Disconnect</a></div>'
            f'<div class="row"><div><div class="lab">Agreements needing attention</div>'
            f'<div class="hint">Renewal and notice deadlines your library is watching, in a window you '
            f'can open any time from the {html.escape(appname)} menu.</div></div>'
            f'<a class="btn ghost" href="hvniz://agreements">Open</a></div></div>')


# ── Full Disk Access: the branded permission panel (v0.63.2) ──────────────────────────────
# Replaces a plain NSAlert whose only art was the system caution badge and whose body was one
# 90-word paragraph. The four steps of the permission concierge are all here and visible:
# DETECT (the live pill), EXPLAIN (one line tied to what it buys), DEEP-LINK (one gold button
# straight to Privacy_AllFiles), VERIFY (Swift polls and calls __fdaGranted(), so the window
# turns itself green instead of asking the person whether it worked).
def fda_panel(appname="PageHawk", category="Document Studio", granted=False, icon=""):
    mark = (f'<img class="hero" src="{html.escape(icon)}" alt="{html.escape(appname)}">'
            if icon else "")
    # The app's own mark carries the identity here, so the panel uses a QUIET wordmark: name and
    # category only, no third tagline line. Three stacked centered lines above the headline pushed
    # the button and the "what this grants" card below the fold of a 660pt window.
    wordmark = (f'<div class="mark">{appname}</div>'
                + (f'<div class="markdv">{category}</div>' if category else ""))
    steps = "".join(
        f'<li><span class="n">{i}</span><span>{t}</span></li>' for i, t in enumerate([
            "Press <b>Open Full Disk Access</b> below. macOS jumps straight to the right list.",
            f"Switch <b>{html.escape(appname)}</b> on. It is already in the list, waiting.",
            "Come back here. This window updates itself the moment it lands.",
        ], 1))
    on_html = f"""
    {mark}
    {wordmark}
    <div class="fhead"><h1>Full Disk Access is on</h1>
      <span class="pill on" id="fpill">On</span></div>
    <p class="lede">{html.escape(appname)} opens Downloads, Documents, Dropbox and anywhere else
      you keep files the moment you ask for it.</p>
    <div class="card"><div class="row"><div><div class="lab">Change your mind any time</div>
      <div class="hint">System Settings &rsaquo; Privacy &amp; Security &rsaquo; Full Disk Access</div></div>
      <a class="btn ghost" href="hvniz://fdaopen">Open the list</a></div></div>
    <div class="acts"><a class="btn" href="hvniz://closepanel">Done</a></div>"""
    off_html = f"""
    {mark}
    {wordmark}
    <div class="fhead"><h1>Open every folder at full speed</h1>
      <span class="pill off" id="fpill">Off</span></div>
    <p class="lede">One switch and {html.escape(appname)} reads Downloads, Documents, Dropbox and
      the rest of your library the instant you open a file. Today macOS asks about each folder
      the first time you visit it.</p>
    <ol class="steps">{steps}</ol>
    <div class="acts"><a class="btn" href="hvniz://fdaopen" id="fbtn">Open Full Disk Access</a>
      <a class="btn ghost" href="hvniz://closepanel">Not now</a>
      <span class="watch" id="fwatch">Watching for the switch&hellip;</span></div>
    <div class="card"><div class="row"><div><div class="lab">What this grants</div>
      <div class="hint">Broad read access to your files, the same reach Finder has. It is optional
      and reversible, and every folder you open by hand keeps working exactly as it does now.</div>
      </div></div></div>
    <div class="foot">Your files stay on this Mac. {html.escape(appname)} reads them to show them
      to you and sends nothing anywhere.</div>"""
    body = f"""<style>
    /* the icns already carries the macOS squircle and its own transparent margin, so a
       border-radius + box-shadow here painted a second, larger rounded plate behind it. */
    .hero{{display:block;width:84px;height:84px;margin:0 auto 6px}}
    .fhead{{display:flex;align-items:center;justify-content:center;gap:11px;margin-top:18px}}
    .fhead h1{{font-family:Cambria,'Hoefler Text',Georgia,serif;font-weight:500;font-size:23px;
      margin:0;letter-spacing:.2px;color:{INK}}}
    .lede{{text-align:center;font-size:14.5px;color:#4A463C;margin:12px auto 4px;max-width:520px}}
    .steps{{list-style:none;margin:18px auto 4px;padding:0;max-width:520px}}
    .steps li{{display:flex;align-items:flex-start;gap:12px;padding:7px 0;color:#4A463C;font-size:13.5px}}
    .steps .n{{flex:none;width:23px;height:23px;border-radius:50%;background:{GOLD};color:#23262A;
      font:600 12px/23px -apple-system,sans-serif;text-align:center}}
    .acts{{display:flex;align-items:center;justify-content:center;gap:11px;margin:18px 0 4px;flex-wrap:wrap}}
    /* {MUTE} on the ivory paper is 3.47:1, under the AA floor for 11.5px text; #6E664F is the
       same warm grey at 5.3:1 (the value this file's shared CSS already uses for muted labels). */
    .watch{{font-size:11.5px;color:#6E664F;opacity:0;transition:opacity .2s;width:100%;text-align:center}}
    .watch.on{{opacity:1}}
    /* Dark theme: these classes are new, so the shared dark block above does not cover them and
       ink-on-charcoal would ship straight through it (contrast-legibility-guardrail). */
    @media (prefers-color-scheme:dark){{
      .fhead h1{{color:#F0EBE1}}
      .lede,.steps li{{color:#B9B3A6}}
      .watch{{color:#9A9690}}
    }}
    </style>{on_html if granted else off_html}
    <script>
    // Called by the app the moment the switch is detected (VERIFY step). Nobody has to tell us.
    window.__fdaGranted=function(){{
      var p=document.getElementById('fpill');
      if(p){{p.className='pill on';p.textContent='On';}}
      var h=document.querySelector('.fhead h1'); if(h) h.textContent='Full Disk Access is on';
      var l=document.querySelector('.lede');
      if(l) l.textContent='Every folder opens the moment you ask for it. You are all set.';
      var s=document.querySelector('.steps'); if(s) s.remove();
      var w=document.getElementById('fwatch'); if(w) w.remove();
      var b=document.getElementById('fbtn');
      if(b){{b.textContent='Done';b.setAttribute('href','hvniz://closepanel');}}
      // "Not now" is an answer to a question that has just been answered.
      var g=document.querySelector('.acts .btn.ghost'); if(g) g.remove();
    }};
    window.__fdaWatching=function(){{
      var w=document.getElementById('fwatch'); if(w) w.classList.add('on');
    }};
    </script>"""
    return shell(f"{appname} · Full Disk Access", body)


SEV_LABEL = {"act": ("on", "Act now"), "watch": ("off", "Watch")}


def agreements(items, connected=True, appname="PageHawk", version="1.0.0",
               category="Document Studio", error=""):
    """The payoff surface: SealHawk's lifecycle items, in the reader.

    Every row is one deadline SealHawk computed by deterministic arithmetic from the agreement's own
    terms, so the sentence shown here is the library's, not a paraphrase. `item_id` (the AGREEMENT),
    never the action id, is what Open passes back.
    """
    body = head("AGREEMENTS &middot; NEEDING ATTENTION", app=appname, category=category)
    body += '<div class="rule"></div>'
    if error:
        body += (f'<div class="card"><div class="row"><div><div class="lab">Couldn&rsquo;t load your agreements</div>'
                 f'<div class="hint">{html.escape(error)}</div></div>'
                 f'<a class="btn ghost" href="hvniz://settings">Open Settings</a></div></div>')
    elif not connected:
        body += ('<div class="card"><div class="row"><div><div class="lab">Not connected</div>'
                 '<div class="hint">Connect a SealHawk contract library in Settings, and the renewal and '
                 'notice deadlines it is watching appear here.</div></div>'
                 '<a class="btn" href="hvniz://settings">Open Settings</a></div></div>')
    elif not items:
        body += ('<div class="card"><div class="row"><div><div class="lab">Nothing needs attention</div>'
                 '<div class="hint">No agreement in your library has a renewal or notice deadline coming up. '
                 'This list fills itself as terms approach.</div></div></div></div>')
    else:
        rows = []
        for it in items:
            sev = str(it.get("severity") or "watch")
            cls, label = SEV_LABEL.get(sev, ("off", sev.title() or "Watch"))
            due = html.escape(str(it.get("due_date") or ""))
            due_line = f' &middot; due {due}' if due else ""
            item_id = str(it.get("item_id") or "")
            open_btn = (f'<a class="btn ghost" href="hvniz://openagreement?id={urllib.parse.quote(item_id)}">Open</a>'
                        if item_id else "")
            rows.append(
                f'<div class="row"><div>'
                f'<div class="lab">{html.escape(str(it.get("title") or "Untitled agreement"))} '
                f'<span class="pill {cls}">{label}</span></div>'
                f'<div class="hint">{html.escape(str(it.get("message") or ""))}{due_line}</div></div>'
                f'{open_btn}</div>')
        body += '<div class="card">' + "".join(rows) + '</div>'
        body += ('<div class="hint" style="text-align:center;margin:10px 0 0">Open downloads the executed PDF '
                 'from your library and opens it here for reading.</div>')
    body += (f'<div class="foot">{html.escape(appname)} {html.escape(version)} &middot; '
             f'Deadlines come from your SealHawk library; SealHawk owns the record.</div>')
    return shell(f"{appname} · Agreements", body)


def _default_apps_section(appname, groups, never):
    """Settings > Default apps: the honest mirror of Launch Services.

    Rows are built from the app's OWN Info.plist (Swift's defaultGroupsJSON), so this list can
    never claim a type the bundle does not declare — macOS would refuse it anyway.

    ONE-WAY BY DESIGN: every row offers "Make default" but none offers "turn off". macOS gives an
    app an API to BECOME the default and none to relinquish it; "off" means giving the type to a
    different app, which is the user's call to make in Finder, not ours to fake. A switch that
    silently failed to turn off would be a lie, so an owned type reads as a plain "Default" pill
    plus the exact Finder path to change it. (Verified 2026-07-15: setting a *different* app as
    default raises macOS's own consent dialog and fails with userCanceledErr when unattended.)
    """
    n = html.escape(appname)
    rows = []
    on_count = sum(1 for g in groups if g.get("on"))
    total = len(groups)
    for g in groups:
        name, on, exts = html.escape(g.get("name", "")), bool(g.get("on")), html.escape(g.get("exts", ""))
        pill = '<span class="pill on">Default</span>' if on else '<span class="pill off">Off</span>'
        act = ("" if on else
               f'<a class="btn ghost dfbtn" href="hvniz://setdefaultgroup?name={urllib.parse.quote(g.get("name",""))}">Make default</a>')
        rows.append(
            f'<div class="row"><div><div class="lab">{name} {pill}</div>'
            f'<div class="hint">{exts}</div></div>{act}</div>')
    all_on = (total > 0 and on_count == total)
    if all_on:
        head = (f'<div class="row"><div><div class="lab">Every supported type '
                f'<span class="pill on">Default</span></div>'
                f'<div class="hint">{n} opens every document type it supports. To hand one back to '
                f'another app: Get Info on a file &rarr; Open with &rarr; Change All.</div></div></div>')
    else:
        head = (f'<div class="row"><div><div class="lab">Make {n} the default for everything</div>'
                f'<div class="hint">Sets all {total} types at once ({on_count} of {total} already set). '
                f'macOS may ask you to confirm, and this takes a few seconds per type.</div></div>'
                f'<a class="btn dfbtn" href="hvniz://setdefaultall" id="setall">Set all</a></div>')
    undo = ("" if not never else
            f'<div class="row"><div><div class="lab">Reminders off</div>'
            f'<div class="hint">You asked {n} not to remind you about default apps. It will not ask again.</div></div>'
            f'<a class="btn ghost dfbtn" href="hvniz://defaultneverundo">Turn reminders back on</a></div>')
    return (f'<style>.dfbtn{{white-space:nowrap;flex:none}}</style><div class="card">{head}{"".join(rows)}{undo}</div>'
            f'<script>(function(){{var b=document.getElementById("setall");if(b)b.onclick=function(){{'
            f'b.textContent="Setting\\u2026";b.style.pointerEvents="none";}};'
            f'window.__phkDefaultProgress=function(h,t){{if(b)b.textContent="Setting "+h+"/"+t+"\\u2026";}};}})();</script>')


# ── Settings ▸ Account, and Settings ▸ Devices & Sync ───────────────────────────────────────
# Both were added 2026-08-28, and both exist because of one measured failure. PageHawk on Jason's
# two Macs had generated two SEPARATE libraries (171 documents on one, 21 on the other) and there
# was no surface anywhere in the app that said so. The whole account control was a single item
# buried in the app menu; the pairing code was the eighth heading down a nine-heading General tab,
# under "Default apps", and described only as an iPhone feature. His words: "it's not clear how to
# log in and make sure that you're connected to the Epiphani account so that it synchronizes files."
#
# So identity and sync each get a NAMED PLACE IN THE RAIL, under their own ACCOUNT group heading,
# and the sync pane's first job is to state the standing out loud rather than leave a person to
# work it out from documents that never arrive.

def _account_section(acct, appname="PageHawk"):
    """Who this Mac is signed in as, and the one button that changes it."""
    signed = bool(acct.get("signedIn"))
    email = (acct.get("email") or "").strip()
    pro = acct.get("pro")   # "active" | "inactive" | absent. ABSENT IS NOT "inactive".
    if signed:
        who = html.escape(email) if email else "Signed in"
        ident = ('<div class="row"><div><div class="lab">' + who + '</div>'
                 '<div class="hint">Your Epiphani account. One sign-in covers every Epiphani app. It is what '
                 'lets you publish a document to share and what carries your purchases; your '
                 'library travels between devices separately, on Devices &amp; Sync.</div></div>'
                 '<a class="btn ghost" href="hvniz://account">Sign out</a></div>')
    else:
        ident = ('<div class="row"><div><div class="lab">Sign in to your Epiphani account</div>'
                 '<div class="hint">Signing in lets you publish a document to share and carries anything you '
                 'have bought. It opens your browser once; nothing leaves this Mac until you ask '
                 'for it.</div></div>'
                 '<a class="btn" href="hvniz://account">Sign in</a></div>')
    # The Pro row is RENDERED ONLY WHEN WE KNOW. Signed out, never fetched, or offline on a fresh
    # account all read as "unknown", and a blank is honest where "Not active" would be a guess
    # printed as fact. Same rule the app menu's Pro row already follows.
    prorow = ""
    if pro == "active":
        prorow = ('<div class="row"><div><div class="lab">PageHawk Pro</div>'
                  '<div class="hint">Active on this account.</div></div>'
                  '<span class="pill on">Active</span></div>')
    elif pro == "inactive":
        prorow = ('<div class="row"><div><div class="lab">PageHawk Pro</div>'
                  '<div class="hint">Unlimited sharing and the full toolset.</div></div>'
                  '<a class="btn ghost" href="https://pagehawk.com/#pricing">See Pro</a></div>')
    return '<div class="card">' + ident + prorow + '</div>'


def _sync_section(sync, pair_row):
    """Whether this Mac shares a library with the rest of the account, said in one sentence.

    `standing` comes from the shared iCloud-Keychain beacon (main.swift MacPairBeacon), so it is a
    statement about the ACCOUNT, not about this Mac in isolation. "split" is the state that ran
    silently for months: this Mac has a library, the account has a different one, and the two never
    meet. It is the only one that gets a banner, and the banner carries the fix rather than
    describing it.
    """
    standing = sync.get("standing") or "none"
    if standing == "split":
        note = ('<div class="snote"><b>This Mac is on its own library.</b> Another of your devices '
                'is on the same iCloud account but reading a different library, which '
                'is why documents you open there do not show up here. Joining it fixes that. '
                'Anything this Mac already uploaded stays in its own library.'
                '<div class="acts"><a class="btn" href="hvniz://joinlibrary">Use my other device&rsquo;s library</a>'
                '<a class="btn ghost" href="hvniz://publishlibrary">Use this Mac&rsquo;s instead</a></div></div>')
    elif standing == "joined":
        note = ('<p class="psub"><span class="sok">&#10003; Connected.</span> This Mac shares one '
                'library with your other devices, so a document you open here is on your iPhone at '
                'the same page, and the page you reached there comes back to this Mac.</p>')
    elif standing == "alone":
        note = ('<p class="psub">This Mac is the first device on your account. Every device you add '
                'from here joins the library below.</p>')
    else:
        note = ('<p class="psub">This Mac has no library yet. Open any document once and PageHawk '
                'creates one, then this is where you connect your other devices to it.</p>')
    return note + (
    '<div class="card">'
      '<div class="row"><div><div class="lab">This Mac&rsquo;s library code</div>'
        '<div class="hint">The one credential that joins devices. Type it into PageHawk on another '
        'device and the two become one library. It is also the key your documents are encrypted '
        'with before they leave this Mac, so treat it the way you would a password: nobody without '
        'it can read them, and that includes us.</div></div>'
        + pair_row + '</div>'
      '<div class="row"><div><div class="lab">Add an iPhone or iPad</div>'
        '<div class="hint">Shows the code as a QR you can scan with the phone&rsquo;s camera. An iPhone or '
        'iPad on the same iCloud account usually finds this library on its own.</div></div>'
        '<a class="btn ghost" href="hvniz://pairsheet">Show code&hellip;</a></div>'
      '<div class="row"><div><div class="lab">Add another Mac</div>'
        '<div class="hint">Install PageHawk on the other Mac, signed in to the same iCloud account as '
        'this one, and it joins this library by itself. If that Mac had already started a library '
        'of its own, its Devices &amp; Sync screen offers to switch it over. On a different iCloud '
        'account, type the code above into it instead.</div></div>'
        '</div>'
    '</div>')


def settings(cfg, version="1.0.0", fda=False, iconsdir="", appname="PageHawk", category="Document Studio", defaultpdf=False, voices=None, defaultgroups=None, defaultnever=False, mcp_status=None, sealhawk_status=None, finder_ext=False, nightwindow="", account_status=None, sync_status=None, open_palette_live=""):
    account_status = account_status or {}
    sync_status = sync_status or {}
    defaultgroups = defaultgroups or []
    depth = cfg.get("max_depth", 2)
    depth = -1 if depth is None else depth
    viewer = cfg.get("viewer", "app")
    open_in = cfg.get("open_in", "tab")
    # DEFAULT ON (Jason 2026-08-09: "default to remember the list"). Off also DELETES the file,
    # because leaving a list of documents on disk after someone asks not to remember them is the
    # opposite of what they asked for.
    remember_tabs = cfg.get("remember_closed_tabs", True)
    # v2 key (2026-08-04). The DEFAULT is "review" — the library — because the dialog that used to
    # ask this question is gone (Jason: "That is the better path than this pop-up"). Two documents
    # open in the library and its Compare button does the rest. The old two-way key is
    # intentionally not read; it answered a different question.
    two_file = cfg.get("two_file_drop_action_v2", "review")
    # multi_file_drop_action is NO LONGER READ (spec 09 Part 1): three or more documents always
    # open as a library now, so there is no fork left to prefer and no honest row to render. The
    # stored value is left in place rather than deleted, because it is somebody's past answer and
    # the Studio is what should migrate it. A settings control for a question the app stopped
    # asking is worse than no control: it promises a choice that changes nothing.
    # Config KEYS keep their historical *_drop_action names on purpose: renaming them would
    # strand every saved preference. Only the user-facing copy says "open" (v0.63.10, when
    # selecting several files in Finder and pressing Return began reaching these forks too).
    appearance = cfg.get("appearance", "light")   # match the engine default (main.swift appearancePref); "auto" here made Settings show "Follow System" while the app ran Light
    # ── Night mode (2026-08-22) ──────────────────────────────────────────────────────────────
    # Defaults match nightCfg() in main.swift exactly. They have to: a panel that renders a
    # different default from the engine's shows a setting the app is not running, which is the
    # same defect the appearance line above already carries a comment about.
    # "auto", matching the launch migration in main.swift. A panel that renders a different
    # default from the engine shows a setting the app is not running.
    night_mode = cfg.get("night_mode", "auto")
    night_from = cfg.get("night_from", "sunset")
    night_from_time = cfg.get("night_from_time", "20:00")
    night_until = cfg.get("night_until", "sunrise")
    night_until_time = cfg.get("night_until_time", "07:00")
    night_pages = bool(cfg.get("night_pages", True))
    # "system" is the default on purpose (Jason 2026-08-30, REQ-20260830-001): documents follow
    # the Mac's light/dark, and nothing is remembered per file unless he opts into it.
    reading_theme_files = cfg.get("reading_theme_files", "system")
    # The window in words, computed by the ENGINE (main.swift -> PhkNight.windowLabel) and passed
    # in, so this panel never runs a second copy of the solar arithmetic that could drift from it.
    night_line = nightwindow or ""
    ignore = bool(cfg.get("ignore_archive_folders", True))
    menubar = bool(cfg.get("menu_bar_icon", False))
    pageops_confirm = bool(cfg.get("pageops_confirm", True))   # P1 #4: ask before changing PDF files (default on)
    default_zoom = cfg.get("reader_default_zoom", "page-width") or "page-width"   # v0.45 item 15
    zoom_with_win = bool(cfg.get("reader_zoom_with_window", True))   # v0.49: re-fit zoom on window resize, default on
    save_mode = cfg.get("pdf_save_mode", "inplace") or "inplace"                  # v0.45 item 15: in place vs a copy
    copy_suffix = cfg.get("pdf_copy_suffix", "(edited)") or ""                    # v0.45 item 15: output naming
    sign_ink = cfg.get("sign_default_ink", "black") or "black"                    # v0.45 item 15: signature default ink
    outdir = cfg.get("output_copy_dir", "") or ""
    sr_backend = cfg.get("smart_rename_backend", "auto") or "auto"   # P2 Smart Rename backend
    sr_key = cfg.get("smart_rename_api_key", "") or ""               # P2 BYO API key (optional upgrade)
    # ── The ⌘O Open surface: two choices, and they are SETTINGS ──────────────────────────────
    # Jason ran a taste ballot on 2026-08-19 (five layouts × four palettes) and picked L2 "Covers
    # first" and P4 "Cool slate", with a standing rule attached to the picks, verbatim: "I believe
    # that we should build multiple many of these options and allow a user to change these in the
    # settings for both."
    #
    # The defaults here are HIS PICKS and the option lists are every shape the ballot drew. Both
    # halves are checked against main.swift's OpenSurface unions by tools/verify-open-surface.mjs:
    # a shape declared there that this pane does not offer is a shape nobody can reach, which is
    # the exact failure the rule exists to prevent.
    open_layout = cfg.get("open_layout", "covers") or "covers"
    # THE ENGINE DECIDES THIS, NOT THIS FILE (fixed 2026-08-28). It used to read
    # cfg.get("open_palette", "slate") -- the 2026-08-19 ballot pick, pinned here as a literal.
    # main.swift's OpenSurface.palette() had since been changed to resolve an UNSTORED palette
    # against the page ground (cream in light, pagedark in dark) so the window and the page do not
    # show a seam, and nothing updated this line. The result was a screen that lied: with no
    # open_palette key -- which is the state Jason's own config was in -- Settings showed "Cool
    # slate" selected while the app rendered cream.
    #
    # `pagedark` is deliberately NOT offered as a choice (it is warmdark's palette standing on the
    # page landing's own ground, and it is never stored), so it has no row to select. It shows as
    # "Warm dark", which is the palette it actually uses, and the note under the control says the
    # ground is being matched automatically. Better a true statement plus a nuance than a false
    # selection.
    open_palette = (cfg.get("open_palette") or "").strip()
    palette_auto = not open_palette          # nobody has chosen; the engine is resolving it
    if palette_auto:
        open_palette = (open_palette_live or "cream").strip()
    palette_pagedark = (open_palette == "pagedark")
    if palette_pagedark:
        open_palette = "warmdark"
    palette_note = ""
    if palette_auto:
        palette_note = ('<div class="hint" style="padding:6px 4px 0">You have not picked one, so '
                        'PageHawk is matching the Open screen to the page you land on'
                        + (' &mdash; in dark it uses this palette on the page&rsquo;s own ground.'
                           if palette_pagedark else '.')
                        + ' Choosing any palette here keeps it in both light and dark.</div>')
    statusbar = bool(cfg.get("reader_statusbar", True))       # v0.42: bottom status bar, default on
    edit_warn = bool(cfg.get("edit_fidelity_warn", True))     # v0.55.1: ask before edits/saves that strip document extras
    pair_code = (cfg.get("pair_code") or "").strip()          # cross-device pairing code (config-owned; also in the PageHawk menu)
    pair_grouped = (pair_code[:4] + "-" + pair_code[4:]) if len(pair_code) == 8 else pair_code
    if pair_code:
        _copyjs = ("(function(){var r=document.createRange();r.selectNodeContents(document.getElementById('paircode'));"
                   "var s=getSelection();s.removeAllRanges();s.addRange(r);document.execCommand('copy');s.removeAllRanges();})();return false")
        pair_row = ('<div style="display:flex;align-items:center;gap:10px">'
                    '<code id="paircode" style="font:700 20px ui-monospace,Menlo,monospace;letter-spacing:3px;padding:8px 14px;'
                    'border:1.5px solid var(--accent,#C8A872);border-radius:10px;background:rgba(200,168,114,.12);user-select:all">'
                    + html.escape(pair_grouped) + '</code>'
                    '<a class="btn ghost" href="#" onclick="' + html.escape(_copyjs, quote=True) + '">Copy</a></div>')
    else:
        pair_row = ('<div class="hint">Open any document once (or use the PageHawk menu &rarr; '
                    'Pair with iPhone&hellip;) to create this Mac&rsquo;s code.</div>')
    cur_voice = cfg.get("tts_voice", "auto") or "auto"        # v0.42: read-aloud voice ("auto" = best installed)
    voices = voices or []
    voice_opts = '<option value="auto"%s>Automatic (best installed)</option>' % (" selected" if cur_voice == "auto" else "")
    for v in voices:
        vid, vlabel = v.get("id", ""), v.get("label", "")
        if not vid: continue
        voice_opts += '<option value="%s"%s>%s</option>' % (
            html.escape(vid, quote=True), " selected" if vid == cur_voice else "", html.escape(vlabel))
    chk = lambda b: " checked" if b else ""
    faq = "".join(f"<details><summary>{html.escape(q)}</summary><div class='a'>{a}</div></details>" for q, a in FAQ)
    fda_pill = '<span class="pill on">On</span>' if fda else '<span class="pill off">Off</span>'
    # What the schedule decided, in words. Rendered only when night mode is on, because a status
    # line under a switch that is off is noise; and it is left EMPTY rather than guessed at when
    # the engine did not pass one, since a made-up sunset here would be worse than none.
    night_status = (f'<div class="hint" style="padding:6px 4px 2px">{html.escape(night_line)}</div>'
                    if night_mode != "off" and night_line else "")
    # The set-time box stays on screen when the anchor is the sun, because it is not dead: it is
    # the FALLBACK that carries the window where the sun does not set. Dimmed, so it cannot be
    # misread as the setting in force, and it says so on hover rather than only in the docs.
    # .7, not lower: MEASURED, because the eyeball would have shipped .6. Composited against each
    # theme's own field, .6 gives 3.91:1 in light (below the 4.5:1 AA floor) and 5.17:1 in dark;
    # .7 gives 5.27:1 and 6.50:1. contrast-legibility-guardrail.md — a dimmed control is still text.
    _dim = lambda fallback: "opacity:.7" if fallback else ""
    _timetip = lambda fallback: ("Used only if the sun cannot be worked out here (a polar season, "
                                 "or no time zone)." if fallback else "The time in force.")
    fda_btn = "" if fda else '<a class="btn ghost" href="hvniz://fda">Grant…</a>'
    # The Finder extension row. Unlike FDA it stays clickable when it is already ON, because the
    # wizard is also where somebody goes to READ what the extension can see and to turn it back
    # off — a permission you can only ever say yes to is not a permission.
    fx_pill = '<span class="pill on">On</span>' if finder_ext else '<span class="pill off">Off</span>'
    fx_btn = ('<a class="btn ghost" href="hvniz://finderext">Review…</a>' if finder_ext
              else '<a class="btn ghost" href="hvniz://finderext">Turn on…</a>')
    sh = cfg.get("shortcuts", {}) or {}
    scrows = ""
    for aid, label, default in SHORTCUTS:
        combo = sh.get(aid, default)
        scrows += (f'<div class="row"><div class="lab">{html.escape(label)}</div>'
                   f'<div class="scwrap"><span class="kbchip" data-action="{aid}" data-combo="{html.escape(combo)}" '
                   f'data-default="{html.escape(default)}" onclick="recKey(this)" title="Click, then press the keys"></span>'
                   f'<span class="screset" onclick="resetKey(this)" title="Reset to default">&#8634;</span></div></div>')
    cur_icon = cfg.get("app_icon", ICON_DEFAULT)
    _valid = {f"{g}_{t}_{b}" for g, _, _ in ICON_GROUPS for t, _ in ICON_TREATMENTS for b, _ in ICON_BACKDROPS}
    if cur_icon not in _valid: cur_icon = ICON_DEFAULT   # stale/old id -> highlight the default
    def _swatch(iid, label_html, title):
        thumb = os.path.join(iconsdir, iid + "_thumb.png") if iconsdir else ""
        b64 = ""
        if thumb and os.path.exists(thumb):
            try: b64 = base64.b64encode(open(thumb, "rb").read()).decode()
            except Exception: b64 = ""
        img = (f'<img alt="{html.escape(title)}" src="data:image/png;base64,{b64}">' if b64
               else '<div class="swph"></div>')
        sel = " sel" if iid == cur_icon else ""
        return (f'<div class="swatch{sel}" onclick="seticon(this,\'{iid}\')" title="{html.escape(title)}">'
                f'{img}<span class="ck">&#10003;</span><span>{label_html}</span></div>')
    icon_groups = ""
    for gkey, gname, gdesc in ICON_GROUPS:
        sw = ""
        for tkey, tname in ICON_TREATMENTS:
            for bkey, bname in ICON_BACKDROPS:
                sw += _swatch(f"{gkey}_{tkey}_{bkey}", f"{tname}<br><em>{bname}</em>",
                              f"{gname}: {tname}, {bname} background")
        icon_groups += (f'<div class="igrp"><div class="igh">{gname} <em>{gdesc}</em></div>'
                        f'<div class="swgrid">{sw}</div></div>')
    # ── Rail glyphs ────────────────────────────────────────────────────────────────────────
    # Inline SVG rather than a glyph font or unicode. The panel ships as ONE self-contained file
    # loaded from file://, so it has no icon font to reach for, and a unicode pick that happens to
    # be missing from the system UI font renders as tofu on somebody else's Mac -- which is the
    # kind of defect that only shows up in a screenshot, after shipping. These inherit stroke from
    # `.snav svg`, so the selected row's icon turns gold with its label and needs no second rule.
    _svg = lambda d: ('<svg viewBox="0 0 16 16" aria-hidden="true">' + d + '</svg>')
    ic_general     = _svg('<path d="M2 4h12M2 8h12M2 12h12"/><circle cx="6" cy="4" r="1.6"/>'
                          '<circle cx="10" cy="8" r="1.6"/><circle cx="5" cy="12" r="1.6"/>')
    ic_opening     = _svg('<rect x="2" y="3" width="12" height="10" rx="1.6"/><path d="M6.5 3v10"/>')
    ic_editing     = _svg('<path d="M11.2 2.6l2.2 2.2L5.6 12.6 2.8 13.2l.6-2.8z"/>')
    ic_appearance  = _svg('<circle cx="8" cy="8" r="5.4"/><path d="M8 2.6v10.8"/>'
                          '<path d="M8 3.4a4.6 4.6 0 010 9.2z" fill="currentColor" stroke="none"/>')
    ic_shortcuts   = _svg('<rect x="1.8" y="4" width="12.4" height="8" rx="1.6"/>'
                          '<path d="M4.4 6.6h.01M7 6.6h.01M9.6 6.6h.01M5.2 9.4h5.6"/>')
    ic_permissions = _svg('<rect x="3.2" y="7" width="9.6" height="6.4" rx="1.5"/>'
                          '<path d="M5.6 7V5.2a2.4 2.4 0 014.8 0V7"/>')
    ic_account     = _svg('<circle cx="8" cy="5.6" r="2.6"/><path d="M2.9 13.4a5.1 5.1 0 0110.2 0"/>')
    ic_sync        = _svg('<path d="M2.6 6.4A5.6 5.6 0 0112 4.6"/><path d="M12.2 2.4v2.6h-2.6"/>'
                          '<path d="M13.4 9.6A5.6 5.6 0 014 11.4"/><path d="M3.8 13.6V11h2.6"/>')
    ic_help        = _svg('<circle cx="8" cy="8" r="5.8"/>'
                          '<path d="M6.3 6.3a1.75 1.75 0 013.4.6c0 1.2-1.7 1.4-1.7 2.6"/>'
                          '<path d="M8 11.6h.01"/>')
    railhead = (f'<div class="mark">{appname}</div>'
                + (f'<div class="markdv">{category}</div>' if category else ""))
    verline = html.escape(version)
    body = f"""
    <div class="setwrap">
    <div class="srail">
      <div class="srailtop">{railhead}</div>
      <div class="sgrp">Settings</div>
      <button class="snav" data-tab="general" onclick="showTab('general')">{ic_general}General</button>
      <button class="snav" data-tab="opening" onclick="showTab('opening')">{ic_opening}Opening</button>
      <button class="snav" data-tab="editing" onclick="showTab('editing')">{ic_editing}Editing &amp; PDF Tools</button>
      <button class="snav" data-tab="appearance" onclick="showTab('appearance')">{ic_appearance}Appearance</button>
      <button class="snav" data-tab="shortcuts" onclick="showTab('shortcuts')">{ic_shortcuts}Shortcuts</button>
      <button class="snav" data-tab="permissions" onclick="showTab('permissions')">{ic_permissions}Permissions</button>
      <div class="sgrp">Account</div>
      <button class="snav" data-tab="account" onclick="showTab('account')">{ic_account}Account</button>
      <button class="snav" data-tab="sync" onclick="showTab('sync')">{ic_sync}Devices &amp; Sync</button>
      <button class="snav" data-tab="help" onclick="showTab('help')">{ic_help}Help</button>
      <div class="srailfoot">{appname} {verline}<br><a href="hvniz://checkupdates">Check for updates</a></div>
    </div>
    <div class="spane">

    <div class="tabpanel" data-panel="general">
    <div class="ptitle">General</div>
    <p class="psub">How PageHawk finds your documents, and how it behaves while you read them.</p>
    <h2>Scanning</h2>
    <div class="card">
      <div class="row"><div><div class="lab">Folder depth</div>
        <div class="hint">How many sub-folder levels to include</div></div>
        <select onchange="setcfg('max_depth',this.value)">
          {_opt(0,depth,'Top level only')}{_opt(1,depth,'1 level')}{_opt(2,depth,'2 levels (default)')}{_opt(-1,depth,'Unlimited')}
        </select></div>
      <div class="row"><div><div class="lab">Ignore &ldquo;Archive&rdquo; folders</div>
        <div class="hint">Skip folders named archive, old, versions, etc.</div></div>
        <label class="sw"><input type="checkbox"{chk(ignore)} onchange="setcfg('ignore_archive_folders',this.checked?1:0)"><span class="sl"></span></label></div>
    </div>
    <h2>Behavior</h2>
    <div class="card">
      <div class="row"><div><div class="lab">Menu bar icon</div>
        <div class="hint">Quick access from the menu bar (takes effect right away)</div></div>
        <label class="sw"><input type="checkbox"{chk(menubar)} onchange="setcfg('menu_bar_icon',this.checked?1:0)"><span class="sl"></span></label></div>
      <div class="row"><div><div class="lab">Status bar in the reader</div>
        <div class="hint">Folder, file name, page number, and zoom along the bottom of every document window. Applies to open windows right away.</div></div>
        <label class="sw"><input type="checkbox"{chk(statusbar)} onchange="setcfg('reader_statusbar',this.checked?1:0)"><span class="sl"></span></label></div>
      <div class="row"><div><div class="lab">Ask before a save removes document extras</div>
        <div class="hint">Some documents carry content the editor cannot keep (tracked changes, comments, headers and footers, footnotes). When on, the editor shows a small notice while you work and asks once before a save overwrites the original, offering to save a copy instead. &ldquo;Don&rsquo;t ask again&rdquo; in that dialog switches this off; turn it back on here any time.</div></div>
        <label class="sw"><input type="checkbox"{chk(edit_warn)} onchange="setcfg('edit_fidelity_warn',this.checked?1:0)"><span class="sl"></span></label></div>
      <div class="row"><div><div class="lab">Read-aloud voice</div>
        <div class="hint">Higher-quality voices install in System Settings &rarr; Accessibility &rarr; Spoken Content &rarr; System Voice &rarr; Manage Voices, then appear here. &ldquo;Automatic&rdquo; always picks the best one installed.</div></div>
        <select onchange="setcfg('tts_voice',this.value)">{voice_opts}</select></div>
      <div class="row"><div><div class="lab">Also keep a copy of each review in</div>
        <div class="hint">Optional. A folder you choose, for the review windows you open. The app always keeps its own copy in the Reviews folder, so you can leave this blank.</div></div>
        <input type="text" value="{html.escape(outdir)}" placeholder="(blank, kept in Reviews folder)" onchange="setcfg('output_copy_dir',this.value)"></div>
    </div>
    <h2>Default apps</h2>
    {_default_apps_section(appname, defaultgroups, defaultnever)}
    </div>

    <div class="tabpanel" data-panel="opening">
    <div class="ptitle">Opening</div>
    <p class="psub">What you see when you press &#8984;O, and where a document lands once you pick it.
    These four questions used to sit in the middle of General, one screen away from the Open
    surface they decide the other half of.</p>
    <h2>Windows and tabs</h2>
    <div class="card">
      <div class="row"><div><div class="lab">Open reviews in</div>
        <div class="hint">This app, or a browser window</div></div>
        <select onchange="setcfg('viewer',this.value)">
          {_opt('app',viewer,'This app')}{_opt('browser',viewer,'Default browser')}{_opt('chrome-app',viewer,'Chrome (app window)')}
        </select></div>
      <div class="row"><div><div class="lab">Additional reviews open as</div>
        <div class="hint">A new tab in the current window, or a separate window. Tabs can be dragged off to sit side by side. (The empty welcome window always fills in place.)</div></div>
        <select onchange="setcfg('open_in',this.value)">
          {_opt('tab',open_in,'New tab')}{_opt('window',open_in,'New window')}
        </select></div>
      <div class="row"><div><div class="lab">When you open two documents</div>
        <div class="hint">They open as a library, where the Compare button already knows there are only two and puts them side by side on one click. Change it here if you would rather always compare straight away, or always open each one in its own tab.</div></div>
        <select onchange="setcfg('two_file_drop_action_v2',this.value)">
          {_opt('review',two_file,'Open as a library')}{_opt('compare',two_file,'Always compare')}{_opt('individual',two_file,'Always open individually')}
        </select></div>
      <div class="row"><div><div class="lab">Remember closed tabs after quitting</div>
        <div class="hint">Reopen a tab you closed with &#8679;&#8984;T, including after you quit or the app stops unexpectedly. PageHawk keeps the last 20, and only remembers where the document is, never what is in it. Turning this off forgets the list right away.</div></div>
        <label class="sw"><input type="checkbox"{chk(remember_tabs)} onchange="setcfg('remember_closed_tabs',this.checked?1:0)"><span class="sl"></span></label></div>
    </div>
    <h2>The Open screen</h2>
    <p class="hint" style="margin:0 0 4px">What &#8984;O shows you. Both of these change the Open screen only &mdash; Recents and Favorites keep the searchable list they already have.</p>
    <div class="card">
      <div class="row"><div><div class="lab">Layout</div>
        <div class="hint"><b>Covers first</b> shows each document&rsquo;s real first page, which tells six documents that share a name apart faster than their names do. <b>One list</b> is keystroke-first. <b>Preview beside the list</b> turns opening into a confirmation. <b>Sortable columns</b> is the densest, for a working library rather than a handful.</div></div>
        <select id="set-open-layout" onchange="setcfg('open_layout',this.value)">
          {_opt('covers',open_layout,'Covers first')}{_opt('live',open_layout,'Two lists (folders and documents)')}{_opt('list',open_layout,'One list, type to narrow')}{_opt('preview',open_layout,'Preview beside the list')}{_opt('columns',open_layout,'Sortable columns')}
        </select></div>
      <div class="row"><div><div class="lab">Palette</div>
        <div class="hint">The colors of the Open screen. Every one of these is measured for readable contrast in the text sizes this screen actually uses.</div></div>
        <select id="set-open-palette" onchange="setcfg('open_palette',this.value)">
          {_opt('slate',open_palette,'Cool slate')}{_opt('cream',open_palette,'Cream & gold')}{_opt('paper',open_palette,'Paper & ink')}{_opt('warmdark',open_palette,'Warm dark')}
        </select></div>
      {palette_note}
    </div>
    <div class="hint" style="text-align:center;margin:8px 0 0">A change shows up the next time you press &#8984;O.</div>
    </div>

    <div class="tabpanel" data-panel="shortcuts">
    <div class="ptitle">Shortcuts</div>
    <p class="psub">Every command with a key, and every one of them yours to change.</p>
    <h2>Keyboard shortcuts</h2>
    <div class="card">{scrows}</div>
    <div class="hint" style="text-align:center;margin:8px 0 0">Click a shortcut, then press the keys. Press Delete to remove it entirely. The arrow resets it to the default.</div>
    </div>

    <div class="tabpanel" data-panel="editing">
    <div class="ptitle">Editing &amp; PDF Tools</div>
    <p class="psub">What happens to a PDF when you change it, and the tools that do the changing.</p>
    <h2>PDF changes</h2>
    <div class="card">
      <div class="row"><div><div class="lab">Ask before changing PDF files</div>
        <div class="hint">Page tools that rotate, delete, duplicate or reorder pages edit the saved file. Keep this on to confirm each change first (each confirmation has a &ldquo;Don&rsquo;t ask me again&rdquo; option). Turn it off to apply page changes without asking.</div></div>
        <label class="sw"><input type="checkbox"{chk(pageops_confirm)} onchange="setcfg('pageops_confirm',this.checked?1:0)"><span class="sl"></span></label></div>
      <div class="row"><div><div class="lab">When you edit text or add markup</div>
        <div class="hint">Save changes into the same file, or always keep the original and write a new copy alongside it.</div></div>
        <select onchange="setcfg('pdf_save_mode',this.value)">
          {_opt('inplace',save_mode,'Save into the file')}{_opt('copy',save_mode,'Always save a copy')}
        </select></div>
      <div class="row"><div><div class="lab">Copy name ending</div>
        <div class="hint">Added to the file name when a copy is saved, e.g. &ldquo;Contract {html.escape(copy_suffix or '(edited)')}.pdf&rdquo;.</div></div>
        <input type="text" value="{html.escape(copy_suffix)}" placeholder="(edited)" onchange="setcfg('pdf_copy_suffix',this.value)"></div>
      <div class="row"><div><div class="lab">Reset &ldquo;Don&rsquo;t ask me again&rdquo; choices</div>
        <div class="hint">Bring back every page-change confirmation you previously dismissed.</div></div>
        <a class="btn ghost" href="hvniz://resetpageopskip">Reset</a></div>
    </div>
    <h2>Smart Rename &amp; Organize</h2>
    <div class="card">
      <div class="row"><div><div class="lab">AI engine</div>
        <div class="hint">How Smart Rename and Organize read your documents. <b>Automatic</b> uses Apple Intelligence on this Mac when available (nothing is uploaded, no download needed), then your API key, then Ollama. Everything a document says stays on your Mac unless you choose an API key.</div></div>
        <select onchange="setcfg('smart_rename_backend',this.value)">
          {_opt('auto',sr_backend,'Automatic (recommended)')}{_opt('foundation',sr_backend,'Apple Intelligence (on-device)')}{_opt('apikey',sr_backend,'My API key')}{_opt('ollama',sr_backend,'Ollama (local)')}
        </select></div>
      <div class="row"><div><div class="lab">API key (optional)</div>
        <div class="hint">A quality upgrade and the fallback on Macs without Apple Intelligence. Paste an OpenAI, Anthropic, or Google key: the provider is detected automatically, and the key is stored only on this Mac. Leave blank to stay fully on-device.</div></div>
        <input type="password" value="{html.escape(sr_key)}" placeholder="sk-… (optional)" onchange="setcfg('smart_rename_api_key',this.value)"></div>
    </div>
    <h2>Reading &amp; tools</h2>
    <div class="card">
      <div class="row"><div><div class="lab">Default zoom</div>
        <div class="hint">How every document first opens. &ldquo;Fit width&rdquo; fills the window; a percentage opens at that exact size.</div></div>
        <select onchange="setcfg('reader_default_zoom',this.value)">
          {_opt('page-width',default_zoom,'Fit width')}{_opt('page-fit',default_zoom,'Fit page')}{_opt('50',default_zoom,'50%')}{_opt('75',default_zoom,'75%')}{_opt('100',default_zoom,'100%')}{_opt('125',default_zoom,'125%')}{_opt('150',default_zoom,'150%')}{_opt('200',default_zoom,'200%')}
        </select></div>
      <div class="row"><div><div class="lab">Change zoom with window size</div>
        <div class="hint">Match the page width to the window and let it scroll up and down, so text is never cut off at the sides. Fit&nbsp;width tracks the window as you resize or move it; a manual zoom only re-fits when the window gets too narrow to show the page without clipping.</div></div>
        <label class="sw"><input type="checkbox"{chk(zoom_with_win)} onchange="setcfg('reader_zoom_with_window',this.checked?1:0)"><span class="sl"></span></label></div>
      <div class="row"><div><div class="lab">Signature ink</div>
        <div class="hint">The default ink color when you draw or type a signature.</div></div>
        <select onchange="setcfg('sign_default_ink',this.value)">
          {_opt('black',sign_ink,'Black')}{_opt('blue',sign_ink,'Blue')}
        </select></div>
      <div class="row"><div><div class="lab">Reset the toolbar</div>
        <div class="hint">Put every reader toolbar button back to its original place and visibility.</div></div>
        <a class="btn ghost" href="hvniz://resettoolbar">Reset toolbar</a></div>
    </div>
    </div>

    <div class="tabpanel" data-panel="permissions">
    <div class="ptitle">Permissions</div>
    <p class="psub">What macOS lets PageHawk reach, and where to change your mind.</p>
    <h2>Permissions</h2>
    <div class="card"><div class="row"><div><div class="lab">Full Disk Access {fda_pill}</div>
      <div class="hint">Open any folder without a per-folder prompt. Optional, reversible.</div></div>
      {fda_btn}</div>
    <div class="row"><div><div class="lab">Finder extension {fx_pill}</div>
      <div class="hint">Puts <b>Share with {appname}</b> and <b>Open in {appname}</b> in the right-click
      menu itself, instead of two levels down under Services. It only sees the items you right-click,
      and never reads what is inside them.</div></div>
      {fx_btn}</div></div>

    <h2 style="margin-top:18px">Software updates</h2>
    <div class="card"><div class="row"><div><div class="lab">Check for updates</div>
      <div class="hint">Version {html.escape(version)}. Checks automatically on launch and every
      6 hours, and installs silently; use this to check right now. Also in the Help menu and the
      {appname} menu.</div></div>
      <a class="btn" href="hvniz://checkupdates">Check for Updates Now</a></div></div>
    </div>

    <div class="tabpanel" data-panel="appearance">
    <div class="ptitle">Appearance</div>
    <p class="psub">Theme, the night schedule, and this app&rsquo;s icon on your Dock.</p>
    <h2>Theme</h2>
    <div class="card">
      <div class="row"><div><div class="lab">Light / Dark</div>
        <div class="hint">Choose Light or Dark, or Follow System so it tracks the device (which can switch by time of day). Also ⌘T to toggle, or the ◐ in a review.</div></div>
        <select onchange="setcfg('appearance',this.value)">
          {_opt('light',appearance,'Light')}{_opt('dark',appearance,'Dark')}{_opt('auto',appearance,'Follow System')}
        </select></div>
    </div>
    <h2>Night mode</h2>
    <div class="card">
      <div class="row"><div><div class="lab">Turn dark on at night</div>
        <div class="hint">Dark arrives on its own in the evening and leaves in the morning, across
        the whole app: windows, panels, the editor&rsquo;s page, and the documents themselves. Choose
        <b>Automatically at night</b> and it follows the schedule below; <b>Always on</b> keeps it dark
        at every hour. Either way <b>&#8984;T</b> still overrides it for tonight, and the schedule
        picks up again at the next change.</div></div>
        <select onchange="setcfg('night_mode',this.value)">
          {_opt('off',night_mode,'Off')}{_opt('auto',night_mode,'Automatically at night')}{_opt('on',night_mode,'Always on')}
        </select></div>
      <div class="row"><div><div class="lab">Dark from</div>
        <div class="hint">Sunset is worked out on this Mac from its time zone. Nothing is sent
        anywhere and no location permission is asked for.</div></div>
        <span style="display:flex;gap:8px;align-items:center">
        <select onchange="setcfg('night_from',this.value)">
          {_opt('sunset',night_from,'Sunset')}{_opt('time',night_from,'A set time')}
        </select>
        <input type="time" value="{html.escape(night_from_time)}" onchange="setcfg('night_from_time',this.value)"
               style="{_dim(night_from!='time')}" title="{_timetip(night_from!='time')}"></span></div>
      <div class="row"><div><div class="lab">Back to light at</div></div>
        <span style="display:flex;gap:8px;align-items:center">
        <select onchange="setcfg('night_until',this.value)">
          {_opt('sunrise',night_until,'Sunrise')}{_opt('time',night_until,'A set time')}
        </select>
        <input type="time" value="{html.escape(night_until_time)}" onchange="setcfg('night_until_time',this.value)"
               style="{_dim(night_until!='time')}" title="{_timetip(night_until!='time')}"></span></div>
      <div class="row"><div><div class="lab">Relight document pages</div>
        <div class="hint">The reason it is easier on the eyes rather than just darker around the
        edges: the pages themselves are relit, not only the window. The file is never changed. How
        a theme you pick by hand behaves is the next choice down. Off leaves pages in their true
        colors.</div></div>
        <label class="sw"><input type="checkbox"{chk(night_pages)} onchange="setcfg('night_pages',this.checked?1:0)"><span class="sl"></span></label></div>
      <div class="row"><div><div class="lab">Documents and light or dark mode</div>
        <div class="hint">Follow this Mac: pages go dark when the Mac does and come back to light in
        the morning, on every file, automatically; a theme you pick by hand lasts until the next
        switch. Remember per file: a theme you pick sticks to that document and always wins.</div></div>
        <select onchange="setcfg('reading_theme_files',this.value)">
          {_opt('system',reading_theme_files,'Follow this Mac')}{_opt('perfile',reading_theme_files,'Remember per file')}
        </select></div>
      {night_status}
    </div>
    <h2>App icon</h2>
    <div class="card">
      <div class="hint" style="padding:2px 4px 14px">Choose the Dock and menu-bar icon. Three marks (Stack, Compare, Lens), each in three treatments on a light gold or dark field. The selected mark also appears at the top of every review. The selected icon is highlighted with a check; the rest are dimmed. The installed app's Finder icon stays the build default.</div>
      {icon_groups}
    </div>
    </div>

    <div class="tabpanel" data-panel="account">
    <div class="ptitle">Account</div>
    <p class="psub">One Epiphani account covers every Epiphani app: it carries what you have bought
    and lets you publish a document to share. Which documents sync between your devices is a
    separate question, answered on Devices &amp; Sync.</p>
    {_account_section(account_status, appname)}
    <h2>Connected services</h2>
    <p class="psub">Other things this Mac is linked to. Separate from your Epiphani sign-in above:
    these are connections PageHawk makes on your behalf, and each is turned off from its own row.</p>
    {_sealhawk_section(sealhawk_status, appname)}
    {_mcp_section(mcp_status)}
    </div>

    <div class="tabpanel" data-panel="sync">
    <div class="ptitle">Devices &amp; Sync</div>
    <p class="psub">Your devices share one library: open a document on this Mac and it is on your
    iPhone at the same page, and the page you reach there comes back here. Devices find each other
    through your iCloud account, or by the code below. Documents are encrypted on this Mac before
    they go anywhere.</p>
    {_sync_section(sync_status, pair_row)}
    </div>

    <div class="tabpanel" data-panel="help">
    <div class="ptitle">Help</div>
    <p class="psub">The short version, and the questions people actually ask.</p>
    <h2>Quick start</h2>
    <ul>
      <li>Drag a folder onto the window, or press <b>⌘O</b>. Each review opens in its own window.</li>
      <li>Click a tile to enlarge it; use <b>← / →</b> to move between files.</li>
      <li>Mark each item <b>Keep</b>, <b>Trash</b>, or <b>Unsure</b>; nothing is deleted without a confirm.</li>
    </ul>
    <h2>Frequently asked</h2>
    {faq}
    <div class="hint" style="text-align:center;margin:10px 0 0">The full Help window (Help menu, or ⌘?) has the keyboard map and the three ways to look.</div>
    </div>

    <div class="foot"><a href="hvniz://help">Help &amp; FAQ</a> &nbsp;&middot;&nbsp;
      <a href="hvniz://about">About</a> &nbsp;&middot;&nbsp;
      <a href="hvniz://reveal-archive">Reviews folder</a><br>
      Changes save automatically.</div>
    </div>
    </div>"""
    return shell_rail(f"{appname} Settings", body)


def main():
    if len(sys.argv) < 3:
        sys.stderr.write("usage: panels.py KIND OUT [--config P] [--version V] [--fda 0|1] [--finderext 0|1] [--iconsdir D] [--icon PNG] [--app NAME] [--category CAT] [--mcpstatus JSON] [--sealhawkstatus JSON] [--accountstatus JSON] [--syncstatus JSON] [--openpalette NAME] [--agreements JSON] [--agreementserror MSG] [--nightwindow TEXT]\n"); return
    kind, out = sys.argv[1], sys.argv[2]
    cfg, version, fda, iconsdir = {}, "1.0.0", False, ""
    finder_ext = False   # Layer 2 Finder extension: enabled in System Settings? (host answers)
    icon = ""   # file:// path to the app's own mark, rendered by the host (v0.63.2 FDA panel)
    appname, category = "PageHawk", "Document Studio"
    defaultpdf = False
    defaultgroups, defaultnever = [], False
    voices = []
    mcp_status = {}
    sealhawk_status = {}
    account_status = {}   # Settings > Account: who this Mac is signed in as (host answers)
    sync_status = {}      # Settings > Devices & Sync: does this Mac share a library with the account?
    open_palette_live = ""   # Settings > Opening: the palette the ENGINE resolved (host answers)
    agreement_items, agreements_error = [], ""
    nightwindow = ""   # the night window in words, computed by the engine (PhkNight.windowLabel)
    a = sys.argv[3:]
    for i, t in enumerate(a):
        if t == "--config" and i + 1 < len(a):
            try: cfg = json.load(open(a[i + 1]))
            except Exception: cfg = {}
        elif t == "--version" and i + 1 < len(a): version = a[i + 1]
        elif t == "--fda" and i + 1 < len(a): fda = a[i + 1] == "1"
        elif t == "--finderext" and i + 1 < len(a): finder_ext = a[i + 1] == "1"
        elif t == "--iconsdir" and i + 1 < len(a): iconsdir = a[i + 1]
        elif t == "--icon" and i + 1 < len(a): icon = a[i + 1]
        elif t == "--app" and i + 1 < len(a): appname = a[i + 1]
        elif t == "--category" and i + 1 < len(a): category = a[i + 1]
        elif t == "--defaultpdf" and i + 1 < len(a): defaultpdf = a[i + 1] == "1"
        elif t == "--defaultnever" and i + 1 < len(a): defaultnever = a[i + 1] == "1"
        elif t == "--defaultgroups" and i + 1 < len(a):
            try: defaultgroups = json.loads(a[i + 1])
            except Exception: defaultgroups = []
        elif t == "--voices" and i + 1 < len(a):
            try: voices = json.loads(a[i + 1])
            except Exception: voices = []
        elif t == "--mcpstatus" and i + 1 < len(a):
            try: mcp_status = json.loads(a[i + 1])
            except Exception: mcp_status = {}
        elif t == "--sealhawkstatus" and i + 1 < len(a):
            try: sealhawk_status = json.loads(a[i + 1])
            except Exception: sealhawk_status = {}
        elif t == "--accountstatus" and i + 1 < len(a):
            try: account_status = json.loads(a[i + 1])
            except Exception: account_status = {}
        elif t == "--syncstatus" and i + 1 < len(a):
            try: sync_status = json.loads(a[i + 1])
            except Exception: sync_status = {}
        elif t == "--agreements" and i + 1 < len(a):
            try: agreement_items = json.loads(a[i + 1])
            except Exception: agreement_items = []
        elif t == "--agreementserror" and i + 1 < len(a): agreements_error = a[i + 1]
        elif t == "--nightwindow" and i + 1 < len(a): nightwindow = a[i + 1]
        elif t == "--openpalette" and i + 1 < len(a): open_palette_live = a[i + 1]
    if kind == "about": out_html = about(version, appname, category)
    elif kind == "fda": out_html = fda_panel(appname, category, fda, icon)
    elif kind == "help": out_html = help_(version, appname, category)
    elif kind == "agreements":
        out_html = agreements(agreement_items, bool(sealhawk_status.get("connected")),
                              appname, version, category, agreements_error)
    elif kind == "settings": out_html = settings(cfg, version, fda, iconsdir, appname, category, defaultpdf, voices, defaultgroups, defaultnever, mcp_status, sealhawk_status, finder_ext, nightwindow, account_status, sync_status, open_palette_live)
    else: out_html = about(version, appname, category)
    open(out, "w").write(out_html)


if __name__ == "__main__":
    main()
