theseus/chrome.html
Local Dev e8e603902c feat(pdf-editor): the dock click is the "open a file" gesture, so answer it with one
Opening a PDF cost four clicks across two screens: the dock icon, a dropdown
with a single entry in it, an editor with an empty drop zone, and finally the
Open button that produced a file browser. Three of those were the program
asking the user to confirm what they had already said by clicking a PDF icon.

The dock item now raises the file browser itself. The dialog is native and
raised from the add-on's Node side, because a file:// page cannot open one
without a user gesture of its own and a freshly-opened tab has none to spend.
The chosen file goes through the same scratch handoff a right-clicked link
already used, so the editor opens with the document in it rather than with an
invitation to find one. Cancelling still lands in the empty editor, which is
where drag-and-drop works and is probably still where someone who changed
their mind about the file wants to be.

Second click removed in the chrome: a toolbar menu holding exactly one item is
not a menu, it is a button wearing a dropdown, so it now dispatches directly.
Falls through to the popup if the renderer has no addonMenuSelect.

Not yet exercised end to end — TheseusNavigator/node_modules is empty while
another session reinstalls it, so the CDP suites cannot boot Electron. Both
files parse; run scratchpad/verify-pdf-editor/drive.mjs once the tree is back.
2026-09-22 01:44:15 +02:00

