Two new settings under Settings > Appearance > Toolbar: urlBarSize: wide (default) | medium | compact searchBoxSize: hidden | compact | normal (default) | wide Applied as data-attrs on the .bar element in chrome.html; CSS switches the flex-basis of .urlwrap and the width of .searchbox. min-width on .urlwrap guards against squeezing the URL invisible. Settings-set now broadcasts settings-update to the chrome renderer, so resizing takes effect live without a relaunch. Same channel is exposed for future chrome-side settings. Motivation: the extension dock grew a per-addon button per install, and the URL bar (flex:1) had been eating all the remaining space. Users who want more room for extensions can now shrink or hide the search box and cap the URL bar width.
1009 lines
60 KiB
HTML
1009 lines
60 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);
|
|
}
|
|
@media (prefers-color-scheme: light) {
|
|
:root {
|
|
--bg:#e9ecf1; --surface:#ffffff; --surface2:#f1f3f7; --active:#dbe1ec;
|
|
--ink:#1a1f28; --mut:#3c4453; --dim:#697280; --faint:#98a1b0;
|
|
--line:rgba(0,0,0,.18); --line2:rgba(0,0,0,.11); --hover:rgba(0,0,0,.06);
|
|
}
|
|
}
|
|
[hidden] { display: none !important; }
|
|
/* No `height: 100vh` — the chrome view is sized to CHROME_H by main.js,
|
|
and body took that height, so document.body.scrollHeight always equalled
|
|
the current CHROME_H. syncHeight() then just re-sent CHROME_H, so any
|
|
grow (for a menu) could never shrink back. Letting body size to its
|
|
content means scrollHeight is the natural chrome height, and syncHeight
|
|
shrinks correctly after every menu-close. */
|
|
body { margin: 0; background: var(--bg); color: var(--ink); user-select: none; overflow: hidden; }
|
|
.tabs { display: flex; align-items: flex-end; gap: 4px; padding: 6px 8px 0; height: 34px; overflow: hidden; }
|
|
/* Same-size tabs: each tab claims an equal share of the row, capped at
|
|
200px so 2 tabs don't stretch across the whole window. min-width lets
|
|
many tabs shrink cleanly while still showing a couple letters. */
|
|
.tab { display: flex; align-items: center; gap: 8px; flex: 1 1 0; max-width: 200px; min-width: 60px; padding: 6px 10px;
|
|
background: var(--surface); border: 1px solid var(--line2); border-bottom: none; border-radius: 8px 8px 0 0;
|
|
font-size: 12.5px; cursor: default; color: var(--mut); user-select: none; }
|
|
.tab.dragging { opacity: .45; cursor: grabbing; }
|
|
.tab.dropbefore { box-shadow: -2px 0 0 var(--acid); }
|
|
.tab.dropafter { box-shadow: 2px 0 0 var(--acid); }
|
|
.tab.active { background: var(--active); color: var(--ink); }
|
|
.tab .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
|
.tab .fav { width: 14px; height: 14px; flex: none; border-radius: 2px; object-fit: contain; }
|
|
.tab .spin { width: 10px; height: 10px; flex: none; border: 1.5px solid #ffffff2e; border-top-color: #d6ff3d; 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: rgba(214,255,61,.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: rgba(214,255,61,.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: rgba(214,255,61,.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. */
|
|
.extdock { display: flex; gap: 2px; align-items: center; padding-left: 4px; margin-left: 2px;
|
|
border-left: 1px solid var(--line); }
|
|
.extbtn { width: 30px; height: 30px; display: 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: rgba(214,255,61,.12); color: var(--acid); box-shadow: inset 0 -2px 0 var(--acid); }
|
|
.extbtn.soon { opacity: .45; cursor: help; }
|
|
.extbtn.soon:hover { opacity: .7; background: var(--line2); }
|
|
/* downloads button — a small badge sits over the icon when there's activity */
|
|
.dlbtn { position: relative; }
|
|
.dlbtn.spin::before { content: ""; position: absolute; inset: 4px; border-radius: 999px;
|
|
border: 1.5px solid transparent; border-top-color: #4b7bec; animation: dlspin .9s infinite linear; }
|
|
@keyframes dlspin { to { transform: rotate(360deg); } }
|
|
.dlbadge { position: absolute; top: 2px; right: 2px; min-width: 14px; height: 14px; padding: 0 3px;
|
|
background: #4b7bec; color: #fff; border-radius: 7px; font-size: 9.5px; font-weight: 700;
|
|
display: grid; place-items: center; line-height: 1; box-shadow: 0 0 0 2px var(--bg); }
|
|
.dlbtn.done .dlbadge { background: #4fd1a5; }
|
|
.dlbtn.err .dlbadge { background: #f6768a; }
|
|
/* indeterminate loading bar under the toolbar — collapses when idle so it
|
|
doesn't take a permanent 2px strip below the address bar */
|
|
.loadbar { height: 0; overflow: hidden; background: transparent; transition: height .12s linear; }
|
|
.loadbar.on { height: 2px; }
|
|
.loadbar.on::after { content: ""; display: block; height: 100%; width: 35%; border-radius: 2px;
|
|
background: linear-gradient(90deg, transparent, #4b7bec, #d6ff3d, transparent); animation: loadslide 1.1s infinite linear; }
|
|
@keyframes loadslide { 0% { transform: translateX(-110%); } 100% { transform: translateX(400%); } }
|
|
/* address bar. flex:1 by default consumes remaining space; user can cap
|
|
it via Settings > Appearance > Address bar size so the extension dock
|
|
gets more room. min-width guards against squeezing the URL invisible. */
|
|
.urlwrap { flex: 1 1 auto; min-width: 200px; display: flex; align-items: center; gap: 4px; background: var(--surface); border: 1px solid var(--line);
|
|
border-radius: 999px; padding: 0 6px 0 4px; }
|
|
.bar[data-urlsize="medium"] .urlwrap { flex: 0 1 640px; }
|
|
.bar[data-urlsize="compact"] .urlwrap { flex: 0 1 400px; }
|
|
.bar[data-searchsize="hidden"] .searchbox { display: none; }
|
|
.bar[data-searchsize="compact"] .searchbox { width: 180px; }
|
|
.bar[data-searchsize="wide"] .searchbox { width: 400px; }
|
|
.urlwrap:focus-within { border-color: #4b7bec; }
|
|
/* Site security shield — always present. Colour communicates state:
|
|
neutral (default) for home / resolving, green when the connection is
|
|
secure (BCNR chain-verified OR https:// clearnet), red when it isn't
|
|
(nxdomain / resolver error / plain http). Shape stays constant so the
|
|
button doesn't jump around when the state changes. */
|
|
.secbadge { display: grid; place-items: center; width: 28px; height: 26px; border-radius: 6px; border: none;
|
|
background: transparent; cursor: pointer; padding: 0; color: var(--dim); }
|
|
.secbadge:hover { background: var(--line2); }
|
|
.secbadge svg { width: 16px; height: 16px; fill: currentColor; }
|
|
.secbadge.secure { color: #3fb950; } /* GitHub-style saturated green */
|
|
.secbadge.insecure { color: #f6768a; }
|
|
.secbadge.warn { color: #f6ad55; } /* kept for legacy call sites */
|
|
input { padding: 7px 6px; border-radius: 999px; border: none; background: transparent; color: inherit; font-size: 13.5px; outline: none; }
|
|
#url { flex: 1; }
|
|
/* Registry indicator at the END of the address bar. Two shapes:
|
|
- Toggle (segmented control) when this host could be served by EITHER
|
|
registry — a single pill split in half, active side filled, inactive
|
|
side transparent and clickable to switch.
|
|
- Single pill when there's no alternative (pure ICANN or a BCNR-unique TLD).*/
|
|
#reg { display: inline-flex; align-items: center; }
|
|
.reg { font-size: 10.5px; letter-spacing: .03em; padding: 2px 10px; white-space: nowrap;
|
|
cursor: default; user-select: none; transition: background .12s, color .12s, border-color .12s; }
|
|
/* Single-chip form (non-collision hosts) */
|
|
.reg.single { border-radius: 999px; border: 1px solid; }
|
|
.reg.single.bcdn.active { background: rgba(214,255,61,.15); color: #d6ff3d; border-color: #d6ff3d55; }
|
|
.reg.single.icann.active { background: rgba(76,158,255,.15); color: #4c9eff; border-color: #4c9eff55; }
|
|
/* Toggle (segmented control) form for collision candidates */
|
|
.tog { display: inline-flex; border-radius: 999px; overflow: hidden; border: 1px solid #ffffff26; }
|
|
.tog .reg { border: 0; }
|
|
.tog .reg + .reg { border-left: 1px solid #ffffff26; }
|
|
.tog .reg.active.bcdn { background: rgba(214,255,61,.18); color: #d6ff3d; }
|
|
.tog .reg.active.icann { background: rgba(76,158,255,.18); color: #4c9eff; }
|
|
.tog .reg.switch { background: transparent; color: #7b8494; cursor: pointer; }
|
|
.tog .reg.switch:hover { color: #e5e7eb; background: rgba(255,255,255,.05); }
|
|
.star { border: none; background: transparent; cursor: pointer; color: var(--dim); font-size: 15px; padding: 2px 4px; border-radius: 6px; }
|
|
.star:hover { background: var(--line2); color: var(--ink); } .star.on { color: #ffd23d; }
|
|
/* Update-available chip — shows when the releases manifest names a newer
|
|
version than this build. Two buttons: main body opens the download URL
|
|
in the system browser; ✕ dismisses for the session. */
|
|
.upchip { display: inline-flex; align-items: center; gap: 0; background: rgba(214,255,61,.14);
|
|
border: 1px solid #d6ff3d55; border-radius: 999px; color: #eaffb0; 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: rgba(214,255,61,.10); }
|
|
.upchip .updismiss { background: transparent; border: none; border-left: 1px solid #d6ff3d33;
|
|
color: inherit; opacity: .55; padding: 5px 8px; cursor: pointer; font-size: 11px; }
|
|
.upchip .updismiss:hover { opacity: 1; }
|
|
/* Password-fill chip — only visible when the vault is unlocked AND the
|
|
current site has matching credentials. Count sits to the left of the key. */
|
|
.pwchip { display: inline-flex; align-items: center; gap: 3px; border: none; background: transparent; color: #3fb950; cursor: pointer; padding: 2px 5px; border-radius: 6px; font-size: 11px; font-weight: 700; }
|
|
.pwchip:hover { background: rgba(63,185,80,.14); }
|
|
.pwchip svg { width: 14px; height: 14px; }
|
|
/* search box: engine icon (click = pick engine) + a wide typing area */
|
|
.searchbox { display: flex; align-items: center; width: 300px; background: var(--surface); border: 1px solid var(--line);
|
|
border-radius: 999px; padding: 0 12px 0 3px; }
|
|
.searchbox:focus-within { border-color: #4b7bec; }
|
|
/* dropdown button shows the SELECTED engine's favicon (emoji fallback) + a caret */
|
|
.engine-btn { display: flex; align-items: center; gap: 3px; height: 30px; border: none; cursor: pointer;
|
|
border-radius: 999px 0 0 999px; padding: 0 4px 0 8px; background: transparent; }
|
|
.engine-btn:hover { background-color: var(--hover); }
|
|
.engine-btn #engineIcon { display: grid; place-items: center; width: 16px; height: 16px; }
|
|
.engine-btn .eimg { width: 16px; height: 16px; border-radius: 3px; }
|
|
.engine-btn .esym { font-size: 14px; line-height: 1; }
|
|
.engine-btn .cv { font-size: 8px; color: var(--dim); }
|
|
#search { flex: 1; padding: 7px 4px; }
|
|
.tor { padding: 7px 10px; border-radius: 8px; border: 1px solid var(--line); background: #2b2f3a; color: var(--ink); cursor: pointer; white-space: nowrap; font-size: 12.5px; }
|
|
.tor.connecting { background: #7a5c14; } .tor.on { background: #6b3fa0; }
|
|
.logo { display: flex; align-items: center; gap: 6px; font-weight: 700; color: #d6ff3d; white-space: nowrap;
|
|
padding: 6px 12px; border: 1px solid #d6ff3d33; border-radius: 8px; cursor: pointer; background: rgba(214,255,61,.08); font-size: 13px; }
|
|
.logo:hover { background: rgba(214,255,61,.16); }
|
|
.logo .gear { font-size: 12px; opacity: .8; }
|
|
/* favorites bar — new-tab page only, single compact row */
|
|
.bookmarks { display: flex; align-items: center; gap: 4px; padding: 3px 10px 5px; height: 26px; overflow: hidden; }
|
|
.bm { display: flex; align-items: center; gap: 6px; max-width: 180px; padding: 3px 9px; border-radius: 6px;
|
|
background: var(--surface2); border: 1px solid var(--hover); color: var(--mut); font-size: 12px; cursor: pointer; white-space: nowrap; }
|
|
.bm:hover { background: var(--active); color: var(--ink); }
|
|
.bm .bmfav { width: 14px; height: 14px; flex: none; object-fit: contain; border-radius: 2px; }
|
|
.bm .bt { overflow: hidden; text-overflow: ellipsis; }
|
|
/* In-chrome prompt modal (window.prompt is disabled in Electron BrowserViews,
|
|
so bookmark rename etc. use this instead). */
|
|
.promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 10000;
|
|
display: grid; place-items: center; }
|
|
.promptbox { background: var(--surface, #1c222c); border: 1px solid var(--line);
|
|
border-radius: 10px; padding: 14px 16px 12px; min-width: 320px; max-width: 480px;
|
|
box-shadow: 0 20px 60px #000d; color: var(--ink); }
|
|
.promptbox .plbl { font-size: 12px; color: var(--mut); margin-bottom: 8px; }
|
|
.promptbox input { width: 100%; box-sizing: border-box; padding: 7px 10px; border-radius: 6px;
|
|
background: var(--surface2, #0f1621); border: 1px solid var(--line);
|
|
color: var(--ink); font: inherit; font-size: 13px; outline: none; }
|
|
.promptbox input:focus { border-color: rgba(214,255,61,.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: rgba(214,255,61,.35); }
|
|
.promptbox .pbtn.primary { background: var(--acid, #d6ff3d); 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); }
|
|
}
|
|
.tordisc { font-size: 11.5px; color: #d9c7f2; background: #2a1c40; border-top: 1px solid #6b3fa055; padding: 5px 14px; }
|
|
.tordisc a { color: #d6ff3d; }
|
|
.bcnrbar { display: flex; align-items: center; gap: 10px; font-size: 11.5px; color: var(--mut);
|
|
background: #1a2417; border-top: 1px solid #d6ff3d33; padding: 6px 14px; }
|
|
.bcnrbar .breg { font-size: 10px; letter-spacing: .03em; padding: 1px 7px; border-radius: 999px;
|
|
background: rgba(214,255,61,.14); color: #d6ff3d; border: 1px solid #d6ff3d33; }
|
|
.bcnrbar b { color: var(--ink); }
|
|
.bcnrbar .bopen { margin-left: auto; padding: 4px 11px; border-radius: 7px; border: 1px solid #d6ff3d55;
|
|
background: rgba(214,255,61,.12); color: #eaffb0; cursor: pointer; font-size: 11.5px; }
|
|
.bcnrbar .bopen:hover { background: rgba(214,255,61,.22); }
|
|
.bcnrbar .bdismiss { color: var(--dim); text-decoration: none; } .bcnrbar .bdismiss:hover { color: var(--ink); }
|
|
</style></head>
|
|
<body>
|
|
<div class="tabs" id="tabs"></div>
|
|
<div class="bar" id="bar">
|
|
<div class="nav">
|
|
<button class="ic" id="back" title="Back"><svg viewBox="0 0 16 16"><path d="M10 3 L5 8 L10 13"/></svg></button>
|
|
<button class="ic" id="fwd" title="Forward"><svg viewBox="0 0 16 16"><path d="M6 3 L11 8 L6 13"/></svg></button>
|
|
<button class="ic" id="reload" title="Reload"><svg viewBox="0 0 16 16"><path d="M13 8 a5 5 0 1 1 -1.5 -3.6"/><path d="M13 2.5 L13 5 L10.5 5"/></svg></button>
|
|
<button class="ic" id="home" title="Home"><svg viewBox="0 0 16 16"><path d="M2.5 7.5 L8 3 L13.5 7.5"/><path d="M4 6.7 L4 13 L12 13 L12 6.7"/></svg></button>
|
|
</div>
|
|
<div class="urlwrap">
|
|
<button class="secbadge" id="secbadge" title="Site information">
|
|
<!-- Heraldic shield outline matching the reference image: flared top
|
|
shoulders, concave sides tapering to a sharp bottom point. Single
|
|
filled path, `currentColor` so state class toggles the tint. -->
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" aria-hidden="true">
|
|
<path d="M8 1.2 C6 1.2 3.5 1.7 2.5 2.3 Q2 2.6 2 3.5 L2 7 C2 11.2 4.2 13.7 8 15 C11.8 13.7 14 11.2 14 7 L14 3.5 Q14 2.6 13.5 2.3 C12.5 1.7 10 1.2 8 1.2 Z"/>
|
|
</svg>
|
|
</button>
|
|
<input id="url" placeholder="Ask a search engine or enter web address" spellcheck="false">
|
|
<span class="reg" id="reg" hidden></span>
|
|
<button class="pwchip" id="pwchip" title="Fill password for this site" hidden><span id="pwchipCount"></span><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="9" r="3.2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8.8 8.6 L14 8.6 M13.2 8.6 L13.2 11 M11.5 8.6 L11.5 10.4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
|
|
<button class="star" id="star" title="Save this page">☆</button>
|
|
</div>
|
|
<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>
|
|
<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="extbtn soon" id="aegisSoon" title="Aegis Wallet — built-in BCH wallet, coming soon" aria-disabled="true">₿</button>
|
|
</span>
|
|
<span class="upchip" id="upchip" hidden><button class="upcore" id="upDownload" title=""></button><button class="updismiss" id="upDismiss" title="Not now">✕</button></span>
|
|
<button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button>
|
|
<button class="logo" id="logo" title="Settings"><span>⛓ Theseus</span><span class="gear">⚙</span></button>
|
|
</div>
|
|
<div class="loadbar" id="loadbar"></div>
|
|
<div class="bookmarks" id="bookmarks" hidden></div>
|
|
<div id="tordisc" class="tordisc" hidden>
|
|
Onion mode hides your IP from sites and your ISP (including .bch lookups).
|
|
It is <b>not full anonymity</b> — this browser can still be fingerprinted; for that use the Tor Browser.
|
|
<a href="#" id="tordismiss">got it</a>
|
|
</div>
|
|
<div id="bcnrbar" class="bcnrbar" hidden>
|
|
<span class="breg" id="bcnrreg">BCDN</span>
|
|
<span><b id="bcnrhost"></b> also exists on <b>BCDN</b> — the decentralized registry.</span>
|
|
<button id="bcnropen" class="bopen">Open on BCDN</button>
|
|
<a href="#" id="bcnrdismiss" class="bdismiss">dismiss</a>
|
|
</div>
|
|
<script>
|
|
const $ = (id) => document.getElementById(id);
|
|
const T = window.theseus;
|
|
function syncHeight() { requestAnimationFrame(() => T.setChromeHeight(document.body.scrollHeight)); }
|
|
const RELOAD_SVG = `<svg viewBox="0 0 16 16"><path d="M13 8 a5 5 0 1 1 -1.5 -3.6"/><path d="M13 2.5 L13 5 L10.5 5"/></svg>`;
|
|
const STOP_SVG = `<svg viewBox="0 0 16 16"><path d="M4.5 4.5 L11.5 11.5 M11.5 4.5 L4.5 11.5"/></svg>`;
|
|
|
|
function goURL() {
|
|
const v = $("url").value.trim();
|
|
if (!v) return;
|
|
// Blur so the tabs-event handler can update the URL bar to the actual
|
|
// loaded page — the "don't clobber the typed text" guard would otherwise
|
|
// keep the search keyword displayed while the results are loading.
|
|
$("url").blur();
|
|
T.navigate(v);
|
|
}
|
|
// Address-bar suggestions — debounced call to main on every keystroke.
|
|
// Main computes matches from history and shows/hides a floating overlay
|
|
// WebContentsView anchored under the address bar's client rect.
|
|
let suggestTimer = null;
|
|
let pickerOpen = false;
|
|
const askSuggest = () => {
|
|
const q = $("url").value;
|
|
const r = $("url").getBoundingClientRect();
|
|
const rect = { x: Math.round(r.left), y: Math.round(r.bottom + 4), w: Math.round(r.width + 40) };
|
|
T.suggestAddress(q, rect);
|
|
// main hides itself when there are zero matches; assume open otherwise.
|
|
pickerOpen = true;
|
|
};
|
|
// Whether the user has moved the picker's highlight since last typing.
|
|
// Enter with a highlight submits THAT url (via the picker), not the
|
|
// typed text — so pressing ↓ then Enter opens the highlighted entry
|
|
// instead of doing a web search for the letters you typed.
|
|
let pickerHasCursor = false;
|
|
const debouncedSuggest = () => { clearTimeout(suggestTimer); suggestTimer = setTimeout(askSuggest, 80); };
|
|
$("url").addEventListener("input", () => { pickerHasCursor = false; debouncedSuggest(); });
|
|
$("url").addEventListener("focus", debouncedSuggest);
|
|
$("url").addEventListener("blur", () => {
|
|
// Delay: gives a click on the picker time to register before we close.
|
|
setTimeout(() => { T.closeAddressPicker(); pickerOpen = false; pickerHasCursor = false; }, 160);
|
|
});
|
|
$("url").addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") {
|
|
if (pickerOpen && pickerHasCursor) {
|
|
// Forward Enter to the picker so it navigates to the HIGHLIGHTED
|
|
// suggestion instead of running goURL against the typed text
|
|
// (which would search for the letters, not open the URL).
|
|
T.addressCursor("enter");
|
|
return;
|
|
}
|
|
T.closeAddressPicker(); pickerOpen = false; pickerHasCursor = false; goURL();
|
|
return;
|
|
}
|
|
if (e.key === "Escape") { T.closeAddressPicker(); pickerOpen = false; pickerHasCursor = false; return; }
|
|
if (!pickerOpen) return;
|
|
if (e.key === "ArrowDown") { e.preventDefault(); pickerHasCursor = true; T.addressCursor("next"); }
|
|
else if (e.key === "ArrowUp") { e.preventDefault(); pickerHasCursor = true; T.addressCursor("prev"); }
|
|
});
|
|
// Enter submits the search; keep the query visible so the user can refine
|
|
// it or search again — clearing it on submit lost context and made refining
|
|
// annoying (esp. when the engine's own results page uses its own search box).
|
|
$("search").addEventListener("keydown", (e) => { if (e.key === "Enter") { const q = $("search").value.trim(); if (q) T.search(q); } });
|
|
$("back").onclick = () => T.back();
|
|
$("fwd").onclick = () => T.forward();
|
|
// Shift-click on the reload button = hard reload (bypass cache), same
|
|
// convention as Chrome/Firefox. Keyboard alternative: Ctrl+Shift+R.
|
|
$("reload").onclick = (e) => T.reload(e.shiftKey);
|
|
$("home").onclick = () => T.goHome();
|
|
$("logo").onclick = () => T.openSettings();
|
|
$("secbadge").onclick = () => { const r = $("secbadge").getBoundingClientRect(); T.toggleSiteInfo({ x: Math.round(r.left), y: Math.round(r.bottom + 4) }); };
|
|
|
|
// ---- search-engine picker: the magnifier button opens a custom overlay
|
|
// dropdown (real favicons); here we only track the current engine's name. ----
|
|
function setEnginePlaceholder(d) {
|
|
const cur = (d.engines || []).find((e) => e.id === d.current);
|
|
$("search").placeholder = cur ? "Search with " + cur.name : "Search";
|
|
const icon = $("engineIcon");
|
|
const sym = (cur && cur.sym) || "🔍";
|
|
if (cur && cur.favicon)
|
|
icon.innerHTML = `<img class="eimg" src="${cur.favicon.replace(/"/g, """)}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'esym',textContent:'${sym}'}))">`;
|
|
else icon.innerHTML = `<span class="esym">${sym}</span>`;
|
|
}
|
|
$("engineBtn").onclick = () => {
|
|
const r = $("engineBtn").getBoundingClientRect();
|
|
T.toggleEnginePicker({ x: Math.round(r.left), y: Math.round(r.bottom + 4) });
|
|
};
|
|
T.getSearchEngines().then(setEnginePlaceholder);
|
|
T.onEngines && T.onEngines(setEnginePlaceholder);
|
|
|
|
// ---- downloads button: badge + click toggles the panel ----
|
|
const dlBtn = $("downloads"), dlBadge = $("dlbadge");
|
|
function renderDownloads(items) {
|
|
const list = items || [];
|
|
const active = list.filter((d) => d.state === "progressing").length;
|
|
const doneNew = list.filter((d) => d.state === "completed").length;
|
|
const failed = list.filter((d) => d.state === "interrupted").length;
|
|
dlBtn.classList.toggle("spin", active > 0);
|
|
dlBtn.classList.toggle("done", active === 0 && doneNew > 0 && failed === 0);
|
|
dlBtn.classList.toggle("err", active === 0 && failed > 0);
|
|
const badge = active || (doneNew + failed);
|
|
if (badge > 0) { dlBadge.hidden = false; dlBadge.textContent = badge > 99 ? "99+" : String(badge); }
|
|
else { dlBadge.hidden = true; }
|
|
}
|
|
dlBtn.onclick = () => {
|
|
const r = dlBtn.getBoundingClientRect();
|
|
T.toggleDownloads({ x: Math.round(r.right - 340), y: Math.round(r.bottom + 4) });
|
|
};
|
|
T.getDownloads && T.getDownloads().then(renderDownloads);
|
|
T.onDownloads && T.onDownloads(renderDownloads);
|
|
|
|
// ---- extension dock ----
|
|
// One toolbar button per registered addon sidebar-panel, plus a static
|
|
// "coming soon" placeholder for Aegis (the built-in BCH wallet, in
|
|
// progress). Click a live button → open the sidebar on that panel; if
|
|
// the sidebar was already open on that panel, collapse it.
|
|
const extDock = $("extdock");
|
|
const extButtons = $("extbuttons");
|
|
let lastPanels = [], sidebarOpen = false, activePanelId = null;
|
|
function renderSidebarState(d) {
|
|
if (!d) return;
|
|
lastPanels = Array.isArray(d.panels) ? d.panels : [];
|
|
sidebarOpen = !!d.visible;
|
|
activePanelId = d.active || null;
|
|
// Dock is always visible when either at least one addon panel exists
|
|
// OR the Aegis placeholder is meant to show. Aegis is always shown
|
|
// for now, so the dock is always visible.
|
|
extDock.hidden = false;
|
|
extButtons.innerHTML = lastPanels.map((p) => {
|
|
const isActive = sidebarOpen && p.panelId === activePanelId;
|
|
const icon = String(p.icon || "•");
|
|
return `<button class="extbtn${isActive ? " active" : ""}" data-panel="${p.panelId.replace(/"/g,""")}" title="${(p.title || "Extension").replace(/"/g,""")}">${icon.replace(/</g,"<")}</button>`;
|
|
}).join("");
|
|
extButtons.querySelectorAll(".extbtn").forEach((btn) => {
|
|
btn.onclick = () => {
|
|
const pid = btn.dataset.panel;
|
|
if (sidebarOpen && pid === activePanelId) { T.closeSidebar && T.closeSidebar(); }
|
|
else { T.openSidebar && T.openSidebar(pid); }
|
|
};
|
|
});
|
|
}
|
|
$("aegisSoon").onclick = () => {
|
|
// Non-blocking hint — the user asked for the placeholder to appear now
|
|
// even though the wallet ships in a later release. Once the addon is
|
|
// registered it renders as a live extbtn and this static button can
|
|
// come out.
|
|
$("aegisSoon").title = "Aegis Wallet — built-in BCH wallet, coming soon";
|
|
};
|
|
T.sidebarState && T.sidebarState().then(renderSidebarState);
|
|
T.onSidebarState && T.onSidebarState(renderSidebarState);
|
|
|
|
// ---- favorites bar (new-tab page only) ----
|
|
let current = { url: "", title: "", favicon: null }, bookmarks = [];
|
|
function renderBookmarks() {
|
|
const box = $("bookmarks");
|
|
box.innerHTML = bookmarks.length
|
|
? bookmarks.map((b, i) => {
|
|
const fav = b.favicon
|
|
? `<img class="bmfav" src="${String(b.favicon).replace(/"/g,""")}" onerror="this.remove()">`
|
|
: `<span class="bmfav" style="display:inline-block"></span>`;
|
|
return `<div class="bm" data-i="${i}" title="${(b.url||"").replace(/"/g,""")}">${fav}<span class="bt">${(b.title||b.url).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);
|
|
});
|
|
// 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());
|
|
|
|
// ---- security badge + minimal registry indicator ----
|
|
// Shield colour communicates connection state at a glance:
|
|
// neutral — home / resolving (no site OR pending)
|
|
// secure — BCDN chain-verified (kind:"ok") OR https:// clearnet
|
|
// insecure — nxdomain / resolver error / plain http:// clearnet
|
|
function setBadge(d) {
|
|
const b = $("secbadge");
|
|
let cls = "secbadge", title = "Site information";
|
|
const isHttps = typeof d?.url === "string" && d.url.startsWith("https:");
|
|
const isHttp = typeof d?.url === "string" && d.url.startsWith("http:") && !isHttps;
|
|
if (!d || d.kind === "home") title = "Theseus";
|
|
else if (d.kind === "ok") { cls = "secbadge secure"; title = "Secure · BCDN via " + (d.source || "on-chain") + " — click for details"; }
|
|
else if (d.kind === "web" && isHttps) { cls = "secbadge secure"; title = "Secure · ICANN (HTTPS) — click for details"; }
|
|
else if (d.kind === "web" && isHttp) { cls = "secbadge insecure"; title = "Not secure · plain HTTP — click for details"; }
|
|
else if (d.kind === "web") title = "Connection · ICANN — click for details";
|
|
else if (d.kind === "resolving") title = "Resolving…";
|
|
else if (d.kind === "nxdomain") { cls = "secbadge insecure"; title = "Not registered on BCDN"; }
|
|
else if (d.kind === "error") { cls = "secbadge insecure"; title = "Resolution error"; }
|
|
b.className = cls; b.title = title;
|
|
}
|
|
function setReg(d) {
|
|
const r = $("reg");
|
|
if (!d || d.kind === "home") { r.hidden = true; r.innerHTML = ""; return; }
|
|
// Keep chips visible during "resolving" so the switch feels like a toggle,
|
|
// not a disappear-and-reappear (user complaint 2026-08-02).
|
|
if (d.kind === "resolving") { r.hidden = false; return; }
|
|
r.hidden = false;
|
|
// Two chips (toggle) when this host is a collision candidate — could be
|
|
// BCDN OR ICANN. Active one is coloured; inactive is greyed + clickable.
|
|
const isCand = !!d.tld && (d.kind === "ok" || d.kind === "web") && d.bcnrNativeTld === false;
|
|
const active = d.kind === "ok" ? "bcdn" : d.kind === "web" ? "icann" : null;
|
|
if (isCand) {
|
|
const bcdn = `<span class="reg bcdn ${active === "bcdn" ? "active" : "switch"}" data-c="bcdn" title="${active === "bcdn" ? "Serving from BCDN" : "Switch to BCDN"}">BCDN</span>`;
|
|
const icann = `<span class="reg icann ${active === "icann" ? "active" : "switch"}" data-c="icann" title="${active === "icann" ? "Serving from ICANN" : "Switch to ICANN"}">ICANN</span>`;
|
|
r.innerHTML = `<span class="tog">${bcdn}${icann}</span>`;
|
|
for (const chip of r.querySelectorAll(".reg.switch")) {
|
|
chip.onclick = () => T.collisionSwitch({ choice: chip.dataset.c, remember: "no" });
|
|
}
|
|
} else if (d.kind === "web") {
|
|
r.innerHTML = `<span class="reg single icann active">ICANN</span>`;
|
|
} else {
|
|
r.innerHTML = `<span class="reg single bcdn active">BCDN</span>`;
|
|
}
|
|
}
|
|
|
|
// ---- Tor ----
|
|
let torShown = false;
|
|
$("tor").onclick = () => { T.toggleTor(); if (!torShown) { $("tordisc").hidden = false; torShown = true; syncHeight(); } };
|
|
$("tordismiss").onclick = (e) => { e.preventDefault(); $("tordisc").hidden = true; syncHeight(); };
|
|
|
|
// ---- passive BCNR offer ----
|
|
T.onBcnrOffer((d) => {
|
|
const bar = $("bcnrbar");
|
|
if (d && d.host) { $("bcnrhost").textContent = d.host; $("bcnrreg").textContent = "BCDN"; bar.hidden = false; }
|
|
else bar.hidden = true;
|
|
syncHeight();
|
|
});
|
|
$("bcnropen").onclick = () => { $("bcnrbar").hidden = true; syncHeight(); T.switchToBcnr(); };
|
|
$("bcnrdismiss").onclick = (e) => { e.preventDefault(); $("bcnrbar").hidden = true; syncHeight(); };
|
|
T.onTor((d) => {
|
|
const b = $("tor"); b.className = "tor " + (d.state === "on" ? "on" : d.state === "connecting" ? "connecting" : "");
|
|
b.textContent = d.state === "on" ? "🧅 Tor: On" : d.state === "connecting" ? "🧅 connecting…" : "🧅 Tor: Off";
|
|
});
|
|
|
|
// Address-picker pick: the picker fires "address-pick" which routes to
|
|
// navigateTab, but the tabs event's focus-guard would leave the typed
|
|
// query in place if the URL input still had DOM focus. Force the full
|
|
// picked URL into the bar and drop focus so subsequent tabs events
|
|
// paint the loaded URL cleanly. Also arm a short-lived override that
|
|
// makes the next onTabs event overwrite the URL bar even if the input
|
|
// still shows focused — belt-and-braces for cases where blur() is
|
|
// async and the tabs event arrives first.
|
|
let overrideUrlBarUntil = 0;
|
|
T.onAddressPicked && T.onAddressPicked((url) => {
|
|
try { $("url").blur(); } catch (e) {}
|
|
$("url").value = String(url || "");
|
|
overrideUrlBarUntil = Date.now() + 1500;
|
|
});
|
|
|
|
// ---- tabs ----
|
|
T.onTabs((d) => {
|
|
$("back").disabled = !d.canBack; $("fwd").disabled = !d.canForward;
|
|
// loading indicator: progress bar + reload↔stop button
|
|
$("loadbar").classList.toggle("on", !!d.loading);
|
|
// Any tabs event that fires while no menu is open is a good moment to
|
|
// reset the chrome view height to the natural body-scrollHeight — this
|
|
// eliminates the "chrome stays huge after a menu action" gap that could
|
|
// otherwise linger if a menu-close path missed syncHeight().
|
|
if (!document.querySelector(".ctxmenu, .grouppop")) syncHeight();
|
|
const rb = $("reload");
|
|
if (d.loading) { rb.innerHTML = STOP_SVG; rb.title = "Stop"; rb.onclick = () => T.stop(); }
|
|
else { rb.innerHTML = RELOAD_SVG; rb.title = "Reload (Shift-click: hard reload)"; rb.onclick = (ev) => T.reload(ev.shiftKey); }
|
|
if (document.activeElement !== $("url") || Date.now() < overrideUrlBarUntil) $("url").value = d.url || "";
|
|
current.url = d.url || "";
|
|
const active = d.tabs.find((t) => t.active);
|
|
current.title = active ? active.title : "";
|
|
current.favicon = active ? (active.favicon || null) : null;
|
|
updateStar();
|
|
const box = $("tabs");
|
|
// Render tabs with group awareness. For every group, emit a chip BEFORE
|
|
// the group's first tab. If the group is collapsed, tabs inside it are
|
|
// hidden and the chip shows the count as "[● 3]"; expanded chips show
|
|
// just the color dot.
|
|
const collapsed = new Set(d.collapsedGroups || []);
|
|
const groupsSeen = new Set();
|
|
const groupsCount = {};
|
|
for (const t of d.tabs) if (t.group) groupsCount[t.group] = (groupsCount[t.group] || 0) + 1;
|
|
box.innerHTML = d.tabs.map((t) => {
|
|
let out = "";
|
|
// Chip appears once, at the FIRST tab of the group in render order.
|
|
if (t.group && !groupsSeen.has(t.group)) {
|
|
groupsSeen.add(t.group);
|
|
const isCollapsed = collapsed.has(t.group);
|
|
out += `<button class="gchip g-${t.group}${isCollapsed ? " collapsed" : ""}" data-group="${t.group}" title="${isCollapsed ? "Expand" : "Collapse"} ${t.group} group"><span class="gcdot g-${t.group}"></span>${isCollapsed ? `<span class="gccnt">${groupsCount[t.group]}</span>` : ""}</button>`;
|
|
}
|
|
// Skip rendering the tab itself if its group is collapsed.
|
|
if (t.group && collapsed.has(t.group)) return out;
|
|
// Loading spinner takes the icon slot while a page is loading, then
|
|
// hands it back to the favicon once page-favicon-updated fires.
|
|
const icon = t.loading
|
|
? '<span class="spin"></span>'
|
|
: (t.favicon ? `<img class="fav" src="${String(t.favicon).replace(/"/g,""")}" onerror="this.remove()">` : '');
|
|
const mute = t.muted ? `<span class="mute" title="Muted">🔇</span>` : "";
|
|
const tt = ((t.title || "New Tab") + (t.url ? "\n" + t.url : "")).replace(/"/g, """);
|
|
out += `<div class="tab ${t.active ? "active" : ""}${t.group ? " grp g-" + t.group : ""}" data-id="${t.id}" draggable="true" title="${tt}">${icon}<span class="t">${(t.title||"New Tab").replace(/</g,"<")}</span>${mute}<span class="x" data-close="${t.id}">✕</span></div>`;
|
|
return out;
|
|
}).join("") +
|
|
`<span class="newtab" id="newtab">+</span>`;
|
|
// dragId is shared between tab-reorder handlers and group-chip drop
|
|
// handlers; declared here so both scopes see the same identity.
|
|
let dragId = null;
|
|
// Group chip clicks:
|
|
// collapsed chip → show a vertical popover listing the group's tabs
|
|
// expanded chip → toggle to collapsed
|
|
// Also acts as a drop target: drag any tab onto a chip to assign it
|
|
// to that group.
|
|
box.querySelectorAll(".gchip").forEach((chip) => {
|
|
chip.onclick = (ev) => {
|
|
ev.stopPropagation();
|
|
const color = chip.dataset.group;
|
|
if (chip.classList.contains("collapsed")) {
|
|
openGroupPopover(chip, color, d);
|
|
} else {
|
|
T.tabGroupToggle && T.tabGroupToggle(color);
|
|
}
|
|
};
|
|
chip.addEventListener("dragover", (ev) => {
|
|
if (dragId == null) return;
|
|
ev.preventDefault(); ev.dataTransfer.dropEffect = "move";
|
|
chip.classList.add("droptarget");
|
|
});
|
|
chip.addEventListener("dragleave", () => chip.classList.remove("droptarget"));
|
|
chip.addEventListener("drop", (ev) => {
|
|
ev.preventDefault();
|
|
chip.classList.remove("droptarget");
|
|
if (dragId != null) T.tabGroup(dragId, chip.dataset.group);
|
|
dragId = null;
|
|
});
|
|
});
|
|
box.querySelectorAll(".tab").forEach((el) => el.onclick = (e) => {
|
|
if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close));
|
|
else T.switchTab(Number(el.dataset.id));
|
|
});
|
|
// Right-click a tab → floating menu (Reload / Duplicate / Group / Add to
|
|
// Bookmarks / Mute / Close). Menu closes on any other click.
|
|
box.querySelectorAll(".tab").forEach((el) => el.addEventListener("contextmenu", (e) => {
|
|
e.preventDefault();
|
|
const id = Number(el.dataset.id);
|
|
const t = d.tabs.find((x) => x.id === id);
|
|
if (!t) return;
|
|
openTabContextMenu(e.clientX, e.clientY, t);
|
|
}));
|
|
// Drag-reorder — HTML5 drag events. Drop-side chosen by whether the pointer
|
|
// is on the left or right half of the target tab (matches Chrome UX).
|
|
// dragId already declared above so group-chip drop targets share it.
|
|
box.querySelectorAll(".tab").forEach((row) => {
|
|
row.addEventListener("dragstart", (e) => {
|
|
dragId = Number(row.dataset.id);
|
|
try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(dragId)); } catch {}
|
|
row.classList.add("dragging");
|
|
});
|
|
row.addEventListener("dragend", () => {
|
|
row.classList.remove("dragging");
|
|
box.querySelectorAll(".tab").forEach((r) => r.classList.remove("dropbefore", "dropafter"));
|
|
dragId = null;
|
|
});
|
|
row.addEventListener("dragover", (e) => {
|
|
if (dragId == null || Number(row.dataset.id) === dragId) return;
|
|
e.preventDefault(); e.dataTransfer.dropEffect = "move";
|
|
const r = row.getBoundingClientRect();
|
|
const before = (e.clientX - r.left) < r.width / 2;
|
|
row.classList.toggle("dropbefore", before);
|
|
row.classList.toggle("dropafter", !before);
|
|
});
|
|
row.addEventListener("dragleave", () => row.classList.remove("dropbefore", "dropafter"));
|
|
row.addEventListener("drop", (e) => {
|
|
e.preventDefault();
|
|
const targetId = Number(row.dataset.id);
|
|
if (dragId == null || dragId === targetId) return;
|
|
const r = row.getBoundingClientRect();
|
|
const before = (e.clientX - r.left) < r.width / 2;
|
|
T.moveTab(dragId, targetId, before ? "before" : "after");
|
|
});
|
|
});
|
|
$("newtab").onclick = () => T.newTab();
|
|
});
|
|
|
|
// ---- Tab context menu (right-click a tab) ----
|
|
const TAB_GROUP_COLORS = [
|
|
{ id: "red", label: "Red" }, { id: "orange", label: "Orange" },
|
|
{ id: "yellow", label: "Yellow" }, { id: "green", label: "Green" },
|
|
{ id: "cyan", label: "Cyan" }, { id: "blue", label: "Blue" },
|
|
{ id: "purple", label: "Purple" },
|
|
];
|
|
function closeAllMenus() {
|
|
document.querySelectorAll(".ctxmenu, .grouppop").forEach((m) => m.remove());
|
|
// Menus were rendered outside the chrome's normal flow (position:fixed),
|
|
// so closing them means the natural chrome height wins again.
|
|
syncHeight();
|
|
}
|
|
// Vertical dropdown of a collapsed group's tabs. Click a row → switch to
|
|
// that tab (and expand the group so the newly-active tab is visible in
|
|
// the strip). The X on a row closes just that tab. Popover closes on
|
|
// outside click via the shared document listener at the bottom.
|
|
function openGroupPopover(chip, color, d) {
|
|
closeAllMenus();
|
|
const rect = chip.getBoundingClientRect();
|
|
const pop = document.createElement("div");
|
|
pop.className = "grouppop";
|
|
pop.dataset.group = color;
|
|
const groupTabs = d.tabs.filter((t) => t.group === color);
|
|
const header = document.createElement("div");
|
|
header.className = "gph";
|
|
header.innerHTML = '<span class="gcdot g-' + color + '"></span><span>' + color + ' group · ' + groupTabs.length + ' tab' + (groupTabs.length === 1 ? '' : 's') + '</span>';
|
|
pop.appendChild(header);
|
|
for (const t of groupTabs) {
|
|
const row = document.createElement("div");
|
|
row.className = "gpit" + (t.active ? " active" : "");
|
|
const fav = t.favicon
|
|
? '<img class="gfav" src="' + String(t.favicon).replace(/"/g, """) + '" 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 (document.activeElement !== $("url")) {
|
|
if (d.kind === "home") $("url").value = "";
|
|
else if (d.host && !$("url").value) $("url").value = d.host;
|
|
}
|
|
// Favorites bar shows only on the new-tab / home page (hides once a page loads).
|
|
// Bookmarks bar was originally home-only; users adding a bookmark from
|
|
// a real tab saw nothing visible. Keep it visible on every tab so the
|
|
// add / remove has an immediate on-screen effect.
|
|
$("bookmarks").hidden = false;
|
|
syncHeight();
|
|
});
|
|
|
|
syncHeight();
|
|
// Toolbar sizing: URL bar + search box widths follow user preference.
|
|
// Applied as data-attrs on .bar so CSS handles the layout swap.
|
|
function applyBarSizes(s) {
|
|
if (!s) return;
|
|
const bar = $("bar");
|
|
if (s.urlBarSize) bar.dataset.urlsize = s.urlBarSize;
|
|
if (s.searchBoxSize) bar.dataset.searchsize = s.searchBoxSize;
|
|
syncHeight();
|
|
}
|
|
T.getSettings && T.getSettings().then(applyBarSizes);
|
|
T.onSettingsUpdate && T.onSettingsUpdate(applyBarSizes);
|
|
// 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>
|