theseus/chrome.html
Local Dev 22be28ed17 fix(theseus/aegis): retire bchwallet on every launch + branded dock/list icon
Two follow-ups from the on-device test.

Two Aegis addons showing up (bchwallet + aegis):
- migrateAegisRename previously only ran when addons/aegis/ didn't exist,
  which meant any bchwallet copy the signed OTA update endpoint reinstalls
  after the first migration stays there forever, and AddonHost loads both
  as separate wallets. Rewritten to always retire addons/bchwallet/ when
  it's present, regardless of whether aegis/ is already installed. The
  storage-copy (bchwallet.json → aegis.json) still only runs the first
  time so a downgrade doesn't clobber fresh 0.4+ state.
- Also flushes any stray addons/siawallet/ that comes back the same way.

Sidebar dock and Extensions list icons showed the raw 🛡 emoji:
- chrome.html's dock-button renderer and settings.html's extensions-list
  renderer now accept `data:image/svg+xml…` values for manifest.icon and
  render them as <img> instead of text. Emoji strings still render as
  before.
- aegis addon.json's icon is now the exact hex-aspis mark from
  aegis.x/brand/favicon.svg (URL-encoded inline). Version bumped to
  0.4.1 so seedBundledAddons reseeds the new addon.json on next launch.
2026-09-08 18:42:00 +02:00