1751 lines
104 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 {
/* Chrome strip is a soft neutral grey — flat white was too bright next
to the browsed page, and pure #ffffff tabs disappeared into the
window title bar on light Windows themes. --bg tints the whole
top strip; --surface (inactive tab) sits a hair above it; --active
(foreground tab) pops a bit brighter so the current tab reads. */
--bg:#e6e8ec; --surface:#f2f4f7; --surface2:#eaecf0; --active:#ffffff;
--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; }
/* The tab row doubles as the window's title bar on Windows (main.js hides
the native frame): the row is a drag region, everything clickable in it
opts out, and --wco-reserve keeps tabs clear of the native minimise /
maximise / close overlay (set from navigator.windowControlsOverlay, or a
fixed 140px when that API isn't exposed). */
.tabs { display: flex; align-items: flex-end; padding: 6px 8px 0; padding-right: calc(8px + var(--wco-reserve, 0px));
height: 34px; overflow: hidden; -webkit-app-region: drag; }
/* The tabs sit in their own row INSIDE the reserved-for-window-controls
padding. Padding does not stop overflowing flex items, so with many
tabs the last ones used to run under the minimise / close buttons;
the row is a sized box (min-width: 0) that clips at its own edge. */
.tabrow { display: flex; align-items: flex-end; gap: 4px; flex: 1 1 auto; min-width: 0; height: 100%;
overflow-x: auto; overflow-y: hidden; scrollbar-width: none; -webkit-app-region: drag; }
.tabrow::-webkit-scrollbar { display: none; }
.tabrow > *, .tabs > .newtab, .grouppop, .ctxmenu { -webkit-app-region: no-drag; }
/* The + button sits OUTSIDE the scrolling row, so it stays reachable no
matter how many tabs are open; the row scrolls (wheel) once tabs hit
their minimum width, like Chrome. */
.tabs > .newtab { flex: none; margin-left: 2px; }
/* Firefox-style overflow: once tabs hit their minimum width the row
scrolls, the earliest tabs slide out on the left, and an arrow at each
end moves the row (they only appear when there is something to scroll). */
.tabscroll { flex: none; width: 24px; height: 28px; margin: 0 2px; padding: 0; border: 1px solid var(--line2); border-bottom: none;
border-radius: 8px 8px 0 0; background: var(--surface); color: var(--mut); cursor: pointer; font: 16px/1 system-ui, sans-serif;
-webkit-app-region: no-drag; }
.tabscroll:hover { color: var(--ink); background: var(--hover); }
.tabscroll:disabled { opacity: .3; cursor: default; }
/* 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. With many tabs
they shrink down to icon + close (34px) before anything is clipped. */
.tab { display: flex; align-items: center; gap: 8px; flex: 1 1 0; max-width: 200px; min-width: 76px; 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); }
/* The selected tab: brighter fill plus an accent stripe and outline, so it
reads at a glance among a dozen same-size tabs. A grouped tab keeps its
group colour on the stripe and still gets the accent outline. */
.tab.active { background: var(--active); color: var(--ink); border-color: rgb(from var(--acid) r g b / .7);
box-shadow: inset 0 2px 0 var(--acid); }
.tab.active.grp { box-shadow: inset 0 2px 0 var(--gc, var(--acid)); }
.tab .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
.tab .ico { display: flex; align-items: center; flex: none; }
.tab .ico:empty { display: none; }
.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. */
/* The address bar and search bar share ONE flex budget between the nav
buttons and the trailing button dock — the whole .urlsearch unit takes
whatever space is left after the fixed items. A single drag handle
between them shifts the ratio (bigger URL bar = smaller search bar,
and vice versa) without moving anything else in the toolbar. When the
window is too narrow for both, the search bar hides (see the
data-responsive rules below); the URL bar keeps working alone. */
/* margin-right creates a bit of breathing room between the search bar
and the trailing dock (Downloads, extensions, ⛓ Theseus) so they
never look like they collide. */
.urlsearch { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px; margin-right: 10px; }
.urlwrap { position: relative; flex: 0 1 var(--urlbar-w, 60%); min-width: 220px; display: flex; align-items: center; gap: 4px; background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 0 6px 0 4px; }
/* Search-box floor bumped so the "Search" placeholder always fits inside
the input — dropping below ~180px in normal font sizes was where the
placeholder text started getting clipped. */
.searchbox { flex: 1 1 0; min-width: 180px; }
/* Single drag handle sitting between the two — a thin 6px column with a
col-resize cursor. Zero-sum semantics: dragging right grows the URL
bar and shrinks the search bar; dragging left does the inverse. */
.bardrag { flex: 0 0 6px; align-self: stretch; cursor: col-resize; background: transparent; border-radius: 3px; }
.bardrag:hover, .bardrag.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,
.bar[data-responsive="1"] .bardrag,
.bar[data-responsive="2"] .bardrag,
.bar[data-responsive="3"] .bardrag { 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; flex: 1 1 auto; }
/* 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; }
/* min-width: 0 is what keeps the trailing controls INSIDE the pill. An
<input> has an intrinsic minimum (~20 characters) that a flex item
won't shrink below by default, so on a narrow window the input held
its width and pushed the registry button and the star out past the
bar's right edge. */
#url { flex: 1 1 0; min-width: 0; }
.secbadge, .pwchip, .star, .regbtn, .zoomchip { flex: none; }
/* Zoom indicator — only shown when the active tab isn't at 100 %.
Click resets (Ctrl+0); Ctrl +/- and Ctrl+wheel step the level. */
.zoomchip { border: none; background: transparent; color: var(--mut); cursor: pointer; padding: 2px 5px;
border-radius: 6px; font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; }
.zoomchip:hover { background: var(--line2); color: var(--ink); }
/* Ariadne's Thread button at the END of the address bar. One icon in
place of the old BCDN/ICANN chips: tinted acid when the page is served
from BCDN, blue for ICANN, neutral otherwise. A small caret marks a
collision candidate (the name exists on both registries). Click opens
a native menu: switch registry, remember the choice, collision policy,
resolver settings. */
.regbtn { display: inline-flex; align-items: center; gap: 1px; height: 24px; padding: 0 3px 0 4px; border-radius: 6px;
border: none; background: transparent; cursor: pointer; color: var(--dim); }
.regbtn:hover { background: var(--line2); }
.regbtn svg { width: 18px; height: 18px; }
.regbtn.bcdn { color: var(--acid); }
.regbtn.icann { color: #4c9eff; }
.regbtn .cv { display: none; font-size: 8px; color: var(--dim); }
.regbtn.cand .cv { display: inline; }
.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.
Width comes from the parent .urlsearch flex budget (flex: 1 1 0) —
the earlier top rule at line 172 sets that; here we only style. */
.searchbox { display: flex; align-items: center; 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 toggle lives on the favorites row beside the plug-in dock (same
chip height as .plugbtn); the tint still says off / connecting / on. */
.tor { display: inline-flex; align-items: center; height: 22px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--line);
background: #2b2f3a; color: var(--ink); cursor: pointer; white-space: nowrap; font: inherit; font-size: 12px; font-weight: 600; }
.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. Chips are a
uniform fixed width so the row reads as a neat grid regardless of the
underlying title length; the strip is 28px tall to fit the roomier chips. */
.bookmarks { display: flex; align-items: center; gap: 6px; padding: 4px 10px 6px; height: 28px; overflow: hidden; }
#bmlist { display: flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; overflow: hidden; }
/* Plug-in dock: first-class Silent Mode components (Aegis) get a labelled
chip at the right end of the favorites row, directly under the Theseus
menu — always visible, never collapsed with the extensions. */
.bmright { margin-left: auto; flex: none; display: flex; align-items: center; gap: 6px; padding-left: 8px; }
.plugdock { display: flex; align-items: center; gap: 4px; }
.plugbtn { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 10px 0 8px; border-radius: 6px;
font: inherit; font-size: 12px; font-weight: 600; color: var(--mut); cursor: pointer; white-space: nowrap;
border: 1px solid var(--line); background: transparent; }
.plugbtn:hover { background: var(--line2); color: var(--ink); }
.plugbtn.active { background: rgb(from var(--acid) r g b / .12); color: var(--acid); border-color: color-mix(in srgb, var(--acid) 35%, transparent); }
.plugbtn img { display: block; }
/* 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); }
/* Fixed width (not max-width) so every chip is the same size — the row
reads as a grid. Title-only labels keep this width workable; renderer
trims descriptors after " — ". */
.bm { display: flex; align-items: center; gap: 6px; width: 150px; flex: 0 0 150px; padding: 4px 10px; border-radius: 6px;
background: var(--surface2); border: 1px solid var(--hover); color: var(--mut); font-size: 12.5px; cursor: grab; white-space: nowrap; }
.bm:hover { background: var(--active); color: var(--ink); }
.bm.dragging { opacity: .45; cursor: grabbing; }
.bm.dropbefore { box-shadow: -2px 0 0 var(--acid); }
.bm.dropafter { box-shadow: 2px 0 0 var(--acid); }
.bm .bmfav { width: 14px; height: 14px; flex: none; object-fit: contain; border-radius: 2px; }
.bm .bt { overflow: hidden; text-overflow: ellipsis; flex: 1; }
/* 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); }
/* The old light-mode chips used the BCH dark #253A49 as their fill.
At small font sizes on light Windows the white-on-dark-navy label
fell below legible, so both .logo (⛓ Theseus) and .tor switch to
a light-grey chip with dark ink — same footprint, readable text.
Update pill picks up the same treatment. */
.logo { background: #eef1f5; color: #253A49; border-color: rgba(0,0,0,.14); }
.logo:hover { background: #e2e6ec; }
.logo .gear { color: #253A49; opacity: .85; }
.tor { background: #eef1f5; color: #253A49; border-color: rgba(0,0,0,.14); }
.tor:hover { background: #e2e6ec; }
.tor.connecting { background: #fbe9b5; color: #5a3f00; border-color: rgba(0,0,0,.14); }
.tor.on { background: #e6d9f5; color: #3f1f6b; border-color: rgba(0,0,0,.14); }
.upchip { background: #eef1f5; color: #253A49; border-color: rgba(0,0,0,.14); }
.upchip .upcore:hover { background: #e2e6ec; }
.upchip .updismiss { border-left-color: rgba(0,0,0,.14); color: #253A49; }
}
.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="tabstrip"><button class="tabscroll" id="tabsLeft" title="Scroll tabs left" hidden></button><div class="tabrow" id="tabs"></div><button class="tabscroll" id="tabsRight" title="Scroll tabs right" hidden></button></div>
<div class="bar" id="bar">
<div class="nav">
<button class="ic" id="back" title="Back"><svg viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round"><path d="M13 8H3M6 5L3 8l3 3"/></svg></button>
<button class="ic" id="fwd" title="Forward"><svg viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8h10M10 5l3 3-3 3"/></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="urlsearch" id="urlsearch">
<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">
<button class="zoomchip" id="zoomchip" title="Reset zoom (Ctrl+0)" hidden></button>
<button class="regbtn" id="reg" title="Ariadne's Thread" hidden>
<!-- Ariadne's thread: a spiral of semicircles (radii 1 → 5,
centres alternating 8 / 9) unwinding into a loose tail. -->
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M9 7.5 A1 1 0 0 0 7 7.5 A2 2 0 0 0 11 7.5 A3 3 0 0 0 5 7.5 A4 4 0 0 0 13 7.5 A5 5 0 0 0 3 7.5 C3 11.2 6 14.6 13.6 13.4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
<span class="cv"></span>
</button>
<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>
<div class="bardrag" id="bardrag" title="Drag to shift the address / search bar ratio"></div>
<div class="searchbox">
<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>
</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="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 id="bmlist"></div><span class="bmright"><span class="plugdock" id="plugdock" hidden></span><button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button></span></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)); }
// Reserve room for the native window-controls overlay at the right of the
// tab row (Windows only — the frame is hidden there, see main.js). Use the
// overlay's real geometry when Chromium exposes it to this view; otherwise
// the standard three 46px buttons.
(function reserveWindowControls() {
if (!/Windows/i.test(navigator.userAgent)) return;
const apply = () => {
const wco = navigator.windowControlsOverlay;
let reserve = 140;
try {
if (wco && wco.visible) { const r = wco.getTitlebarAreaRect(); reserve = Math.max(0, Math.round(window.innerWidth - (r.x + r.width))); }
} catch {}
document.documentElement.style.setProperty("--wco-reserve", reserve + "px");
};
apply();
try { navigator.windowControlsOverlay?.addEventListener("geometrychange", apply); } catch {}
window.addEventListener("resize", apply);
})();
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();
// Hold Back/Forward (or right-click) for the history list, like Chrome.
// A hold longer than 450 ms opens the native menu and swallows the click
// that would otherwise fire on release.
for (const [id, dir] of [["back", "back"], ["fwd", "forward"]]) {
const btn = $(id);
let timer = null, held = false;
const open = () => {
held = true;
const r = btn.getBoundingClientRect();
T.navHistoryMenu && T.navHistoryMenu(dir, { x: r.left, y: r.bottom });
};
btn.addEventListener("mousedown", (e) => {
if (e.button !== 0 || btn.disabled) return;
held = false; clearTimeout(timer); timer = setTimeout(open, 450);
});
const cancel = () => { clearTimeout(timer); timer = null; };
btn.addEventListener("mouseup", cancel);
btn.addEventListener("mouseleave", cancel);
btn.addEventListener("click", (e) => { if (held) { held = false; e.stopImmediatePropagation(); e.preventDefault(); } }, true);
btn.addEventListener("contextmenu", (e) => { e.preventDefault(); if (!btn.disabled) open(); held = false; });
}
// 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");
// Add-on icon → HTML. Text by default (an emoji like 🛡, or "•"). An
// add-on that wants a branded mark sets manifest.icon to a data: URI
// (svg or png) and we render it as an <img> in the same slot. Anything
// else is HTML-escaped and stays text. Used by the dock buttons, the
// collapsed dock button and the collapsed dropdown alike.
function addonIconHtml(icon, size = 18) {
const s = String(icon || "•");
if (/^data:image\//i.test(s)) return `<img src="${s.replace(/"/g,"&quot;")}" alt="" style="width:${size}px;height:${size}px;display:block;margin:0 auto">`;
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
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 = [], lastAllPanels = [], lastAllMenus = [];
let sidebarOpen = false, activePanelId = null;
let openMenuAddonId = null; // which addon's menu dropdown is currently open
function renderSidebarState(d) {
if (!d) return;
const allPanels = Array.isArray(d.panels) ? d.panels : [];
const allMenus = Array.isArray(d.toolbarMenus) ? d.toolbarMenus : [];
sidebarOpen = !!d.visible;
activePanelId = d.active || null;
// Plug-ins (Aegis) live in their own dock on the favorites row, under
// the Theseus menu; everything else is an extension in the toolbar dock.
lastAllPanels = allPanels; lastAllMenus = allMenus;
lastPanels = allPanels.filter((p) => !p.plugin);
lastMenus = allMenus.filter((m) => !m.plugin);
renderPlugDock(allPanels.filter((p) => p.plugin), allMenus.filter((m) => m.plugin));
// 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.
return `<button class="extbtn${cls}" ${dataAttr} title="${b.title.replace(/"/g,"&quot;")}">${addonIconHtml(b.icon)}</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];
// Same renderer as the full-size buttons — a data: URI icon used to be
// dumped here as text ("data:image/svg+xml…") once the dock collapsed.
$("extmore").innerHTML = first ? addonIconHtml(first.icon) : "🧩";
$("extmore").title = first ? "Extensions — " + first.title : "Extensions";
$("extmore").classList.toggle("active", (sidebarOpen && !!activePanelId) || !!openMenuAddonId);
}
// Plug-in dock (favorites row, right end). A chip per plug-in surface:
// icon + name, tinted while its sidebar panel or menu is open. Same
// actions as the toolbar dock buttons, never collapsed.
function renderPlugDock(panels, menus) {
const dock = $("plugdock");
const chips = [];
// Chip label is the plug-in's product name (e.g. "Aegis Wallet"), the
// panel title goes in the tooltip.
for (const p of panels) chips.push({ kind: "panel", panelId: p.panelId, icon: p.icon, title: p.title || "Plug-in", name: p.addonName || p.title || "Plug-in", active: sidebarOpen && p.panelId === activePanelId });
for (const m of menus) chips.push({ kind: "menu", addonId: m.addonId, icon: m.icon, title: m.title || m.addonId, name: m.title || m.addonId, active: openMenuAddonId === m.addonId });
dock.hidden = chips.length === 0;
dock.innerHTML = chips.map((c) => {
const dataAttr = c.kind === "panel" ? `data-panel="${c.panelId.replace(/"/g,"&quot;")}"` : `data-menu="${c.addonId.replace(/"/g,"&quot;")}"`;
const label = String(c.name).split(/\s+[—–-]\s+/, 1)[0].replace(/</g,"&lt;");
return `<button class="plugbtn${c.active ? " active" : ""}" ${dataAttr} title="${c.title.replace(/"/g,"&quot;")}">${addonIconHtml(c.icon, 14)}<span>${label}</span></button>`;
}).join("");
dock.querySelectorAll(".plugbtn").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);
}
};
});
}
// ---- 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: lastAllPanels, toolbarMenus: lastAllMenus });
}
}
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;
// A menu with one entry is not a menu, it is a button wearing a dropdown.
// Popping it costs the user a second click and a second target to aim at
// for a choice that was never a choice, so dispatch the item directly.
if (menu.items && menu.items.length === 1 && T.addonMenuSelect) {
T.addonMenuSelect(addonId, menu.items[0].id)
.catch((err) => console.warn("addon menu item failed:", err?.message || err));
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">${addonIconHtml(p.icon, 16)}</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">${addonIconHtml(m.icon, 16)}</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 = [];
// Site titles often end with a descriptor after " — " or " - ", e.g.
// "GAME.X — Bitcoin Cash game platform". The chip only has room for the
// leading brand token, so trim at the first em-dash / en-dash / hyphen
// that's surrounded by whitespace. The full title still shows on hover
// (tooltip below) so the descriptor isn't actually lost.
//
// Brand-case normalisation for Silent Mode's .x TLD family: sites save
// their title in whatever case they set (theseus.x, GAME.X, silentmode.x)
// — the chip renders them uniform as <Name>.X. Titles are Title-case by
// default; multi-word brand names (SilentMode, CoinSpectrum) have their
// canonical form in BRAND_CASE_X so they don't collapse to "Silentmode".
const BRAND_CASE_X = {
silentmode: "SilentMode", coinspectrum: "CoinSpectrum",
theseus: "Theseus", sirius: "Sirius", deviant: "Deviant", aegis: "Aegis",
ariadne: "Ariadne", argus: "Argus", hermes: "Hermes",
prometheus: "Prometheus", hephaestus: "Hephaestus", helios: "Helios",
atlas: "Atlas", katalogos: "Katalogos", game: "Game",
poutakidis: "Poutakidis", syskypo: "Syskypo",
};
function bookmarkBrandCase(label) {
const m = label.match(/^([a-zA-Z][a-zA-Z0-9-]*)\.x$/i);
if (!m) return label;
const key = m[1].toLowerCase();
const cased = BRAND_CASE_X[key] || (key.charAt(0).toUpperCase() + key.slice(1));
return cased + ".X";
}
function bookmarkShortLabel(title, url) {
const src = (title || url || "").trim();
if (!src) return "";
const cut = (src.split(/\s+[—–-]\s+/, 1)[0] || src);
return bookmarkBrandCase(cut);
}
function renderBookmarks() {
const box = $("bmlist");
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>`;
const short = bookmarkShortLabel(b.title, b.url);
return `<div class="bm" draggable="true" data-i="${i}" data-url="${(b.url||"").replace(/"/g,"&quot;")}" title="${((b.title||b.url)+" — "+(b.url||"")).replace(/"/g,"&quot;")}">${fav}<span class="bt">${short.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);
});
// Drag-reorder chips — same shape as the tab strip. Drop side is chosen
// by whether the pointer is on the left/right half of the target chip.
// dragUrl is the identity we hand to bookmark-move; renderBookmarks
// fires again after the reorder, which rebinds these handlers cleanly.
let dragUrl = null;
box.querySelectorAll(".bm").forEach((row) => {
row.addEventListener("dragstart", (e) => {
dragUrl = row.dataset.url;
try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", dragUrl); } catch {}
row.classList.add("dragging");
});
row.addEventListener("dragend", () => {
row.classList.remove("dragging");
box.querySelectorAll(".bm").forEach((r) => r.classList.remove("dropbefore", "dropafter"));
dragUrl = null;
});
row.addEventListener("dragover", (e) => {
if (!dragUrl || row.dataset.url === dragUrl) 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 targetUrl = row.dataset.url;
if (!dragUrl || dragUrl === targetUrl) return;
const r = row.getBoundingClientRect();
const before = (e.clientX - r.left) < r.width / 2;
T.moveBookmark && T.moveBookmark(dragUrl, targetUrl, before ? "before" : "after");
});
});
// 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 === "file") title = "Local file";
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" && d.local) { cls = "secbadge insecure"; title = "Local file could not be opened"; }
else if (d.kind === "error") { cls = "secbadge insecure"; title = "Resolution error"; }
b.className = cls; b.title = title;
}
// Ariadne's Thread button: tint = registry serving the page, caret = the
// name exists on both registries. The menu itself is native (main.js
// registry-menu-popup) so it can't be clipped by the chrome view.
function setReg(d) {
const r = $("reg");
if (!d || d.kind === "home" || d.kind === "file" || d.local) { r.hidden = true; r.className = "regbtn"; r.title = "Ariadne's Thread"; return; }
// Keep the last state during "resolving" so a switch reads as a toggle,
// not a disappear-and-reappear (user complaint 2026-08-02).
if (d.kind === "resolving") { r.hidden = false; return; }
r.hidden = false;
const isCand = !!d.tld && (d.kind === "ok" || d.kind === "web") && d.bcnrNativeTld === false;
const on = d.kind === "ok" ? "bcdn" : d.kind === "web" ? "icann" : "";
r.className = "regbtn" + (on ? " " + on : "") + (isCand ? " cand" : "");
r.title = on === "bcdn" ? "Serving from BCDN" + (isCand ? " · also exists on ICANN" : "") + " — click for options"
: on === "icann" ? "Serving from ICANN" + (isCand ? " · also exists on BCDN" : "") + " — click for options"
: "Ariadne's Thread — click for options";
}
$("reg").onclick = () => {
const b = $("reg").getBoundingClientRect();
if (T.registryMenuPopup) T.registryMenuPopup({ x: Math.round(b.left), y: Math.round(b.bottom + 2) }).catch(() => {});
};
// ---- 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;
const urlBarEditing = () => document.hasFocus() && document.activeElement === $("url");
// When focus leaves this view (the user clicked into the page or another
// window), drop the input's focus so the bar tracks the page again.
window.addEventListener("blur", () => { if (document.activeElement === $("url")) $("url").blur(); });
T.onAddressPicked && T.onAddressPicked((url) => {
try { $("url").blur(); } catch (e) {}
$("url").value = String(url || "");
overrideUrlBarUntil = Date.now() + 1500;
});
// ---- tabs ----
$("zoomchip").onclick = () => T.zoomReset && T.zoomReset();
T.onTabs((d) => {
$("back").disabled = !d.canBack; $("fwd").disabled = !d.canForward;
const zc = $("zoomchip"), z = Number(d.zoom) || 100;
zc.hidden = z === 100; zc.textContent = z + "%";
// 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); }
// Only hold the typed text while the field really has the user's focus:
// this view keeps document.activeElement on the input after the user
// clicks into the page (focus moved to the tab's own view), and the bar
// then stopped following navigations until something blurred it.
if (!urlBarEditing() || 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();
renderTabStrip($("tabs"), d);
});
// ---- tab strip: keyed reconciliation ------------------------------------
// The strip used to be rebuilt with innerHTML on every tabs event. That
// recreated every favicon <img> — a visible blink on the OTHER tabs each
// time one tab loaded, reloaded or changed title — and dropped drag/hover
// state mid-gesture. Elements are now keyed (tab id, or group colour for a
// chip), updated in place, and moved into order with insertBefore, which
// never recreates a node or reloads its image. Handlers attach once, at
// creation; they read the latest payload from lastTabsData.
let dragId = null; // shared by tab drag-reorder and group-chip drop targets
let lastTabsData = null;
function makeChip(color) {
const chip = document.createElement("button");
chip.className = "gchip g-" + color; chip.dataset.group = color; chip.dataset.key = "chip:" + color;
chip.innerHTML = `<span class="gcdot g-${color}"></span><span class="gccnt" hidden></span>`;
// collapsed chip → popover listing the group's tabs; expanded → collapse
chip.onclick = (ev) => {
ev.stopPropagation();
if (chip.classList.contains("collapsed")) openGroupPopover(chip, color, lastTabsData);
else T.tabGroupToggle && T.tabGroupToggle(color);
};
// drop target: drag any tab onto the chip to assign it to that group
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, color); dragId = null; });
return chip;
}
function makeTab(id) {
const el = document.createElement("div");
el.className = "tab"; el.dataset.id = String(id); el.dataset.key = "tab:" + id; el.draggable = true;
el.innerHTML = `<span class="ico"></span><span class="t"></span><span class="mute" title="Muted" hidden>🔇</span><span class="x" data-close="${id}">✕</span>`;
el.onclick = (e) => { if (e.target.dataset.close) T.closeTab(id); else T.switchTab(id); };
// Right-click → native OS menu popped from main.js (a DOM menu forced the
// chrome view taller and opened a gap under the toolbar).
el.addEventListener("contextmenu", (e) => { e.preventDefault(); if (T.tabContextMenuPopup) T.tabContextMenuPopup(id, { x: e.clientX, y: e.clientY }); });
// Drag-reorder — drop side chosen by pointer half (matches Chrome UX).
el.addEventListener("dragstart", (e) => { dragId = id; try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); } catch {} el.classList.add("dragging"); });
el.addEventListener("dragend", () => { el.classList.remove("dragging"); el.parentElement?.querySelectorAll(".tab").forEach((r) => r.classList.remove("dropbefore", "dropafter")); dragId = null; });
el.addEventListener("dragover", (e) => {
if (dragId == null || id === dragId) return;
e.preventDefault(); e.dataTransfer.dropEffect = "move";
const r = el.getBoundingClientRect(); const before = (e.clientX - r.left) < r.width / 2;
el.classList.toggle("dropbefore", before); el.classList.toggle("dropafter", !before);
});
el.addEventListener("dragleave", () => el.classList.remove("dropbefore", "dropafter"));
el.addEventListener("drop", (e) => {
e.preventDefault();
if (dragId == null || dragId === id) return;
const r = el.getBoundingClientRect(); const before = (e.clientX - r.left) < r.width / 2;
T.moveTab(dragId, id, before ? "before" : "after");
});
return el;
}
function updateTab(el, t) {
el.classList.toggle("active", !!t.active);
el.classList.toggle("grp", !!t.group);
for (const c of [...el.classList]) if (c.startsWith("g-") && c !== "g-" + t.group) el.classList.remove(c);
if (t.group) el.classList.add("g-" + t.group);
const tt = (t.title || "New Tab") + (t.url ? "\n" + t.url : "");
if (el.title !== tt) el.title = tt;
const label = el.querySelector(".t"); const txt = t.title || "New Tab";
if (label.textContent !== txt) label.textContent = txt;
el.querySelector(".mute").hidden = !t.muted;
// Icon slot: spinner while loading, otherwise the favicon. The <img> is
// only touched when the URL actually changes, so it never reloads.
const ico = el.querySelector(".ico");
const want = t.loading ? "spin" : (t.favicon ? "fav:" + t.favicon : "none");
if (ico.dataset.state !== want) {
ico.dataset.state = want;
if (want === "spin") ico.innerHTML = '<span class="spin"></span>';
else if (want === "none") ico.replaceChildren();
else { const img = document.createElement("img"); img.className = "fav"; img.src = t.favicon; img.onerror = () => { img.remove(); }; ico.replaceChildren(img); }
}
}
function renderTabStrip(box, d) {
lastTabsData = d;
const collapsed = new Set(d.collapsedGroups || []);
const groupsCount = {};
for (const t of d.tabs) if (t.group) groupsCount[t.group] = (groupsCount[t.group] || 0) + 1;
const existing = new Map();
for (const el of box.children) if (el.dataset.key) existing.set(el.dataset.key, el);
const order = [], seen = new Set();
for (const t of d.tabs) {
// Chip once, before the group's first tab in render order.
if (t.group && !seen.has(t.group)) {
seen.add(t.group);
const chip = existing.get("chip:" + t.group) || makeChip(t.group);
const isCollapsed = collapsed.has(t.group);
chip.classList.toggle("collapsed", isCollapsed);
chip.title = (isCollapsed ? "Expand " : "Collapse ") + t.group + " group";
const cnt = chip.querySelector(".gccnt");
cnt.hidden = !isCollapsed; if (isCollapsed) cnt.textContent = String(groupsCount[t.group]);
order.push(chip);
}
if (t.group && collapsed.has(t.group)) continue; // hidden inside a collapsed group
const el = existing.get("tab:" + t.id) || makeTab(t.id);
updateTab(el, t);
order.push(el);
}
// "+" lives next to the row, not in it (see CSS) — created once.
if (!document.getElementById("newtab")) {
const plus = document.createElement("span"); plus.className = "newtab"; plus.id = "newtab"; plus.textContent = "+"; plus.title = "New tab (Ctrl+T)";
plus.onclick = () => T.newTab();
box.parentElement.appendChild(plus);
}
const keep = new Set(order);
for (const el of [...box.children]) if (!keep.has(el)) el.remove();
order.forEach((el, i) => { if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null); });
// Bring the selected tab into view when the SELECTION changes (or a tab
// appears). Not on every tabs event — those fire on every title/favicon/
// loading change, and re-snapping then would undo the user's own
// scrolling with the arrows or the wheel.
const act = box.querySelector(".tab.active");
if (act) {
const key = act.dataset.id;
if (key !== lastScrolledKey && performance.now() > userScrollUntil && box.scrollWidth > box.clientWidth) {
const l = act.offsetLeft, r = l + act.offsetWidth;
if (l < box.scrollLeft) box.scrollLeft = l - 4;
else if (r > box.scrollLeft + box.clientWidth) box.scrollLeft = r - box.clientWidth + 4;
}
lastScrolledKey = key;
}
updateTabScroll();
}
let lastScrolledKey = null;
// While the user is scrolling the row (arrows / wheel), renders must not
// snap it back to the selected tab.
let userScrollUntil = 0;
// Arrows at both ends of the row: shown only while the row overflows,
// each disabled at its end of travel. A click moves by ~60% of the
// visible width; the wheel scrolls too.
function updateTabScroll() {
const box = $("tabs"), left = $("tabsLeft"), right = $("tabsRight");
const over = box.scrollWidth > box.clientWidth + 1;
left.hidden = right.hidden = !over;
if (!over) return;
left.disabled = box.scrollLeft <= 0;
right.disabled = box.scrollLeft >= box.scrollWidth - box.clientWidth - 1;
}
// Immediate, not animated: the chrome view has no smooth scrolling
// (scrollBy({behavior:"smooth"}) moves nothing) and animation frames
// stop whenever the window is occluded, which left a frame-driven slide
// stuck at its start.
function slideTabs(dir) {
const box = $("tabs");
userScrollUntil = performance.now() + 1500;
box.scrollLeft = Math.max(0, Math.min(box.scrollWidth - box.clientWidth, box.scrollLeft + dir * Math.round(box.clientWidth * 0.6)));
updateTabScroll(); // the scroll event itself waits for a frame
}
$("tabsLeft").onclick = () => slideTabs(-1);
$("tabsRight").onclick = () => slideTabs(1);
$("tabs").addEventListener("scroll", updateTabScroll, { passive: true });
window.addEventListener("resize", () => requestAnimationFrame(updateTabScroll));
// Wheel over the row scrolls it sideways (there is no visible scrollbar).
$("tabs").addEventListener("wheel", (e) => {
const box = $("tabs");
if (box.scrollWidth <= box.clientWidth) return;
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (!d) return;
userScrollUntil = performance.now() + 1500;
box.scrollLeft += d;
updateTabScroll();
e.preventDefault();
}, { passive: false });
// ---- 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 (!urlBarEditing()) {
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: only urlBarWidthPx now matters. The URL bar and the
// search bar share one flex budget in .urlsearch — bigger URL bar means
// smaller search bar and vice versa, without moving any of the fixed
// items on either side. Old urlBarSize / searchBoxSize / searchBoxWidthPx
// settings are ignored (kept in the settings file for backward compat
// so an older version's chrome.html can still pick them up).
function applyBarSizes(s) {
if (!s) return;
const bar = $("bar");
const uw = Number(s.urlBarWidthPx) || 0;
if (uw > 0) bar.style.setProperty("--urlbar-w", uw + "px");
else bar.style.removeProperty("--urlbar-w");
// The URL/search ratio change doesn't squeeze the extension dock any
// more (they live in different flex parents), but the responsive-hide
// rules for narrow bars still depend on updateExtCollapse's math, so
// re-check overflow after the layout settles.
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;
// Single drag handle between the address bar and the search bar (Firefox
// model): dragging right grows the URL bar and shrinks the search bar,
// dragging left inverts. Zero-sum — nothing else in the toolbar moves.
// The URL bar's width is what we control directly; the search bar just
// fills whatever's left of the .urlsearch flex budget. Persisted as
// urlBarWidthPx on release. Bounds:
// min = URL bar's own min-width (matches the 220px CSS floor)
// max = urlsearch total width handle gap minSearch(140)
// so the drag can never squeeze the search bar past its CSS floor either.
const barEl2 = $("bar");
const handleEl = $("bardrag");
const urlWrapEl = barEl2.querySelector(".urlwrap");
const urlsearchEl = $("urlsearch");
// Absolute minimums track the CSS min-widths so the drag can't push either
// bar below the size where its content stops fitting. On top of that we
// enforce a 20 % share floor for both bars: the ratio can run from 80/20
// to 20/80 (URL bar / search bar). Combined with the CSS min-widths, the
// tighter of the two rules wins at any given container width.
const MIN_URL_PX = 220, MIN_SEARCH_PX = 180, HANDLE_W = 6, GAP = 6;
const MIN_SHARE = 0.20;
let dragging = false, startX = 0, startW = 0, lastW = 0;
handleEl.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
dragging = true;
startX = e.clientX;
startW = urlWrapEl.getBoundingClientRect().width;
handleEl.setPointerCapture(e.pointerId);
handleEl.classList.add("dragging");
e.preventDefault();
});
handleEl.addEventListener("pointermove", (e) => {
if (!dragging) return;
const total = urlsearchEl.getBoundingClientRect().width;
const minUrl = Math.max(MIN_URL_PX, Math.round(total * MIN_SHARE));
const maxUrl = Math.min(
total - HANDLE_W - GAP * 2 - Math.max(MIN_SEARCH_PX, Math.round(total * MIN_SHARE)),
Math.round(total * (1 - MIN_SHARE))
);
const clampedMax = Math.max(minUrl, maxUrl);
const w = Math.round(Math.max(minUrl, Math.min(clampedMax, startW + (e.clientX - startX))));
lastW = w;
barEl2.style.setProperty("--urlbar-w", w + "px");
if (typeof updateExtCollapse === "function") updateExtCollapse();
});
const stopDrag = (e) => {
if (!dragging) return;
dragging = false;
handleEl.classList.remove("dragging");
try { handleEl.releasePointerCapture(e.pointerId); } catch {}
if (lastW > 0 && T.setSetting) T.setSetting("urlBarWidthPx", lastW);
};
handleEl.addEventListener("pointerup", stopDrag);
handleEl.addEventListener("pointercancel", stopDrag);
// 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>