Updates published after launch never showed: the add-on channel was checked once, 30 s after boot, and staged copies only applied on the next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3 and VPN 0.1.3 landed minutes after the app's only check and stayed invisible through manual scans made earlier and a restart made before they were published. Now: the check repeats every 4 hours; every check (boot, timer, manual, add-on-driven) reports what is staged to the chrome, which shows a chip for staged extensions; clicking it, or the "Update to vX" button that appears on the extension's row and detail in Settings, promotes the staged folder over the installed one and rebuilds the add-on host, so the new version runs without a restart. Plug-ins (Aegis) are excluded from the chip and the hot swap — a wallet updates from its own panel and applies on the next launch. Also from the same review: the new-tab button follows the last tab and parks after the scroll arrow only when the strip overflows; Tor sits left of the Aegis chip; plug-ins no longer appear in Settings › Extensions (they have Plug-ins); the extension detail view has a labelled Back button, a close button and a Check now button; the promotion helper returns what it promoted and accepts a filter.
1847 lines
110 KiB
HTML
1847 lines
110 KiB
HTML
<!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; }
|
||
.tabrow > .newtab { flex: none; align-self: center; 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; }
|
||
/* drag-reorder feedback, same language as tabs and bookmark chips */
|
||
.extbtn.dragging { opacity: .45; }
|
||
.extbtn.dropbefore { box-shadow: -2px 0 0 var(--acid); }
|
||
.extbtn.dropafter { box-shadow: 2px 0 0 var(--acid); }
|
||
.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>
|
||
<span class="upchip" id="xupchip" hidden><button class="upcore" id="xupRestart" title=""></button><button class="updismiss" id="xupDismiss" 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"><button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button><span class="plugdock" id="plugdock" hidden></span></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, """)}" 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,""")}" alt="" style="width:${size}px;height:${size}px;display:block;margin:0 auto">`;
|
||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||
}
|
||
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 = [], lastDock = { order: [], hidden: [] };
|
||
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 || [] });
|
||
}
|
||
// User prefs: drag-reorder order and buttons hidden via the right-click
|
||
// menu. Unknown keys keep registration order after the ordered ones.
|
||
if (d.dock) lastDock = d.dock;
|
||
const order = Array.isArray(lastDock.order) ? lastDock.order : [];
|
||
const hidden = new Set(Array.isArray(lastDock.hidden) ? lastDock.hidden : []);
|
||
const rank = (k) => { const i = order.indexOf(k); return i < 0 ? order.length : i; };
|
||
lastButtons = lastButtons.filter((b) => !hidden.has(b.key)).map((b, i) => ({ ...b, i })).sort((a, b) => rank(a.key) - rank(b.key) || a.i - b.i);
|
||
lastPanels = lastPanels.filter((p) => !hidden.has("p:" + p.panelId));
|
||
lastMenus = lastMenus.filter((m) => !hidden.has("m:" + m.addonId));
|
||
// 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,""")}"`
|
||
: `data-menu="${b.addonId.replace(/"/g,""")}"`;
|
||
// 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} data-key="${b.key.replace(/"/g,""")}" draggable="true" title="${b.title.replace(/"/g,""")}">${addonIconHtml(b.icon)}</button>`;
|
||
}).join("");
|
||
// Drag-reorder: drop side is the half of the target button the pointer
|
||
// is on; main persists the order and re-emits state, which re-renders.
|
||
let dragKey = null;
|
||
const clearDrop = () => extButtons.querySelectorAll(".dropbefore,.dropafter").forEach((x) => x.classList.remove("dropbefore", "dropafter"));
|
||
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);
|
||
}
|
||
};
|
||
btn.addEventListener("dragstart", (e) => {
|
||
dragKey = btn.dataset.key; btn.classList.add("dragging");
|
||
try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", dragKey); } catch {}
|
||
});
|
||
btn.addEventListener("dragend", () => { btn.classList.remove("dragging"); clearDrop(); dragKey = null; });
|
||
btn.addEventListener("dragover", (e) => {
|
||
if (!dragKey || btn.dataset.key === dragKey) return;
|
||
e.preventDefault();
|
||
const r = btn.getBoundingClientRect(); const after = e.clientX > r.left + r.width / 2;
|
||
clearDrop(); btn.classList.add(after ? "dropafter" : "dropbefore");
|
||
});
|
||
btn.addEventListener("dragleave", () => btn.classList.remove("dropbefore", "dropafter"));
|
||
btn.addEventListener("drop", (e) => {
|
||
if (!dragKey || btn.dataset.key === dragKey) return;
|
||
e.preventDefault();
|
||
const r = btn.getBoundingClientRect(); const after = e.clientX > r.left + r.width / 2;
|
||
const from = dragKey; clearDrop(); dragKey = null;
|
||
T.dockMove && T.dockMove(from, btn.dataset.key, after ? "after" : "before");
|
||
});
|
||
// Right-click: native menu owned by main (open/close, move, hide, turn off…).
|
||
btn.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault(); e.stopPropagation();
|
||
closeToolbarMenu();
|
||
const r = btn.getBoundingClientRect();
|
||
T.dockMenu && T.dockMenu(btn.dataset.key, { x: r.left, y: r.bottom });
|
||
});
|
||
});
|
||
// 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,""")}"` : `data-menu="${c.addonId.replace(/"/g,""")}"`;
|
||
const label = String(c.name).split(/\s+[—–-]\s+/, 1)[0].replace(/</g,"<");
|
||
return `<button class="plugbtn${c.active ? " active" : ""}" ${dataAttr} title="${c.title.replace(/"/g,""")}">${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,""")}"><span class="ic">${addonIconHtml(p.icon, 16)}</span><span class="lbl">${(p.title || "Extension").replace(/</g,"<")}</span></div>`);
|
||
}
|
||
for (const m of lastMenus) {
|
||
rows.push(`<div class="epit" data-menu="${m.addonId.replace(/"/g,""")}"><span class="ic">${addonIconHtml(m.icon, 16)}</span><span class="lbl">${(m.title || m.addonId).replace(/</g,"<")}</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");
|
||
// Right-click on the dock's empty space (or the collapsed button): the
|
||
// same menu without a per-button section — "Show hidden" + "Manage".
|
||
extDockEl.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
closeToolbarMenu();
|
||
T.dockMenu && T.dockMenu(null, { x: e.clientX, y: e.clientY });
|
||
});
|
||
// 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,""")}" 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,""")}" title="${((b.title||b.url)+" — "+(b.url||"")).replace(/"/g,""")}">${fav}<span class="bt">${short.replace(/</g,"<")}</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,"<")}</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, "<")}</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());
|
||
|
||
// ---- staged extension updates chip ----
|
||
// Main sends "addon-updates" { staged: [{id,name,version}] } after every
|
||
// update check (boot, 4-hourly, manual) — extensions only, plug-ins update
|
||
// from inside their own panel. Clicking installs the staged versions in
|
||
// place (no restart). Dismiss hides this exact set; a new version staged
|
||
// later brings the chip back.
|
||
let xupDismissedKey = "";
|
||
function renderAddonUpdates(d) {
|
||
const chip = $("xupchip"); if (!chip) return;
|
||
const staged = (d && Array.isArray(d.staged)) ? d.staged : [];
|
||
const key = staged.map((s) => s.id + "@" + s.version).sort().join(",");
|
||
if (!staged.length || key === xupDismissedKey) { chip.hidden = true; return; }
|
||
chip.hidden = false;
|
||
const core = $("xupRestart");
|
||
const names = staged.map((s) => (s.name || s.id) + " " + s.version).join(", ");
|
||
core.textContent = staged.length === 1 ? `↻ Update ${staged[0].name || staged[0].id} to ${staged[0].version}` : `↻ ${staged.length} extension updates — install`;
|
||
core.title = `Signed updates ready: ${names}. Installs them now, no restart needed.`;
|
||
core.onclick = async () => {
|
||
core.textContent = "↻ Installing…"; core.onclick = null;
|
||
try { await (T.applyStagedAddons ? T.applyStagedAddons() : Promise.resolve()); } catch {}
|
||
// main re-sends "addon-updates" with whatever is still pending.
|
||
};
|
||
$("xupDismiss").onclick = () => { xupDismissedKey = key; chip.hidden = true; };
|
||
}
|
||
T.onAddonUpdates && T.onAddonUpdates(renderAddonUpdates);
|
||
T.stagedAddons && T.stagedAddons().then((list) => renderAddonUpdates({ staged: list || [] })).catch(() => {});
|
||
|
||
// ---- 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);
|
||
}
|
||
// "+" follows the last tab (Firefox/Chrome style) while the row fits;
|
||
// once tabs overflow it parks after the right scroll arrow so it stays
|
||
// reachable. placeNewTab() moves it between the two spots — created once.
|
||
let plus = document.getElementById("newtab");
|
||
if (!plus) {
|
||
plus = document.createElement("span"); plus.className = "newtab"; plus.id = "newtab"; plus.textContent = "+"; plus.title = "New tab (Ctrl+T)";
|
||
plus.onclick = () => T.newTab();
|
||
box.appendChild(plus);
|
||
}
|
||
const keep = new Set(order); keep.add(plus);
|
||
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); });
|
||
if (plus.parentElement === box && box.lastElementChild !== plus) box.appendChild(plus);
|
||
placeNewTab();
|
||
// 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.
|
||
// The + button: inside the row after the last tab while everything fits,
|
||
// after the right arrow once the row overflows. Measured with the button
|
||
// out of the row so its own width can't flip the decision back and forth.
|
||
function placeNewTab() {
|
||
const box = $("tabs"), plus = $("newtab"); if (!plus) return;
|
||
const strip = box.parentElement;
|
||
const tabsWidth = [...box.children].filter((el) => el !== plus).reduce((w, el) => w + el.getBoundingClientRect().width + 4, 0);
|
||
const over = tabsWidth + (plus.getBoundingClientRect().width || 28) > box.clientWidth + 1;
|
||
if (over && plus.parentElement === box) strip.appendChild(plus);
|
||
else if (!over && plus.parentElement !== box) box.appendChild(plus);
|
||
}
|
||
function updateTabScroll() {
|
||
const box = $("tabs"), left = $("tabsLeft"), right = $("tabsRight");
|
||
placeNewTab();
|
||
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, """) + '" onerror="this.remove()">'
|
||
: '<span class="gfav" style="display:inline-block"></span>';
|
||
row.innerHTML = fav + '<span class="gt">' + String(t.title || "New Tab").replace(/</g, "<") + '</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>
|