1440 lines
84 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html>
<head><meta charset="utf-8">
<style>
:root {
color-scheme: light dark; font-family: system-ui, sans-serif;
--bg:#0f1420; --surface:#1b2330; --surface2:#171e2a; --active:#263041cc;
--ink:#e7eaf1; --mut:#b9c2d0; --dim:#8b98a9; --faint:#5e6678;
--line:rgba(255,255,255,.20); --line2:rgba(255,255,255,.13); --hover:rgba(255,255,255,.08);
/* Brand acid green. #d6ff3d reads ~10:1 on the dark ground; a darker
variant kicks in for light mode below so it stays legible. */
--acid:#d6ff3d;
}
@media (prefers-color-scheme: light) {
:root {
--bg:#e9ecf1; --surface:#ffffff; --surface2:#f1f3f7; --active:#dbe1ec;
--ink:#1a1f28; --mut:#3c4453; --dim:#697280; --faint:#98a1b0;
--line:rgba(0,0,0,.18); --line2:rgba(0,0,0,.11); --hover:rgba(0,0,0,.06);
/* Darker acid for light backgrounds — same hue family, ~5.5:1
contrast on #ffffff so text and single-pixel accents stay
readable. Tint fills (rgb(from var(--acid) r g b / .08) etc.) stay as-is:
at low alpha the specific hue barely matters. */
--acid: #0AC18E;
}
}
[hidden] { display: none !important; }
/* No `height: 100vh` — the chrome view is sized to CHROME_H by main.js,
and body took that height, so document.body.scrollHeight always equalled
the current CHROME_H. syncHeight() then just re-sent CHROME_H, so any
grow (for a menu) could never shrink back. Letting body size to its
content means scrollHeight is the natural chrome height, and syncHeight
shrinks correctly after every menu-close. */
body { margin: 0; background: var(--bg); color: var(--ink); user-select: none; overflow: hidden; }
.tabs { display: flex; align-items: flex-end; gap: 4px; padding: 6px 8px 0; height: 34px; overflow: hidden; }
/* Same-size tabs: each tab claims an equal share of the row, capped at
200px so 2 tabs don't stretch across the whole window. min-width lets
many tabs shrink cleanly while still showing a couple letters. */
.tab { display: flex; align-items: center; gap: 8px; flex: 1 1 0; max-width: 200px; min-width: 60px; padding: 6px 10px;
background: var(--surface); border: 1px solid var(--line2); border-bottom: none; border-radius: 8px 8px 0 0;
font-size: 12.5px; cursor: default; color: var(--mut); user-select: none; }
.tab.dragging { opacity: .45; cursor: grabbing; }
.tab.dropbefore { box-shadow: -2px 0 0 var(--acid); }
.tab.dropafter { box-shadow: 2px 0 0 var(--acid); }
.tab.active { background: var(--active); color: var(--ink); }
.tab .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.tab .fav { width: 14px; height: 14px; flex: none; border-radius: 2px; object-fit: contain; }
.tab .spin { width: 10px; height: 10px; flex: none; border: 1.5px solid #ffffff2e; border-top-color: var(--acid); border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.tab .x { opacity: .5; cursor: pointer; padding: 0 2px; border-radius: 4px; }
.tab .mute { font-size: 10px; opacity: .8; margin-right: 2px; }
/* Tab group visual: a colored dot before the title, plus a matching top
accent stripe on the tab itself so a whole group reads as one cluster
even when tabs are next to each other. */
.tab .gdot { width: 8px; height: 8px; flex: none; border-radius: 50%; }
.tab.grp { box-shadow: inset 0 2px 0 var(--gc, transparent); }
/* Group chip that appears before the first tab of a group. Clicking
collapses the group (hides its member tabs) and shows a count. */
.gchip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px;
background: var(--surface); border: 1px solid var(--line2); border-bottom: none;
border-radius: 8px 8px 0 0; cursor: pointer; height: 28px;
font: 12px/1 system-ui, sans-serif; color: var(--mut);
box-shadow: inset 0 2px 0 var(--gc, transparent); }
.gchip:hover { color: var(--ink); }
.gchip .gcdot { width: 10px; height: 10px; border-radius: 50%; background: var(--gc); flex: none; }
.gchip .gccnt { color: var(--ink); font-weight: 600; }
.gchip.collapsed { padding: 4px 10px; }
/* Drag hover state — a tab being dragged onto a group chip. */
.gchip.droptarget { background: rgb(from var(--acid) r g b / .15); border-color: var(--acid); }
/* Vertical popover: click a collapsed chip → list its member tabs. */
.grouppop { position: fixed; z-index: 9999; background: var(--surface, #1c222c); border: 1px solid var(--line2);
border-radius: 10px; box-shadow: 0 12px 34px #000c; padding: 4px; min-width: 220px; max-width: 360px;
font-size: 13px; color: var(--ink); }
.grouppop .gph { padding: 6px 10px 4px; font-size: 11px; color: var(--dim); letter-spacing: .08em; text-transform: uppercase;
display: flex; align-items: center; gap: 8px; }
.grouppop .gph .gcdot { width: 8px; height: 8px; border-radius: 50%; }
.grouppop .gpit { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; cursor: pointer;
white-space: nowrap; overflow: hidden; }
.grouppop .gpit:hover { background: var(--hover, #ffffff10); }
.grouppop .gpit.active { background: rgb(from var(--acid) r g b / .08); color: var(--acid); }
.grouppop .gpit .gfav { width: 14px; height: 14px; flex: none; object-fit: contain; border-radius: 2px; }
.grouppop .gpit .gt { overflow: hidden; text-overflow: ellipsis; flex: 1; }
.grouppop .gpit .gx { opacity: .5; padding: 0 4px; font-size: 12px; }
.grouppop .gpit:hover .gx { opacity: .9; }
.grouppop .gpit .gx:hover { color: #f6768a; opacity: 1; }
@media (prefers-color-scheme: light) {
.grouppop { background: #ffffff; border-color: rgba(0,0,0,.15); }
.grouppop .gpit:hover { background: rgba(0,0,0,.05); }
}
.tab.g-red, .gdot.g-red, .gcdot.g-red { --gc: #f6768a; } .gdot.g-red { background: #f6768a; }
.tab.g-orange, .gdot.g-orange, .gcdot.g-orange { --gc: #ffa96a; } .gdot.g-orange { background: #ffa96a; }
.tab.g-yellow, .gdot.g-yellow, .gcdot.g-yellow { --gc: #ffd44f; } .gdot.g-yellow { background: #ffd44f; }
.tab.g-green, .gdot.g-green, .gcdot.g-green { --gc: #4fd1a5; } .gdot.g-green { background: #4fd1a5; }
.tab.g-cyan, .gdot.g-cyan, .gcdot.g-cyan { --gc: #4fd1e5; } .gdot.g-cyan { background: #4fd1e5; }
.tab.g-blue, .gdot.g-blue, .gcdot.g-blue { --gc: #4b7bec; } .gdot.g-blue { background: #4b7bec; }
.tab.g-purple, .gdot.g-purple, .gcdot.g-purple { --gc: #b39ddb; } .gdot.g-purple { background: #b39ddb; }
/* Submenu popover for the tab context menu (Group → color). */
.ctxmenu .mi.sub { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.ctxmenu .mi.sub::after { content: "▸"; color: var(--dim); font-size: 11px; }
.ctxmenu.sub2 { min-width: 140px; }
.ctxmenu .swatch { width: 12px; height: 12px; border-radius: 50%; display: inline-block; margin-right: 8px; vertical-align: -1px; border: 1px solid rgba(255,255,255,.10); }
.tab .x:hover { opacity: 1; background: var(--line2); }
.newtab { padding: 4px 10px; cursor: pointer; color: var(--dim); border-radius: 6px; font-size: 16px; }
.newtab:hover { background: var(--hover); color: var(--ink); }
.bar { display: flex; gap: 6px; align-items: center; padding: 6px 10px; }
.nav { display: flex; gap: 1px; }
.ic { width: 34px; height: 32px; display: grid; place-items: center; border-radius: 8px; cursor: pointer;
color: var(--mut); background: transparent; border: none; padding: 0; }
.ic svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.ic:hover { background: var(--line2); color: var(--ink); }
.ic.active { background: rgb(from var(--acid) r g b / .12); color: var(--acid); }
.ic:active { background: var(--line); }
.ic:disabled { opacity: .28; cursor: default; background: transparent; }
/* One toolbar button per registered addon sidebar-panel, plus a static
placeholder for the built-in wallet before it lands. Each button
shows the panel's icon (usually a single emoji from the manifest);
click toggles the sidebar open on that panel, or collapses if it
was already the active panel. */
/* The dock and its inner buttons row never wrap — a shrunken slot makes
us collapse to the .extmore button (via data-extcollapse), it doesn't
stack the buttons vertically. inline-flex on #extbuttons keeps the
buttons on one line; overflow:hidden clips the last button just before
we swap to the collapsed dock in JS. */
#extbuttons { display: inline-flex; flex-wrap: nowrap; gap: 2px; overflow: hidden; }
.extdock { display: flex; gap: 2px; align-items: center; padding-left: 4px; margin-left: 2px;
flex-wrap: nowrap; overflow: hidden; min-width: 0;
border-left: 1px solid var(--line); }
/* inline-grid (not grid): grid defaults to block-level, which was making
every extension button take its own line inside #extbuttons; inline-grid
keeps the icon-centering behaviour without stacking siblings vertically. */
.extbtn { width: 30px; height: 30px; display: inline-grid; place-items: center; border-radius: 7px;
cursor: pointer; border: none; background: transparent; padding: 0;
font-size: 15px; line-height: 1; color: var(--mut); }
.extbtn:hover { background: var(--line2); color: var(--ink); }
.extbtn.active { background: rgb(from var(--acid) r g b / .12); color: var(--acid); box-shadow: inset 0 -2px 0 var(--acid); }
.extbtn.soon { opacity: .45; cursor: help; }
.extbtn.soon:hover { opacity: .7; background: var(--line2); }
/* downloads button — a small badge sits over the icon when there's activity */
.dlbtn { position: relative; }
.dlbtn.spin::before { content: ""; position: absolute; inset: 4px; border-radius: 999px;
border: 1.5px solid transparent; border-top-color: #4b7bec; animation: dlspin .9s infinite linear; }
@keyframes dlspin { to { transform: rotate(360deg); } }
.dlbadge { position: absolute; top: 2px; right: 2px; min-width: 14px; height: 14px; padding: 0 3px;
background: #4b7bec; color: #fff; border-radius: 7px; font-size: 9.5px; font-weight: 700;
display: grid; place-items: center; line-height: 1; box-shadow: 0 0 0 2px var(--bg); }
.dlbtn.done .dlbadge { background: #4fd1a5; }
.dlbtn.err .dlbadge { background: #f6768a; }
/* indeterminate loading bar under the toolbar — collapses when idle so it
doesn't take a permanent 2px strip below the address bar */
.loadbar { height: 0; overflow: hidden; background: transparent; transition: height .12s linear; }
.loadbar.on { height: 2px; }
.loadbar.on::after { content: ""; display: block; height: 100%; width: 35%; border-radius: 2px;
background: linear-gradient(90deg, transparent, #4b7bec, var(--acid), transparent); animation: loadslide 1.1s infinite linear; }
@keyframes loadslide { 0% { transform: translateX(-110%); } 100% { transform: translateX(400%); } }
/* address bar. flex:1 by default consumes remaining space; user can cap
it via Settings > Appearance > Address bar size so the extension dock
gets more room. min-width guards against squeezing the URL invisible. */
.urlwrap { position: relative; flex: 1 1 auto; min-width: 200px; display: flex; align-items: center; gap: 4px; background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 0 6px 0 4px; }
/* Capped URL bar: pin the trailing items (download / extensions / logo /
Theseus) to the RIGHT edge instead of packing them next to the URL bar
— a smaller address bar shouldn't slide everything leftward. margin-
right: auto on the URL bar consumes the slack. */
.bar[data-urlsize="medium"] .urlwrap { flex: 0 1 640px; margin-right: auto; }
.bar[data-urlsize="compact"] .urlwrap { flex: 0 1 400px; margin-right: auto; }
.bar[data-urlwidth] .urlwrap { flex: 0 0 var(--urlbar-w, auto); margin-right: auto; }
.searchbox { position: relative; }
.bar[data-searchsize="hidden"] .searchbox { display: none; }
.bar[data-searchsize="compact"] .searchbox { width: 180px; }
.bar[data-searchsize="wide"] .searchbox { width: 400px; }
.bar[data-searchwidth] .searchbox { width: var(--searchbox-w, 300px); }
/* Drag-to-resize handles: a thin invisible strip on the trailing edge of
.urlwrap and the leading edge of .searchbox. col-resize cursor makes
the affordance visible; a faint acid tint on hover confirms it's live. */
.urldrag, .searchdrag { position: absolute; top: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 10; background: transparent; }
.urldrag { right: -3px; }
.searchdrag { left: -3px; }
.urldrag:hover, .searchdrag:hover,
.urldrag.dragging, .searchdrag.dragging { background: rgb(from var(--acid) r g b / .35); }
/* Adaptive toolbar. A ResizeObserver on .bar sets data-responsive:
"0" wide — everything visible (default)
"1" tight — auto-hide the search box, min-width for URL bar drops
"2" narrow — Theseus button collapses to just the gear icon
"3" xnarrow — extension dock collapses to a single 🧩 puzzle button
that opens a dropdown of every registered addon panel
User's explicit data-searchsize wins over auto-hide at level 1: if the
user picked "wide" or "normal" they still get it, but "normal" at very
narrow widths yields to the responsive collapse. */
.bar[data-responsive="1"] .searchbox,
.bar[data-responsive="2"] .searchbox,
.bar[data-responsive="3"] .searchbox { display: none; }
.bar[data-responsive="2"] .logo span:not(.gear),
.bar[data-responsive="3"] .logo span:not(.gear) { display: none; }
.bar[data-responsive="2"] .logo,
.bar[data-responsive="3"] .logo { padding: 4px 8px; }
.bar[data-responsive="1"] .urlwrap,
.bar[data-responsive="2"] .urlwrap,
.bar[data-responsive="3"] .urlwrap { min-width: 120px; }
/* Level 3: hide the per-extension button row, show the collapsed puzzle
button. Dropdown is a floating .extpop the JS builds on click. */
/* Two paths to the collapsed dock: the coarse width-based level 3
(window very narrow) AND the fine-grained data-extcollapse (dock's
own slot ran out of room even though the window is wide, e.g. the
user dragged the URL bar so wide the extensions couldn't fit). */
.bar[data-responsive="3"] #extbuttons,
.bar[data-extcollapse="1"] #extbuttons { display: none; }
.extmore { display: none; }
.bar[data-responsive="3"] .extmore,
.bar[data-extcollapse="1"] .extmore { display: inline-grid; }
.extmore { width: 30px; height: 30px; place-items: center; border-radius: 7px;
cursor: pointer; border: none; background: transparent; padding: 0;
font-size: 15px; line-height: 1; color: var(--mut); }
.extmore:hover { background: var(--line2); color: var(--ink); }
.extmore.active { background: rgb(from var(--acid) r g b / .12); color: var(--acid); }
.extpop { position: fixed; z-index: 9999; background: var(--surface, #1c222c);
border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 12px 34px #000c;
padding: 4px; min-width: 180px; }
.extpop .epit { display: flex; align-items: center; gap: 10px; padding: 7px 10px;
border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--ink); }
.extpop .epit:hover { background: var(--hover, #ffffff10); }
.extpop .epit.active { background: rgb(from var(--acid) r g b / .10); color: var(--acid); }
.extpop .epit .ic { width: 16px; text-align: center; font-size: 15px; line-height: 1; }
.extpop .epit .lbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.urlwrap:focus-within { border-color: #4b7bec; }
/* Site security shield — always present. Colour communicates state:
neutral (default) for home / resolving, green when the connection is
secure (BCNR chain-verified OR https:// clearnet), red when it isn't
(nxdomain / resolver error / plain http). Shape stays constant so the
button doesn't jump around when the state changes. */
.secbadge { display: grid; place-items: center; width: 28px; height: 26px; border-radius: 6px; border: none;
background: transparent; cursor: pointer; padding: 0; color: var(--dim); }
.secbadge:hover { background: var(--line2); }
.secbadge svg { width: 16px; height: 16px; fill: currentColor; }
.secbadge.secure { color: #3fb950; } /* GitHub-style saturated green */
.secbadge.insecure { color: #f6768a; }
.secbadge.warn { color: #f6ad55; } /* kept for legacy call sites */
input { padding: 7px 6px; border-radius: 999px; border: none; background: transparent; color: inherit; font-size: 13.5px; outline: none; }
#url { flex: 1; }
/* Registry indicator at the END of the address bar. Two shapes:
- Toggle (segmented control) when this host could be served by EITHER
registry — a single pill split in half, active side filled, inactive
side transparent and clickable to switch.
- Single pill when there's no alternative (pure ICANN or a BCNR-unique TLD).*/
#reg { display: inline-flex; align-items: center; }
.reg { font-size: 10.5px; letter-spacing: .03em; padding: 2px 10px; white-space: nowrap;
cursor: default; user-select: none; transition: background .12s, color .12s, border-color .12s; }
/* Single-chip form (non-collision hosts) */
.reg.single { border-radius: 999px; border: 1px solid; }
.reg.single.bcdn.active { background: rgb(from var(--acid) r g b / .15); color: var(--acid); border-color: color-mix(in srgb, var(--acid) 33%, transparent); }
.reg.single.icann.active { background: rgba(76,158,255,.15); color: #4c9eff; border-color: #4c9eff55; }
/* Toggle (segmented control) form for collision candidates */
.tog { display: inline-flex; border-radius: 999px; overflow: hidden; border: 1px solid #ffffff26; }
.tog .reg { border: 0; }
.tog .reg + .reg { border-left: 1px solid #ffffff26; }
.tog .reg.active.bcdn { background: rgb(from var(--acid) r g b / .18); color: var(--acid); }
.tog .reg.active.icann { background: rgba(76,158,255,.18); color: #4c9eff; }
.tog .reg.switch { background: transparent; color: #7b8494; cursor: pointer; }
.tog .reg.switch:hover { color: #e5e7eb; background: rgba(255,255,255,.05); }
.star { border: none; background: transparent; cursor: pointer; color: var(--dim); font-size: 15px; padding: 2px 4px; border-radius: 6px; }
.star:hover { background: var(--line2); color: var(--ink); } .star.on { color: #ffd23d; }
/* Update-available chip — shows when the releases manifest names a newer
version than this build. Two buttons: main body opens the download URL
in the system browser; ✕ dismisses for the session. */
.upchip { display: inline-flex; align-items: center; gap: 0; background: rgb(from var(--acid) r g b / .14);
border: 1px solid color-mix(in srgb, var(--acid) 33%, transparent); border-radius: 999px; color: var(--acid); font-size: 12px;
font-weight: 600; padding: 0; overflow: hidden; }
.upchip .upcore { background: transparent; border: none; color: inherit; font: inherit;
padding: 5px 10px 5px 12px; cursor: pointer; white-space: nowrap; }
.upchip .upcore:hover { background: rgb(from var(--acid) r g b / .10); }
.upchip .updismiss { background: transparent; border: none; border-left: 1px solid color-mix(in srgb, var(--acid) 20%, transparent);
color: inherit; opacity: .55; padding: 5px 8px; cursor: pointer; font-size: 11px; }
.upchip .updismiss:hover { opacity: 1; }
/* Password-fill chip — only visible when the vault is unlocked AND the
current site has matching credentials. Count sits to the left of the key. */
.pwchip { display: inline-flex; align-items: center; gap: 3px; border: none; background: transparent; color: #3fb950; cursor: pointer; padding: 2px 5px; border-radius: 6px; font-size: 11px; font-weight: 700; }
.pwchip:hover { background: rgba(63,185,80,.14); }
.pwchip svg { width: 14px; height: 14px; }
/* search box: engine icon (click = pick engine) + a wide typing area */
.searchbox { display: flex; align-items: center; width: 300px; background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 0 12px 0 3px; }
.searchbox:focus-within { border-color: #4b7bec; }
/* dropdown button shows the SELECTED engine's favicon (emoji fallback) + a caret */
.engine-btn { display: flex; align-items: center; gap: 3px; height: 30px; border: none; cursor: pointer;
border-radius: 999px 0 0 999px; padding: 0 4px 0 8px; background: transparent; }
.engine-btn:hover { background-color: var(--hover); }
.engine-btn #engineIcon { display: grid; place-items: center; width: 16px; height: 16px; }
.engine-btn .eimg { width: 16px; height: 16px; border-radius: 3px; }
.engine-btn .esym { font-size: 14px; line-height: 1; }
.engine-btn .cv { font-size: 8px; color: var(--dim); }
#search { flex: 1; padding: 7px 4px; }
.tor { padding: 7px 10px; border-radius: 8px; border: 1px solid var(--line); background: #2b2f3a; color: var(--ink); cursor: pointer; white-space: nowrap; font-size: 12.5px; }
.tor.connecting { background: #7a5c14; } .tor.on { background: #6b3fa0; }
.logo { display: flex; align-items: center; gap: 6px; font-weight: 700; color: var(--acid); white-space: nowrap;
padding: 6px 12px; border: 1px solid color-mix(in srgb, var(--acid) 20%, transparent); border-radius: 8px; cursor: pointer; background: rgb(from var(--acid) r g b / .08); font-size: 13px; }
.logo:hover { background: rgb(from var(--acid) r g b / .16); }
.logo .gear { font-size: 12px; opacity: .8; }
/* favorites bar — new-tab page only, single compact row */
.bookmarks { display: flex; align-items: center; gap: 4px; padding: 3px 10px 5px; height: 26px; overflow: hidden; }
/* Ctrl+F find-in-page bar. Sits below the address bar (above tabs body),
~32px tall so it's part of the chrome view height like .tordisc / .bcnrbar.
syncHeight in the tabs handler picks it up automatically. */
.findbar { display: flex; align-items: center; gap: 6px; padding: 5px 10px; border-top: 1px solid var(--line); background: var(--surface); }
.findbar input { flex: 1; min-width: 120px; padding: 4px 8px; border-radius: 6px; background: var(--surface2); color: var(--ink);
border: 1px solid var(--line); font: inherit; font-size: 12.5px; outline: none; }
.findbar input:focus { border-color: color-mix(in srgb, var(--acid) 55%, transparent); }
.findbar input.nomatch { border-color: rgba(246,118,138,.55); background: rgba(246,118,138,.10); }
.findbar .findcount { font-size: 12px; color: var(--dim); font-variant-numeric: tabular-nums; min-width: 46px; text-align: right; }
.findbar .findbtn { border: 1px solid var(--line); background: var(--surface2); color: var(--ink); border-radius: 6px;
padding: 3px 8px; font-size: 13px; cursor: pointer; }
.findbar .findbtn:hover { background: var(--surface); border-color: color-mix(in srgb, var(--acid) 35%, transparent); }
.bm { display: flex; align-items: center; gap: 6px; max-width: 180px; padding: 3px 9px; border-radius: 6px;
background: var(--surface2); border: 1px solid var(--hover); color: var(--mut); font-size: 12px; cursor: pointer; white-space: nowrap; }
.bm:hover { background: var(--active); color: var(--ink); }
.bm .bmfav { width: 14px; height: 14px; flex: none; object-fit: contain; border-radius: 2px; }
.bm .bt { overflow: hidden; text-overflow: ellipsis; }
/* In-chrome prompt modal (window.prompt is disabled in Electron BrowserViews,
so bookmark rename etc. use this instead). */
.promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 10000;
display: grid; place-items: center; }
.promptbox { background: var(--surface, #1c222c); border: 1px solid var(--line);
border-radius: 10px; padding: 14px 16px 12px; min-width: 320px; max-width: 480px;
box-shadow: 0 20px 60px #000d; color: var(--ink); }
.promptbox .plbl { font-size: 12px; color: var(--mut); margin-bottom: 8px; }
.promptbox input { width: 100%; box-sizing: border-box; padding: 7px 10px; border-radius: 6px;
background: var(--surface2, #0f1621); border: 1px solid var(--line);
color: var(--ink); font: inherit; font-size: 13px; outline: none; }
.promptbox input:focus { border-color: rgb(from var(--acid) r g b / .55); }
.promptbox .pact { display: flex; gap: 6px; justify-content: flex-end; margin-top: 10px; }
.promptbox .pbtn { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--line);
background: var(--surface2, #0f1621); color: var(--ink); cursor: pointer;
font: inherit; font-size: 12.5px; }
.promptbox .pbtn:hover { border-color: rgb(from var(--acid) r g b / .35); }
.promptbox .pbtn.primary { background: var(--acid, var(--acid)); color: #0b0e14; border-color: transparent; font-weight: 600; }
.bm .bx { opacity: 0; cursor: pointer; font-size: 11px; } .bm:hover .bx { opacity: .55; }
.bm .bx:hover { opacity: 1; color: #f6768a; }
.bm-empty { color: var(--faint); font-size: 11.5px; white-space: nowrap; }
/* Right-click menu on the bookmarks bar (same visual as settings ctxmenu). */
.ctxmenu { position: fixed; z-index: 9999; background: #1c222c; border: 1px solid var(--line);
border-radius: 8px; box-shadow: 0 12px 34px #000c; padding: 4px; min-width: 180px; font-size: 13px; color: var(--ink); }
.ctxmenu .mi { padding: 7px 12px; border-radius: 5px; cursor: pointer; white-space: nowrap; }
.ctxmenu .mi:hover { background: #ffffff10; }
.ctxmenu .mi.danger { color: #f6768a; } .ctxmenu .mi.danger:hover { background: rgba(246,118,138,.12); }
.ctxmenu .mi.off { color: var(--faint); cursor: default; } .ctxmenu .mi.off:hover { background: transparent; }
.ctxmenu .sep { height: 1px; background: var(--line); margin: 4px 0; }
@media (prefers-color-scheme: light) {
.ctxmenu { background: #ffffff; border-color: rgba(0,0,0,.15); }
.ctxmenu .mi:hover { background: rgba(0,0,0,.05); }
/* BCH dark #253A49 as a light-mode accent so the toolbar isn't
all-white-on-white. .logo (⛓ Theseus) becomes a dark chip; the
download / update pill picks it up so both stand out. */
.logo { background: #253A49; color: #F8FDFF; border-color: #253A49; }
.logo:hover { background: #1a2c39; }
.logo .gear { color: #F8FDFF; opacity: .85; }
.upchip { background: #253A49; color: #F8FDFF; border-color: #253A49; }
.upchip .upcore:hover { background: #1a2c39; }
.upchip .updismiss { border-left-color: rgba(255,255,255,.20); color: #F8FDFF; }
}
.tordisc { font-size: 11.5px; color: #d9c7f2; background: #2a1c40; border-top: 1px solid #6b3fa055; padding: 5px 14px; }
.tordisc a { color: var(--acid); }
.bcnrbar { display: flex; align-items: center; gap: 10px; font-size: 11.5px; color: var(--mut);
background: #1a2417; border-top: 1px solid color-mix(in srgb, var(--acid) 20%, transparent); padding: 6px 14px; }
.bcnrbar .breg { font-size: 10px; letter-spacing: .03em; padding: 1px 7px; border-radius: 999px;
background: rgb(from var(--acid) r g b / .14); color: var(--acid); border: 1px solid color-mix(in srgb, var(--acid) 20%, transparent); }
.bcnrbar b { color: var(--ink); }
.bcnrbar .bopen { margin-left: auto; padding: 4px 11px; border-radius: 7px; border: 1px solid color-mix(in srgb, var(--acid) 33%, transparent);
background: rgb(from var(--acid) r g b / .12); color: var(--acid); cursor: pointer; font-size: 11.5px; }
.bcnrbar .bopen:hover { background: rgb(from var(--acid) r g b / .22); }
.bcnrbar .bdismiss { color: var(--dim); text-decoration: none; } .bcnrbar .bdismiss:hover { color: var(--ink); }
</style></head>
<body>
<div class="tabs" id="tabs"></div>
<div class="bar" id="bar">
<div class="nav">
<button class="ic" id="back" title="Back"><svg viewBox="0 0 16 16"><path d="M10 3 L5 8 L10 13"/></svg></button>
<button class="ic" id="fwd" title="Forward"><svg viewBox="0 0 16 16"><path d="M6 3 L11 8 L6 13"/></svg></button>
<button class="ic" id="reload" title="Reload"><svg viewBox="0 0 16 16"><path d="M13 8 a5 5 0 1 1 -1.5 -3.6"/><path d="M13 2.5 L13 5 L10.5 5"/></svg></button>
<button class="ic" id="home" title="Home"><svg viewBox="0 0 16 16"><path d="M2.5 7.5 L8 3 L13.5 7.5"/><path d="M4 6.7 L4 13 L12 13 L12 6.7"/></svg></button>
</div>
<div class="urlwrap">
<button class="secbadge" id="secbadge" title="Site information">
<!-- Heraldic shield outline matching the reference image: flared top
shoulders, concave sides tapering to a sharp bottom point. Single
filled path, `currentColor` so state class toggles the tint. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 1.2 C6 1.2 3.5 1.7 2.5 2.3 Q2 2.6 2 3.5 L2 7 C2 11.2 4.2 13.7 8 15 C11.8 13.7 14 11.2 14 7 L14 3.5 Q14 2.6 13.5 2.3 C12.5 1.7 10 1.2 8 1.2 Z"/>
</svg>
</button>
<input id="url" placeholder="Ask a search engine or enter web address" spellcheck="false">
<span class="reg" id="reg" hidden></span>
<button class="pwchip" id="pwchip" title="Fill password for this site" hidden><span id="pwchipCount"></span><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="9" r="3.2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8.8 8.6 L14 8.6 M13.2 8.6 L13.2 11 M11.5 8.6 L11.5 10.4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
<button class="star" id="star" title="Save this page"></button>
<div class="urldrag" id="urldrag" title="Drag to resize"></div>
</div>
<div class="searchbox">
<div class="searchdrag" id="searchdrag" title="Drag to resize"></div>
<button id="engineBtn" class="engine-btn" title="Choose search engine"><span id="engineIcon"></span><span class="cv"></span></button>
<input id="search" placeholder="Search" spellcheck="false">
</div>
<button class="ic dlbtn" id="downloads" title="Downloads">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2 V10 M4.5 6.5 L8 10 L11.5 6.5"/><path d="M3 12.5 L3 13.5 L13 13.5 L13 12.5"/></svg>
<span class="dlbadge" id="dlbadge" hidden>0</span>
</button>
<span class="extdock" id="extdock" hidden>
<span id="extbuttons"></span>
<button class="extmore" id="extmore" title="Extensions" aria-label="Extensions"></button>
</span>
<span class="upchip" id="upchip" hidden><button class="upcore" id="upDownload" title=""></button><button class="updismiss" id="upDismiss" title="Not now"></button></span>
<button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button>
<button class="logo" id="logo" title="Settings"><span>⛓ Theseus</span><span class="gear"></span></button>
</div>
<div class="loadbar" id="loadbar"></div>
<div class="bookmarks" id="bookmarks" hidden></div>
<div class="findbar" id="findbar" hidden>
<input id="findInput" type="text" placeholder="Find in page" spellcheck="false" autocomplete="off">
<span class="findcount" id="findCount"></span>
<button class="findbtn" id="findPrev" title="Previous (Shift+Enter)"></button>
<button class="findbtn" id="findNext" title="Next (Enter)"></button>
<button class="findbtn" id="findClose" title="Close (Esc)"></button>
</div>
<div id="tordisc" class="tordisc" hidden>
Onion mode hides your IP from sites and your ISP (including .bch lookups).
It is <b>not full anonymity</b> — this browser can still be fingerprinted; for that use the Tor Browser.
<a href="#" id="tordismiss">got it</a>
</div>
<div id="bcnrbar" class="bcnrbar" hidden>
<span class="breg" id="bcnrreg">BCDN</span>
<span><b id="bcnrhost"></b> also exists on <b>BCDN</b> — the decentralized registry.</span>
<button id="bcnropen" class="bopen">Open on BCDN</button>
<a href="#" id="bcnrdismiss" class="bdismiss">dismiss</a>
</div>
<script>
const $ = (id) => document.getElementById(id);
const T = window.theseus;
function syncHeight() { requestAnimationFrame(() => T.setChromeHeight(document.body.scrollHeight)); }
const RELOAD_SVG = `<svg viewBox="0 0 16 16"><path d="M13 8 a5 5 0 1 1 -1.5 -3.6"/><path d="M13 2.5 L13 5 L10.5 5"/></svg>`;
const STOP_SVG = `<svg viewBox="0 0 16 16"><path d="M4.5 4.5 L11.5 11.5 M11.5 4.5 L4.5 11.5"/></svg>`;
function goURL() {
const v = $("url").value.trim();
if (!v) return;
// Blur so the tabs-event handler can update the URL bar to the actual
// loaded page — the "don't clobber the typed text" guard would otherwise
// keep the search keyword displayed while the results are loading.
$("url").blur();
T.navigate(v);
}
// Address-bar suggestions — debounced call to main on every keystroke.
// Main computes matches from history and shows/hides a floating overlay
// WebContentsView anchored under the address bar's client rect.
let suggestTimer = null;
let pickerOpen = false;
const askSuggest = () => {
const q = $("url").value;
const r = $("url").getBoundingClientRect();
const rect = { x: Math.round(r.left), y: Math.round(r.bottom + 4), w: Math.round(r.width + 40) };
T.suggestAddress(q, rect);
// main hides itself when there are zero matches; assume open otherwise.
pickerOpen = true;
};
// Whether the user has moved the picker's highlight since last typing.
// Enter with a highlight submits THAT url (via the picker), not the
// typed text — so pressing ↓ then Enter opens the highlighted entry
// instead of doing a web search for the letters you typed.
let pickerHasCursor = false;
const debouncedSuggest = () => { clearTimeout(suggestTimer); suggestTimer = setTimeout(askSuggest, 80); };
$("url").addEventListener("input", () => { pickerHasCursor = false; debouncedSuggest(); });
$("url").addEventListener("focus", debouncedSuggest);
$("url").addEventListener("blur", () => {
// Delay: gives a click on the picker time to register before we close.
setTimeout(() => { T.closeAddressPicker(); pickerOpen = false; pickerHasCursor = false; }, 160);
});
$("url").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
if (pickerOpen && pickerHasCursor) {
// Forward Enter to the picker so it navigates to the HIGHLIGHTED
// suggestion instead of running goURL against the typed text
// (which would search for the letters, not open the URL).
T.addressCursor("enter");
return;
}
T.closeAddressPicker(); pickerOpen = false; pickerHasCursor = false; goURL();
return;
}
if (e.key === "Escape") { T.closeAddressPicker(); pickerOpen = false; pickerHasCursor = false; return; }
if (!pickerOpen) return;
if (e.key === "ArrowDown") { e.preventDefault(); pickerHasCursor = true; T.addressCursor("next"); }
else if (e.key === "ArrowUp") { e.preventDefault(); pickerHasCursor = true; T.addressCursor("prev"); }
});
// Enter submits the search; keep the query visible so the user can refine
// it or search again — clearing it on submit lost context and made refining
// annoying (esp. when the engine's own results page uses its own search box).
$("search").addEventListener("keydown", (e) => { if (e.key === "Enter") { const q = $("search").value.trim(); if (q) T.search(q); } });
$("back").onclick = () => T.back();
$("fwd").onclick = () => T.forward();
// Shift-click on the reload button = hard reload (bypass cache), same
// convention as Chrome/Firefox. Keyboard alternative: Ctrl+Shift+R.
$("reload").onclick = (e) => T.reload(e.shiftKey);
$("home").onclick = () => T.goHome();
$("logo").onclick = () => T.openSettings();
$("secbadge").onclick = () => { const r = $("secbadge").getBoundingClientRect(); T.toggleSiteInfo({ x: Math.round(r.left), y: Math.round(r.bottom + 4) }); };
// ---- search-engine picker: the magnifier button opens a custom overlay
// dropdown (real favicons); here we only track the current engine's name. ----
function setEnginePlaceholder(d) {
const cur = (d.engines || []).find((e) => e.id === d.current);
$("search").placeholder = cur ? "Search with " + cur.name : "Search";
const icon = $("engineIcon");
const sym = (cur && cur.sym) || "🔍";
if (cur && cur.favicon)
icon.innerHTML = `<img class="eimg" src="${cur.favicon.replace(/"/g, "&quot;")}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'esym',textContent:'${sym}'}))">`;
else icon.innerHTML = `<span class="esym">${sym}</span>`;
}
$("engineBtn").onclick = () => {
const r = $("engineBtn").getBoundingClientRect();
T.toggleEnginePicker({ x: Math.round(r.left), y: Math.round(r.bottom + 4) });
};
T.getSearchEngines().then(setEnginePlaceholder);
T.onEngines && T.onEngines(setEnginePlaceholder);
// ---- downloads button: badge + click toggles the panel ----
const dlBtn = $("downloads"), dlBadge = $("dlbadge");
function renderDownloads(items) {
const list = items || [];
const active = list.filter((d) => d.state === "progressing").length;
const doneNew = list.filter((d) => d.state === "completed").length;
const failed = list.filter((d) => d.state === "interrupted").length;
dlBtn.classList.toggle("spin", active > 0);
dlBtn.classList.toggle("done", active === 0 && doneNew > 0 && failed === 0);
dlBtn.classList.toggle("err", active === 0 && failed > 0);
const badge = active || (doneNew + failed);
if (badge > 0) { dlBadge.hidden = false; dlBadge.textContent = badge > 99 ? "99+" : String(badge); }
else { dlBadge.hidden = true; }
}
dlBtn.onclick = () => {
const r = dlBtn.getBoundingClientRect();
T.toggleDownloads({ x: Math.round(r.right - 340), y: Math.round(r.bottom + 4) });
};
T.getDownloads && T.getDownloads().then(renderDownloads);
T.onDownloads && T.onDownloads(renderDownloads);
// ---- extension dock ----
// One toolbar button per registered addon sidebar-panel, plus a static
// "coming soon" placeholder for Aegis (the built-in BCH wallet, in
// progress). Click a live button → open the sidebar on that panel; if
// the sidebar was already open on that panel, collapse it.
const extDock = $("extdock");
const extButtons = $("extbuttons");
// Flat list of dock buttons — an addon that registers a sidebar-panel and
// an addon that declares a toolbar-menu both get one entry here. panels
// stay clickable-toggle; menus open a floating dropdown positioned under
// the icon and dispatch the picked item back to the add-on.
let lastPanels = [], lastMenus = [], lastButtons = [];
let sidebarOpen = false, activePanelId = null;
let openMenuAddonId = null; // which addon's menu dropdown is currently open
function renderSidebarState(d) {
if (!d) return;
lastPanels = Array.isArray(d.panels) ? d.panels : [];
lastMenus = Array.isArray(d.toolbarMenus) ? d.toolbarMenus : [];
sidebarOpen = !!d.visible;
activePanelId = d.active || null;
// Build the flat button list, panels first (matches shipped order) then
// toolbar-menu buttons in registration order.
lastButtons = [];
for (const p of lastPanels) {
lastButtons.push({ kind: "panel", key: "p:" + p.panelId, panelId: p.panelId, icon: String(p.icon || "•"), title: p.title || "Extension" });
}
for (const m of lastMenus) {
lastButtons.push({ kind: "menu", key: "m:" + m.addonId, addonId: m.addonId, icon: String(m.icon || "•"), title: m.title || m.addonId, items: m.items || [] });
}
// Hide the dock entirely on a fresh install with no add-ons registered
// — no empty-slot artifact next to the download button.
extDock.hidden = lastButtons.length === 0;
extButtons.innerHTML = lastButtons.map((b) => {
const activePanel = b.kind === "panel" && sidebarOpen && b.panelId === activePanelId;
const activeMenu = b.kind === "menu" && openMenuAddonId === b.addonId;
const cls = (activePanel || activeMenu) ? " active" : "";
const dataAttr = b.kind === "panel"
? `data-panel="${b.panelId.replace(/"/g,"&quot;")}"`
: `data-menu="${b.addonId.replace(/"/g,"&quot;")}"`;
// Icon is text by default (an emoji like 🛡, or a "•" fallback). An
// add-on that wants a branded mark can set manifest.icon to a data:
// URI (svg or png) and we render it as an <img> instead — same size
// slot, no layout change. Anything else is HTML-escaped and stays
// text so the emoji case keeps working.
let iconHtml;
if (/^data:image\//i.test(b.icon)) {
iconHtml = `<img src="${b.icon.replace(/"/g,"&quot;")}" alt="" style="width:18px;height:18px;display:block;margin:0 auto">`;
} else {
iconHtml = b.icon.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
return `<button class="extbtn${cls}" ${dataAttr} title="${b.title.replace(/"/g,"&quot;")}">${iconHtml}</button>`;
}).join("");
extButtons.querySelectorAll(".extbtn").forEach((btn) => {
btn.onclick = () => {
if (btn.dataset.panel) {
const pid = btn.dataset.panel;
closeToolbarMenu();
if (sidebarOpen && pid === activePanelId) { T.closeSidebar && T.closeSidebar(); }
else { T.openSidebar && T.openSidebar(pid); }
} else if (btn.dataset.menu) {
openToolbarMenu(btn.dataset.menu, btn);
}
};
});
// Collapsed dock button: show the first button's icon (falling back
// to 🧩 if nothing is registered). Clicking it opens a dropdown of
// every registered surface so the user can pick.
const first = lastButtons[0];
$("extmore").textContent = first ? first.icon : "🧩";
$("extmore").title = first ? "Extensions — " + first.title : "Extensions";
$("extmore").classList.toggle("active", (sidebarOpen && !!activePanelId) || !!openMenuAddonId);
}
// ---- toolbar-menu dropdown (per-addon) ----
// Positions under the clicked icon, styled after .extpop. Clicking an
// item dispatches to main via addonMenuSelect(addonId, itemId) and
// closes; clicking outside the popover closes it.
function closeToolbarMenu() {
const p = document.getElementById("tbmenu");
if (p) p.remove();
if (openMenuAddonId) {
openMenuAddonId = null;
// Re-render dock so the "active" tint drops off the icon.
renderSidebarState({ visible: sidebarOpen, active: activePanelId, panels: lastPanels, toolbarMenus: lastMenus });
}
}
function openToolbarMenu(addonId, anchorBtn) {
// Toggle: clicking the same icon while its menu is open closes it.
if (openMenuAddonId === addonId) { closeToolbarMenu(); return; }
closeToolbarMenu();
const menu = lastMenus.find((m) => m.addonId === addonId);
if (!menu) return;
openMenuAddonId = addonId;
// Tint the anchor while the menu is showing; main clears it when the
// native menu closes via the "toolbar-menu-closed" event.
const btns = extButtons.querySelectorAll(".extbtn[data-menu]");
btns.forEach((b) => b.classList.toggle("active", b.dataset.menu === addonId));
$("extmore").classList.toggle("active", true);
// Hand off to a NATIVE OS menu. Renderer-DOM dropdowns get clipped by
// chrome.html's own WebContentsView height and hidden behind the tab
// view below — a native Menu.popup escapes that layering.
const r = anchorBtn.getBoundingClientRect();
const rect = { x: Math.round(r.left), y: Math.round(r.bottom + 2), width: Math.round(r.width), height: Math.round(r.height) };
if (T.toolbarMenuPopup) {
T.toolbarMenuPopup(addonId, rect).catch((err) => console.warn("toolbar-menu-popup failed:", err?.message || err));
}
}
// Main tells us when the native menu is dismissed so we can clear the
// "active" tint. `openMenuAddonId` stays set until this arrives (a click
// on the same button while the menu is up is treated as "close by
// reopening intent" by the OS anyway).
if (T.onToolbarMenuClosed) T.onToolbarMenuClosed(() => closeToolbarMenu());
T.sidebarState && T.sidebarState().then(renderSidebarState);
T.onSidebarState && T.onSidebarState(renderSidebarState);
// Collapsed extensions dropdown — opens under the .extmore button at
// responsive level 3, lists every registered panel with icon + name.
function closeExtPop() { const p = document.getElementById("extpop"); if (p) p.remove(); }
$("extmore").onclick = (ev) => {
ev.stopPropagation();
if (document.getElementById("extpop")) return closeExtPop();
closeToolbarMenu();
const rect = $("extmore").getBoundingClientRect();
const pop = document.createElement("div");
pop.id = "extpop"; pop.className = "extpop";
// Include both sidebar panels AND toolbar-menu buttons at narrow widths.
// A panel row toggles the sidebar; a menu row opens a nested submenu.
const rows = [];
for (const p of lastPanels) {
const isActive = sidebarOpen && p.panelId === activePanelId;
rows.push(`<div class="epit${isActive ? " active" : ""}" data-panel="${p.panelId.replace(/"/g,"&quot;")}"><span class="ic">${String(p.icon || "•").replace(/</g,"&lt;")}</span><span class="lbl">${(p.title || "Extension").replace(/</g,"&lt;")}</span></div>`);
}
for (const m of lastMenus) {
rows.push(`<div class="epit" data-menu="${m.addonId.replace(/"/g,"&quot;")}"><span class="ic">${String(m.icon || "•").replace(/</g,"&lt;")}</span><span class="lbl">${(m.title || m.addonId).replace(/</g,"&lt;")}</span></div>`);
}
pop.innerHTML = rows.join("") || `<div class="epit" style="opacity:.6;cursor:default">No extensions installed</div>`;
document.body.appendChild(pop);
const r = pop.getBoundingClientRect();
pop.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - r.width - 4)) + "px";
pop.style.top = Math.round(rect.bottom + 4) + "px";
pop.querySelectorAll(".epit[data-panel]").forEach((row) => {
row.onclick = () => {
const pid = row.dataset.panel;
closeExtPop();
if (sidebarOpen && pid === activePanelId) { T.closeSidebar && T.closeSidebar(); }
else { T.openSidebar && T.openSidebar(pid); }
};
});
pop.querySelectorAll(".epit[data-menu]").forEach((row) => {
row.onclick = () => {
const aid = row.dataset.menu;
closeExtPop();
// Re-anchor the submenu under the .extmore button since the
// per-icon dock button is hidden at this responsive level.
openToolbarMenu(aid, $("extmore"));
};
});
setTimeout(() => {
const off = (e) => { if (!pop.contains(e.target) && e.target !== $("extmore")) { closeExtPop(); document.removeEventListener("mousedown", off); } };
document.addEventListener("mousedown", off);
}, 0);
};
// Responsive breakpoints on .bar width. ResizeObserver reacts to window
// resize, sidebar toggle, and any chrome-height change — all cheap since
// we only touch a single dataset attribute if the level changes.
const barEl = $("bar");
const RESPONSIVE_BREAKS = [
{ w: 1050, level: "0" }, // wide
{ w: 820, level: "1" }, // hide search
{ w: 620, level: "2" }, // theseus icon-only
{ w: 0, level: "3" }, // extensions → dropdown
];
function pickResponsive(w) {
for (const b of RESPONSIVE_BREAKS) if (w >= b.w) return b.level;
return "3";
}
// Fine-grained overflow check: if the extension buttons can't fit their
// own slot in the bar, force the collapsed dock (single button + dropdown)
// regardless of window width. Fires when the user drags the URL bar wider,
// adds an extension, or a wider-icon extension gets registered.
const extButtonsEl = $("extbuttons");
const extDockEl = $("extdock");
// Cache the natural width of the buttons row while expanded. When the dock
// is currently collapsed, #extbuttons has display:none so scrollWidth is
// 0 — measuring it then would falsely decide "plenty of room" and expand,
// which would immediately overflow again → RO loop. Learn while expanded
// and trust that value while collapsed. 8px hysteresis on the re-expand
// side prevents boundary oscillation.
let naturalExtWidth = 0;
function updateExtCollapse() {
if (!extButtonsEl || !extDockEl) return;
const currentlyCollapsed = barEl.dataset.extcollapse === "1";
if (!currentlyCollapsed) {
naturalExtWidth = Math.max(naturalExtWidth, extButtonsEl.scrollWidth);
// While expanded: collapse when .bar's contents overflow their slot
// (i.e. the row + everything else doesn't fit horizontally).
if (barEl.scrollWidth > barEl.clientWidth + 2 && barEl.dataset.responsive !== "3") {
barEl.dataset.extcollapse = "1";
}
} else {
// While collapsed: re-expand only when there's demonstrably room in
// .bar for the buttons at their natural width. Sum the widths of all
// other .bar children (skipping display:none), add the collapsed
// dock's target size (natural), and compare to bar.clientWidth. 8px
// slack + require the strict "current bar isn't already overflowing"
// to keep the toggle from oscillating at the boundary.
if (barEl.scrollWidth > barEl.clientWidth + 2) return; // still cramped
const barStyle = getComputedStyle(barEl);
const gap = parseFloat(barStyle.gap) || 0;
let usedByOthers = 0;
let visibleKids = 0;
for (const k of barEl.children) {
if (getComputedStyle(k).display === "none") continue;
visibleKids += 1;
if (k === extDockEl) continue;
usedByOthers += k.getBoundingClientRect().width;
}
const gaps = gap * Math.max(0, visibleKids - 1);
// .bar has horizontal padding too — subtract it.
const padX = (parseFloat(barStyle.paddingLeft) || 0) + (parseFloat(barStyle.paddingRight) || 0);
const availableForDock = barEl.clientWidth - usedByOthers - gaps - padX;
if (availableForDock >= naturalExtWidth + 8) delete barEl.dataset.extcollapse;
}
}
const ro = new ResizeObserver((entries) => {
for (const e of entries) {
const level = pickResponsive(e.contentRect.width);
if (barEl.dataset.responsive !== level) {
barEl.dataset.responsive = level;
// Close the extmore dropdown if we shrank/grew across level 3.
if (level !== "3") closeExtPop();
}
}
// Re-check overflow every time the bar resizes — cheap.
updateExtCollapse();
});
ro.observe(barEl);
// Also observe #extbuttons directly so that a new extension being
// registered (which changes scrollWidth without a bar resize) triggers a
// re-check.
if (extButtonsEl) new ResizeObserver(updateExtCollapse).observe(extButtonsEl);
// Initial pass — RO fires on subscribe, but only after the next frame;
// set an initial value so first paint doesn't flash the wide layout.
barEl.dataset.responsive = pickResponsive(barEl.getBoundingClientRect().width);
updateExtCollapse();
// ---- favorites bar (new-tab page only) ----
let current = { url: "", title: "", favicon: null }, bookmarks = [];
function renderBookmarks() {
const box = $("bookmarks");
box.innerHTML = bookmarks.length
? bookmarks.map((b, i) => {
const fav = b.favicon
? `<img class="bmfav" src="${String(b.favicon).replace(/"/g,"&quot;")}" onerror="this.remove()">`
: `<span class="bmfav" style="display:inline-block"></span>`;
return `<div class="bm" data-i="${i}" title="${(b.url||"").replace(/"/g,"&quot;")}">${fav}<span class="bt">${(b.title||b.url).replace(/</g,"&lt;")}</span><span class="bx" data-x="${i}">✕</span></div>`;
}).join("")
: `<span class="bm-empty">No saved pages yet — click ☆ or right-click here to add one.</span>`;
box.querySelectorAll(".bm").forEach((el) => el.onclick = (e) => {
const i = Number(el.dataset.i);
if (e.target.dataset.x !== undefined) T.removeBookmark(bookmarks[i].url);
else T.navigate(bookmarks[i].url);
});
// Backfill: older bookmarks stored before favicon-support have no icon.
// If we're currently on that URL and have a favicon in hand, patch it in
// once so the next render draws it.
if (current.url && current.favicon) {
const stale = bookmarks.find((b) => b.url === current.url && !b.favicon);
if (stale && T.updateBookmark) T.updateBookmark(current.url, { favicon: current.favicon });
}
updateStar(); syncHeight();
}
// In-page prompt modal — window.prompt is disabled in Electron BrowserViews.
// Resolves to the trimmed string on OK, or null on Cancel / Escape / mask click.
function inPrompt(label, initial) {
return new Promise((resolve) => {
const mask = document.createElement("div"); mask.className = "promptmask";
mask.innerHTML = `<div class="promptbox"><div class="plbl">${label.replace(/</g,"&lt;")}</div>`
+ `<input type="text" spellcheck="false">`
+ `<div class="pact"><button class="pbtn pcancel" type="button">Cancel</button><button class="pbtn primary pok" type="button">OK</button></div></div>`;
document.body.appendChild(mask);
const input = mask.querySelector("input");
input.value = String(initial ?? "");
const done = (val) => { try { mask.remove(); } catch {} document.removeEventListener("keydown", key); resolve(val); };
const key = (e) => { if (e.key === "Escape") done(null); else if (e.key === "Enter") done(input.value.trim() || null); };
document.addEventListener("keydown", key);
mask.addEventListener("mousedown", (e) => { if (e.target === mask) done(null); });
mask.querySelector(".pcancel").addEventListener("click", () => done(null));
mask.querySelector(".pok").addEventListener("click", () => done(input.value.trim() || null));
setTimeout(() => { input.focus(); input.select(); }, 0);
});
}
// Right-click context menu on the bookmarks bar. Two modes:
// - On a specific bookmark: Open / Edit title / Remove
// - On empty area OR the placeholder text: Add current page (or Remove
// if the page is already bookmarked)
function closeBmMenu() { const m = document.getElementById("bmMenu"); if (m) m.remove(); }
function openBmMenu(x, y, ctx) {
closeBmMenu();
const items = [];
if (ctx.kind === "item") {
const bm = bookmarks[ctx.index];
items.push({ label: "Open", act: () => T.navigate(bm.url) });
items.push({ label: "Edit title…", act: async () => {
const nt = await inPrompt("Edit bookmark title", bm.title || bm.url);
if (nt && nt !== bm.title && T.updateBookmark) T.updateBookmark(bm.url, { title: nt });
}});
items.push({ label: "Remove", act: () => T.removeBookmark(bm.url), danger: true });
items.push({ sep: true });
}
if (current.url) {
const saved = bookmarks.some((b) => b.url === current.url);
items.push(saved
? { label: "Remove current page", act: () => T.removeBookmark(current.url), danger: true }
: { label: "Add current page", act: () => T.addBookmark({ title: current.title || current.url, url: current.url, favicon: current.favicon || null }) });
} else {
items.push({ label: "Add current page", disabled: true });
}
const m = document.createElement("div");
m.id = "bmMenu"; m.className = "ctxmenu";
m.innerHTML = items.map((it, i) => it.sep
? `<div class="sep"></div>`
: `<div class="mi${it.danger ? " danger" : ""}${it.disabled ? " off" : ""}" data-i="${i}">${it.label.replace(/</g, "&lt;")}</div>`
).join("");
document.body.appendChild(m);
const r = m.getBoundingClientRect();
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
m.style.left = Math.min(x, vw - r.width - 6) + "px";
m.style.top = Math.min(y, vh - r.height - 6) + "px";
m.querySelectorAll(".mi").forEach((el) => {
const it = items[Number(el.dataset.i)];
if (it.disabled) return;
el.onclick = () => { closeBmMenu(); try { it.act(); } catch (e) { console.error(e); } };
});
setTimeout(() => {
const off = (ev) => { if (!m.contains(ev.target)) { closeBmMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
const esc = (ev) => { if (ev.key === "Escape") { closeBmMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
document.addEventListener("mousedown", off);
document.addEventListener("keydown", esc);
}, 0);
}
$("bookmarks").addEventListener("contextmenu", (e) => {
e.preventDefault();
const bm = e.target.closest(".bm");
openBmMenu(e.clientX, e.clientY, bm ? { kind: "item", index: Number(bm.dataset.i) } : { kind: "bar" });
});
function updateStar() {
const saved = !!current.url && bookmarks.some((b) => b.url === current.url);
const s = $("star"); s.textContent = saved ? "★" : "☆"; s.classList.toggle("on", saved);
s.title = saved ? "Remove from saved pages" : "Save this page";
}
$("star").onclick = () => {
if (!current.url) return;
if (bookmarks.some((b) => b.url === current.url)) T.removeBookmark(current.url);
else T.addBookmark({ title: current.title || current.url, url: current.url, favicon: current.favicon || null });
};
T.getBookmarks().then((b) => { bookmarks = b || []; renderBookmarks(); });
T.onBookmarks((b) => { bookmarks = b || []; renderBookmarks(); });
// ---- password-fill chip ----
// Visible only when the vault is unlocked AND the current site has matching
// credentials. Click opens a floating picker of matches; clicking a match
// injects the fill into the active tab.
T.onPwAvailability && T.onPwAvailability((d) => {
const chip = $("pwchip"); if (!chip) return;
const n = d && d.count ? d.count : 0;
chip.hidden = n === 0;
$("pwchipCount").textContent = n > 1 ? String(n) : "";
chip.title = n === 1 ? "Fill password for this site" : `${n} saved credentials for this site`;
});
$("pwchip") && ($("pwchip").onclick = () => {
const r = $("pwchip").getBoundingClientRect();
T.togglePwFill({ x: Math.round(r.right - 280), y: Math.round(r.bottom + 4) });
});
// ---- update-available chip ----
// Main sends "update-available" with { version, setupUrl, portableUrl, ... }
// when the releases manifest names a newer Theseus. Null clears the chip
// (up-to-date, or dismissed this session).
T.onUpdateAvailable && T.onUpdateAvailable((d) => {
const chip = $("upchip"); if (!chip) return;
if (!d) { chip.hidden = true; return; }
chip.hidden = false;
const url = d.setupUrl || d.portableUrl || "";
const core = $("upDownload");
// State machine: idle (nothing yet) -> downloading (silent fetch running,
// show percent) -> ready (chip becomes "Install & restart, one click") ->
// failed (fall back to explicit user-triggered download).
const state = d.downloadState || "idle";
if (state === "downloading") {
const pct = d.downloadTotal ? Math.floor((d.downloadReceived / d.downloadTotal) * 100) : null;
core.textContent = pct != null ? `${pct}% — ${d.version}` : `↓ Downloading ${d.version}`;
core.title = "Downloading update in the background";
core.onclick = () => {};
} else if (state === "ready") {
core.textContent = `✓ Install ${d.version} & restart`;
core.title = "One-click install: launches the installer silently and restarts Theseus";
core.onclick = () => T.installUpdateNow && T.installUpdateNow();
} else if (state === "failed") {
core.textContent = `↓ Update to ${d.version}`;
core.title = url ? `Retry download — ${url}` : "Retry download";
core.onclick = () => { if (url) T.openUpdateDownload(url); };
} else {
core.textContent = `↓ Update to ${d.version}`;
core.title = url ? `Download ${url}` : "Download the new version";
core.onclick = () => { if (url) T.openUpdateDownload(url); };
}
});
$("upDismiss") && ($("upDismiss").onclick = () => T.dismissUpdate());
// ---- security badge + minimal registry indicator ----
// Shield colour communicates connection state at a glance:
// neutral — home / resolving (no site OR pending)
// secure — BCDN chain-verified (kind:"ok") OR https:// clearnet
// insecure — nxdomain / resolver error / plain http:// clearnet
function setBadge(d) {
const b = $("secbadge");
let cls = "secbadge", title = "Site information";
const isHttps = typeof d?.url === "string" && d.url.startsWith("https:");
const isHttp = typeof d?.url === "string" && d.url.startsWith("http:") && !isHttps;
if (!d || d.kind === "home") title = "Theseus";
else if (d.kind === "ok") { cls = "secbadge secure"; title = "Secure · BCDN via " + (d.source || "on-chain") + " — click for details"; }
else if (d.kind === "web" && isHttps) { cls = "secbadge secure"; title = "Secure · ICANN (HTTPS) — click for details"; }
else if (d.kind === "web" && isHttp) { cls = "secbadge insecure"; title = "Not secure · plain HTTP — click for details"; }
else if (d.kind === "web") title = "Connection · ICANN — click for details";
else if (d.kind === "resolving") title = "Resolving…";
else if (d.kind === "nxdomain") { cls = "secbadge insecure"; title = "Not registered on BCDN"; }
else if (d.kind === "error") { cls = "secbadge insecure"; title = "Resolution error"; }
b.className = cls; b.title = title;
}
function setReg(d) {
const r = $("reg");
if (!d || d.kind === "home") { r.hidden = true; r.innerHTML = ""; return; }
// Keep chips visible during "resolving" so the switch feels like a toggle,
// not a disappear-and-reappear (user complaint 2026-08-02).
if (d.kind === "resolving") { r.hidden = false; return; }
r.hidden = false;
// Two chips (toggle) when this host is a collision candidate — could be
// BCDN OR ICANN. Active one is coloured; inactive is greyed + clickable.
const isCand = !!d.tld && (d.kind === "ok" || d.kind === "web") && d.bcnrNativeTld === false;
const active = d.kind === "ok" ? "bcdn" : d.kind === "web" ? "icann" : null;
if (isCand) {
const bcdn = `<span class="reg bcdn ${active === "bcdn" ? "active" : "switch"}" data-c="bcdn" title="${active === "bcdn" ? "Serving from BCDN" : "Switch to BCDN"}">BCDN</span>`;
const icann = `<span class="reg icann ${active === "icann" ? "active" : "switch"}" data-c="icann" title="${active === "icann" ? "Serving from ICANN" : "Switch to ICANN"}">ICANN</span>`;
r.innerHTML = `<span class="tog">${bcdn}${icann}</span>`;
for (const chip of r.querySelectorAll(".reg.switch")) {
chip.onclick = () => T.collisionSwitch({ choice: chip.dataset.c, remember: "no" });
}
} else if (d.kind === "web") {
r.innerHTML = `<span class="reg single icann active">ICANN</span>`;
} else {
r.innerHTML = `<span class="reg single bcdn active">BCDN</span>`;
}
}
// ---- Tor ----
let torShown = false;
$("tor").onclick = () => { T.toggleTor(); if (!torShown) { $("tordisc").hidden = false; torShown = true; syncHeight(); } };
$("tordismiss").onclick = (e) => { e.preventDefault(); $("tordisc").hidden = true; syncHeight(); };
// ---- passive BCNR offer ----
T.onBcnrOffer((d) => {
const bar = $("bcnrbar");
if (d && d.host) { $("bcnrhost").textContent = d.host; $("bcnrreg").textContent = "BCDN"; bar.hidden = false; }
else bar.hidden = true;
syncHeight();
});
$("bcnropen").onclick = () => { $("bcnrbar").hidden = true; syncHeight(); T.switchToBcnr(); };
$("bcnrdismiss").onclick = (e) => { e.preventDefault(); $("bcnrbar").hidden = true; syncHeight(); };
T.onTor((d) => {
const b = $("tor"); b.className = "tor " + (d.state === "on" ? "on" : d.state === "connecting" ? "connecting" : "");
b.textContent = d.state === "on" ? "🧅 Tor: On" : d.state === "connecting" ? "🧅 connecting…" : "🧅 Tor: Off";
});
// Address-picker pick: the picker fires "address-pick" which routes to
// navigateTab, but the tabs event's focus-guard would leave the typed
// query in place if the URL input still had DOM focus. Force the full
// picked URL into the bar and drop focus so subsequent tabs events
// paint the loaded URL cleanly. Also arm a short-lived override that
// makes the next onTabs event overwrite the URL bar even if the input
// still shows focused — belt-and-braces for cases where blur() is
// async and the tabs event arrives first.
let overrideUrlBarUntil = 0;
T.onAddressPicked && T.onAddressPicked((url) => {
try { $("url").blur(); } catch (e) {}
$("url").value = String(url || "");
overrideUrlBarUntil = Date.now() + 1500;
});
// ---- tabs ----
T.onTabs((d) => {
$("back").disabled = !d.canBack; $("fwd").disabled = !d.canForward;
// loading indicator: progress bar + reload↔stop button
$("loadbar").classList.toggle("on", !!d.loading);
// Any tabs event that fires while no menu is open is a good moment to
// reset the chrome view height to the natural body-scrollHeight — this
// eliminates the "chrome stays huge after a menu action" gap that could
// otherwise linger if a menu-close path missed syncHeight().
if (!document.querySelector(".ctxmenu, .grouppop")) syncHeight();
const rb = $("reload");
if (d.loading) { rb.innerHTML = STOP_SVG; rb.title = "Stop"; rb.onclick = () => T.stop(); }
else { rb.innerHTML = RELOAD_SVG; rb.title = "Reload (Shift-click: hard reload)"; rb.onclick = (ev) => T.reload(ev.shiftKey); }
if (document.activeElement !== $("url") || Date.now() < overrideUrlBarUntil) $("url").value = d.url || "";
current.url = d.url || "";
const active = d.tabs.find((t) => t.active);
current.title = active ? active.title : "";
current.favicon = active ? (active.favicon || null) : null;
updateStar();
const box = $("tabs");
// Render tabs with group awareness. For every group, emit a chip BEFORE
// the group's first tab. If the group is collapsed, tabs inside it are
// hidden and the chip shows the count as "[● 3]"; expanded chips show
// just the color dot.
const collapsed = new Set(d.collapsedGroups || []);
const groupsSeen = new Set();
const groupsCount = {};
for (const t of d.tabs) if (t.group) groupsCount[t.group] = (groupsCount[t.group] || 0) + 1;
box.innerHTML = d.tabs.map((t) => {
let out = "";
// Chip appears once, at the FIRST tab of the group in render order.
if (t.group && !groupsSeen.has(t.group)) {
groupsSeen.add(t.group);
const isCollapsed = collapsed.has(t.group);
out += `<button class="gchip g-${t.group}${isCollapsed ? " collapsed" : ""}" data-group="${t.group}" title="${isCollapsed ? "Expand" : "Collapse"} ${t.group} group"><span class="gcdot g-${t.group}"></span>${isCollapsed ? `<span class="gccnt">${groupsCount[t.group]}</span>` : ""}</button>`;
}
// Skip rendering the tab itself if its group is collapsed.
if (t.group && collapsed.has(t.group)) return out;
// Loading spinner takes the icon slot while a page is loading, then
// hands it back to the favicon once page-favicon-updated fires.
const icon = t.loading
? '<span class="spin"></span>'
: (t.favicon ? `<img class="fav" src="${String(t.favicon).replace(/"/g,"&quot;")}" onerror="this.remove()">` : '');
const mute = t.muted ? `<span class="mute" title="Muted">🔇</span>` : "";
const tt = ((t.title || "New Tab") + (t.url ? "\n" + t.url : "")).replace(/"/g, "&quot;");
out += `<div class="tab ${t.active ? "active" : ""}${t.group ? " grp g-" + t.group : ""}" data-id="${t.id}" draggable="true" title="${tt}">${icon}<span class="t">${(t.title||"New Tab").replace(/</g,"&lt;")}</span>${mute}<span class="x" data-close="${t.id}">✕</span></div>`;
return out;
}).join("") +
`<span class="newtab" id="newtab">+</span>`;
// dragId is shared between tab-reorder handlers and group-chip drop
// handlers; declared here so both scopes see the same identity.
let dragId = null;
// Group chip clicks:
// collapsed chip → show a vertical popover listing the group's tabs
// expanded chip → toggle to collapsed
// Also acts as a drop target: drag any tab onto a chip to assign it
// to that group.
box.querySelectorAll(".gchip").forEach((chip) => {
chip.onclick = (ev) => {
ev.stopPropagation();
const color = chip.dataset.group;
if (chip.classList.contains("collapsed")) {
openGroupPopover(chip, color, d);
} else {
T.tabGroupToggle && T.tabGroupToggle(color);
}
};
chip.addEventListener("dragover", (ev) => {
if (dragId == null) return;
ev.preventDefault(); ev.dataTransfer.dropEffect = "move";
chip.classList.add("droptarget");
});
chip.addEventListener("dragleave", () => chip.classList.remove("droptarget"));
chip.addEventListener("drop", (ev) => {
ev.preventDefault();
chip.classList.remove("droptarget");
if (dragId != null) T.tabGroup(dragId, chip.dataset.group);
dragId = null;
});
});
box.querySelectorAll(".tab").forEach((el) => el.onclick = (e) => {
if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close));
else T.switchTab(Number(el.dataset.id));
});
// Right-click a tab → floating menu (Reload / Duplicate / Group / Add to
// Bookmarks / Mute / Close). Menu closes on any other click.
box.querySelectorAll(".tab").forEach((el) => el.addEventListener("contextmenu", (e) => {
e.preventDefault();
const id = Number(el.dataset.id);
const t = d.tabs.find((x) => x.id === id);
if (!t) return;
openTabContextMenu(e.clientX, e.clientY, t);
}));
// Drag-reorder — HTML5 drag events. Drop-side chosen by whether the pointer
// is on the left or right half of the target tab (matches Chrome UX).
// dragId already declared above so group-chip drop targets share it.
box.querySelectorAll(".tab").forEach((row) => {
row.addEventListener("dragstart", (e) => {
dragId = Number(row.dataset.id);
try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(dragId)); } catch {}
row.classList.add("dragging");
});
row.addEventListener("dragend", () => {
row.classList.remove("dragging");
box.querySelectorAll(".tab").forEach((r) => r.classList.remove("dropbefore", "dropafter"));
dragId = null;
});
row.addEventListener("dragover", (e) => {
if (dragId == null || Number(row.dataset.id) === dragId) return;
e.preventDefault(); e.dataTransfer.dropEffect = "move";
const r = row.getBoundingClientRect();
const before = (e.clientX - r.left) < r.width / 2;
row.classList.toggle("dropbefore", before);
row.classList.toggle("dropafter", !before);
});
row.addEventListener("dragleave", () => row.classList.remove("dropbefore", "dropafter"));
row.addEventListener("drop", (e) => {
e.preventDefault();
const targetId = Number(row.dataset.id);
if (dragId == null || dragId === targetId) return;
const r = row.getBoundingClientRect();
const before = (e.clientX - r.left) < r.width / 2;
T.moveTab(dragId, targetId, before ? "before" : "after");
});
});
$("newtab").onclick = () => T.newTab();
});
// ---- Tab context menu (right-click a tab) ----
const TAB_GROUP_COLORS = [
{ id: "red", label: "Red" }, { id: "orange", label: "Orange" },
{ id: "yellow", label: "Yellow" }, { id: "green", label: "Green" },
{ id: "cyan", label: "Cyan" }, { id: "blue", label: "Blue" },
{ id: "purple", label: "Purple" },
];
function closeAllMenus() {
document.querySelectorAll(".ctxmenu, .grouppop").forEach((m) => m.remove());
// Menus were rendered outside the chrome's normal flow (position:fixed),
// so closing them means the natural chrome height wins again.
syncHeight();
}
// Vertical dropdown of a collapsed group's tabs. Click a row → switch to
// that tab (and expand the group so the newly-active tab is visible in
// the strip). The X on a row closes just that tab. Popover closes on
// outside click via the shared document listener at the bottom.
function openGroupPopover(chip, color, d) {
closeAllMenus();
const rect = chip.getBoundingClientRect();
const pop = document.createElement("div");
pop.className = "grouppop";
pop.dataset.group = color;
const groupTabs = d.tabs.filter((t) => t.group === color);
const header = document.createElement("div");
header.className = "gph";
header.innerHTML = '<span class="gcdot g-' + color + '"></span><span>' + color + ' group · ' + groupTabs.length + ' tab' + (groupTabs.length === 1 ? '' : 's') + '</span>';
pop.appendChild(header);
for (const t of groupTabs) {
const row = document.createElement("div");
row.className = "gpit" + (t.active ? " active" : "");
const fav = t.favicon
? '<img class="gfav" src="' + String(t.favicon).replace(/"/g, "&quot;") + '" onerror="this.remove()">'
: '<span class="gfav" style="display:inline-block"></span>';
row.innerHTML = fav + '<span class="gt">' + String(t.title || "New Tab").replace(/</g, "&lt;") + '</span><span class="gx" data-close="' + t.id + '" title="Close tab">✕</span>';
row.addEventListener("click", (ev) => {
if (ev.target.dataset.close) {
ev.stopPropagation();
T.closeTab(Number(ev.target.dataset.close));
// Rebuild popover after close (next onTabs update will refresh anyway).
return;
}
// Switch to this tab and expand the group so it's visible in the strip.
T.switchTab(t.id);
if (T.tabGroupToggle) T.tabGroupToggle(color); // was collapsed, now expand
closeAllMenus();
});
pop.appendChild(row);
}
pop.style.left = Math.round(rect.left) + "px";
pop.style.top = Math.round(rect.bottom + 4) + "px";
document.body.appendChild(pop);
const r = pop.getBoundingClientRect();
if (r.right > window.innerWidth) pop.style.left = Math.max(4, window.innerWidth - r.width - 4) + "px";
growChromeForMenu();
}
function openTabContextMenu(x, y, t) {
closeAllMenus();
const m = document.createElement("div");
m.className = "ctxmenu";
m.style.left = x + "px"; m.style.top = y + "px";
// Build items
const item = (label, cls, fn) => {
const el = document.createElement("div");
el.className = "mi" + (cls ? " " + cls : "");
el.innerHTML = label;
el.onclick = (ev) => { ev.stopPropagation(); if (fn) fn(); closeAllMenus(); };
return el;
};
m.appendChild(item("Reload", "", () => T.tabReload(t.id)));
m.appendChild(item("Duplicate", "", () => T.tabDuplicate(t.id)));
// Group submenu — hover/click opens a second menu next to the first.
const gp = item("Group " + (t.group ? '<span class="swatch g-' + t.group + '"></span>' : ""), "sub", null);
gp.onclick = (ev) => {
ev.stopPropagation();
const rect = gp.getBoundingClientRect();
openGroupSubmenu(rect.right + 2, rect.top, t);
};
m.appendChild(gp);
// Add to Bookmarks (disabled if the tab has no url — e.g. Home).
const bmDisabled = !t.url;
m.appendChild(item("Add to Bookmarks", bmDisabled ? "off" : "", bmDisabled ? null : () => T.tabBookmark(t.id)));
m.appendChild(item(t.muted ? "Unmute" : "Mute", "", () => T.tabMute(t.id)));
const sep = document.createElement("div"); sep.className = "sep"; m.appendChild(sep);
m.appendChild(item("Close", "danger", () => T.closeTab(t.id)));
document.body.appendChild(m);
// Clamp horizontally to the viewport. VERTICALLY: the chrome view has a
// fixed height (main.js CHROME_H), which clips anything below that line.
// Grow the chrome view so the menu is fully visible — restored on close.
const r = m.getBoundingClientRect();
if (r.right > window.innerWidth) m.style.left = Math.max(4, window.innerWidth - r.width - 4) + "px";
growChromeForMenu();
}
function growChromeForMenu() {
// Wait a frame so all newly-added overlays contribute to the bounding box.
requestAnimationFrame(() => {
let needed = document.body.scrollHeight;
for (const m of document.querySelectorAll(".ctxmenu, .grouppop")) {
const r = m.getBoundingClientRect();
needed = Math.max(needed, Math.ceil(r.bottom) + 4);
}
T.setChromeHeight(needed);
});
}
function openGroupSubmenu(x, y, t) {
document.querySelectorAll(".ctxmenu.sub2").forEach((m) => m.remove());
const m = document.createElement("div");
m.className = "ctxmenu sub2";
m.style.left = x + "px"; m.style.top = y + "px";
const row = (html, fn) => {
const el = document.createElement("div");
el.className = "mi";
el.innerHTML = html;
el.onclick = (ev) => { ev.stopPropagation(); fn(); closeAllMenus(); };
m.appendChild(el);
};
row('<span class="swatch" style="background:transparent;border:1px dashed var(--line2)"></span>None' + (t.group ? "" : " ✓"), () => T.tabGroup(t.id, null));
for (const c of TAB_GROUP_COLORS) {
row('<span class="swatch g-' + c.id + '"></span>' + c.label + (t.group === c.id ? " ✓" : ""), () => T.tabGroup(t.id, c.id));
}
document.body.appendChild(m);
const r = m.getBoundingClientRect();
if (r.right > window.innerWidth) m.style.left = Math.max(4, x - r.width - 6) + "px";
growChromeForMenu();
}
document.addEventListener("click", (e) => {
// Close any open menus on outside click.
if (!e.target.closest(".ctxmenu, .grouppop, .gchip")) closeAllMenus();
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeAllMenus(); });
// ---- provenance ----
T.onNav((d) => {
setBadge(d); setReg(d);
if (document.activeElement !== $("url")) {
if (d.kind === "home") $("url").value = "";
else if (d.host && !$("url").value) $("url").value = d.host;
}
// Favorites bar shows only on the new-tab / home page (hides once a page loads).
// Bookmarks bar was originally home-only; users adding a bookmark from
// a real tab saw nothing visible. Keep it visible on every tab so the
// add / remove has an immediate on-screen effect.
$("bookmarks").hidden = false;
syncHeight();
});
syncHeight();
// Toolbar sizing: URL bar + search box widths follow user preference.
// Applied as data-attrs on .bar so CSS handles the layout swap.
// urlBarWidthPx / searchBoxWidthPx (integers > 0) come from the drag
// handles; when present they OVERRIDE the discrete size preset. Set to
// null (or 0) to restore preset behavior.
function applyBarSizes(s) {
if (!s) return;
const bar = $("bar");
if (s.urlBarSize) bar.dataset.urlsize = s.urlBarSize;
if (s.searchBoxSize) bar.dataset.searchsize = s.searchBoxSize;
const uw = Number(s.urlBarWidthPx) || 0;
if (uw > 0) { bar.dataset.urlwidth = "1"; bar.style.setProperty("--urlbar-w", uw + "px"); }
else { delete bar.dataset.urlwidth; bar.style.removeProperty("--urlbar-w"); }
const sw = Number(s.searchBoxWidthPx) || 0;
if (sw > 0) { bar.dataset.searchwidth = "1"; bar.style.setProperty("--searchbox-w", sw + "px"); }
else { delete bar.dataset.searchwidth; bar.style.removeProperty("--searchbox-w"); }
// The URL/search width change squeezes the extension dock; the RO on
// #extbuttons doesn't always fire (overflow:hidden parent), so re-check
// overflow explicitly after the next layout pass. requestAnimationFrame
// isn't always enough (the CSS var propagation + reflow can straddle
// frames), hence the belt-and-braces setTimeout too.
if (typeof updateExtCollapse === "function") {
requestAnimationFrame(updateExtCollapse);
setTimeout(updateExtCollapse, 30);
setTimeout(updateExtCollapse, 120);
}
syncHeight();
}
T.getSettings && T.getSettings().then(applyBarSizes);
T.onSettingsUpdate && T.onSettingsUpdate(applyBarSizes);
// ---- Ctrl+F find-in-page bar ----
// Chrome renderer owns the UI; main proxies findInPage / stopFindInPage
// and forwards Chromium's found-in-page events back so we can render
// "N of M". Enter jumps to next, Shift+Enter to previous, Esc closes.
const findbar = $("findbar");
const findInput = $("findInput");
const findCount = $("findCount");
let findQuery = "";
function openFind() {
findbar.hidden = false;
syncHeight();
setTimeout(() => { findInput.focus(); findInput.select(); }, 0);
// Re-highlight the current query if there was one.
if (findInput.value.trim()) T.findInPage && T.findInPage(findInput.value.trim(), { findNext: true });
}
function closeFind() {
findbar.hidden = true;
findQuery = "";
findCount.textContent = "";
findInput.classList.remove("nomatch");
T.findStop && T.findStop();
syncHeight();
}
function stepFind(forward) {
const q = findInput.value.trim();
if (!q) return;
findQuery = q;
T.findInPage && T.findInPage(q, { forward, findNext: true });
}
T.onFindOpen && T.onFindOpen(openFind);
T.onFindResult && T.onFindResult((r) => {
if (r.matches === 0) {
findCount.textContent = "0";
findInput.classList.add("nomatch");
} else {
findCount.textContent = r.activeMatchOrdinal + " / " + r.matches;
findInput.classList.remove("nomatch");
}
});
findInput.addEventListener("input", () => {
const q = findInput.value.trim();
if (!q) { T.findStop && T.findStop(); findCount.textContent = ""; findInput.classList.remove("nomatch"); return; }
// findNext: false → new query starts a fresh search from the top.
T.findInPage && T.findInPage(q, { findNext: q === findQuery, forward: true });
findQuery = q;
});
findInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); stepFind(!e.shiftKey); }
else if (e.key === "Escape") { e.preventDefault(); closeFind(); }
});
$("findPrev").onclick = () => stepFind(false);
$("findNext").onclick = () => stepFind(true);
$("findClose").onclick = closeFind;
// Drag handles: right edge of .urlwrap resizes the URL bar; left edge of
// .searchbox resizes the search box. Persists to settings.urlBarWidthPx /
// searchBoxWidthPx on release, which then wins over the size preset.
function makeDragHandle(handleEl, targetEl, opts) {
let startX = 0, startW = 0, dragging = false, lastW = 0;
handleEl.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
dragging = true;
startX = e.clientX;
startW = targetEl.getBoundingClientRect().width;
handleEl.setPointerCapture(e.pointerId);
handleEl.classList.add("dragging");
e.preventDefault();
});
handleEl.addEventListener("pointermove", (e) => {
if (!dragging) return;
const dx = e.clientX - startX;
// Left-edge handles (search) grow when dragged LEFT (dx < 0).
const w = Math.round(Math.max(opts.min, Math.min(opts.max, startW + (opts.reverseX ? -dx : dx))));
lastW = w;
opts.applyLive(w);
});
const stop = (e) => {
if (!dragging) return;
dragging = false;
handleEl.classList.remove("dragging");
try { handleEl.releasePointerCapture(e.pointerId); } catch {}
if (lastW > 0 && T.setSetting) T.setSetting(opts.settingKey, lastW);
};
handleEl.addEventListener("pointerup", stop);
handleEl.addEventListener("pointercancel", stop);
}
const barEl2 = $("bar");
makeDragHandle($("urldrag"), $("bar").querySelector(".urlwrap"), {
min: 200, max: 1800, settingKey: "urlBarWidthPx", reverseX: false,
applyLive: (w) => { barEl2.dataset.urlwidth = "1"; barEl2.style.setProperty("--urlbar-w", w + "px");
if (typeof updateExtCollapse === "function") updateExtCollapse(); },
});
makeDragHandle($("searchdrag"), $("bar").querySelector(".searchbox"), {
min: 120, max: 800, settingKey: "searchBoxWidthPx", reverseX: true,
applyLive: (w) => { barEl2.dataset.searchwidth = "1"; barEl2.style.setProperty("--searchbox-w", w + "px");
if (typeof updateExtCollapse === "function") updateExtCollapse(); },
});
// Self-heal: some menu-close paths (drag interruptions, focus flips) can
// leave the chrome view taller than the natural body-scrollHeight. Every
// 750 ms with no menu open, re-sync — main.js only re-layouts when the
// value actually changes, so this is a cheap no-op in the steady state.
setInterval(() => { if (!document.querySelector(".ctxmenu, .grouppop")) syncHeight(); }, 750);
</script>
</body>
</html>