Setup 935d637ae19ea821f7e89b0f9a802b4e774b6d1ae3254a70f3e5f17f89424177 Portable bb6bea253d6f32f86e8fdd152cf4c969cf311c991329d5e94fce31215ecf7f00 Chrome-view height gap: any tabs event that fires while no menu/popover is open now normalises the chrome-view height via syncHeight(). A leaked menu-close path could previously leave the strip inflated; the next tabs update guarantees it shrinks back to the natural body-scrollHeight. Tab-group drag-and-drop: group chips are now valid drop targets in the same drag session as tab reorder. Dragging any tab onto a chip and dropping assigns that tab to the chip's group (which also auto-clusters it via the existing tab-group handler in main). Chip highlights acid green while a valid drop hovers. Collapsed group vertical popover: click a collapsed group chip and a floating panel opens below it, listing every tab in the group. Each row shows the favicon + title + a ✕ to close that tab. Clicking a row switches to the tab AND expands the group so the newly-active tab appears in the strip (tabGroupToggle). Popover closes on outside click or Escape; the outside-click filter also ignores clicks inside .gchip so opening the popover doesn't immediately close it. growChromeForMenu() and closeAllMenus() now include .grouppop in their overlay queries so the popover contributes to chrome-view sizing and gets cleaned up alongside the ctxmenus. Deployed: scp + sia-upload of both trees. Verified LIVE 0.3.5.
890 lines
52 KiB
HTML
890 lines
52 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; }
|
|
body { margin: 0; height: 100vh; 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; }
|
|
/* 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 */
|
|
.urlwrap { flex: 1; display: flex; align-items: center; gap: 4px; background: var(--surface); border: 1px solid var(--line);
|
|
border-radius: 999px; padding: 0 6px 0 4px; }
|
|
.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 .bt { overflow: hidden; text-overflow: ellipsis; }
|
|
.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">
|
|
<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>
|
|
<button class="ic" id="sidebarbtn" title="Toggle extension sidebar" hidden>
|
|
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M10 3 L10 13"/></svg>
|
|
</button>
|
|
<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);
|
|
|
|
// ---- add-on sidebar toggle ----
|
|
// Only surfaces the button when at least one add-on has registered a
|
|
// sidebar panel; a fresh install with no add-ons keeps the toolbar clean.
|
|
const sideBtn = $("sidebarbtn");
|
|
function renderSidebarState(d) {
|
|
if (!d) return;
|
|
const has = Array.isArray(d.panels) && d.panels.length > 0;
|
|
sideBtn.hidden = !has;
|
|
sideBtn.classList.toggle("active", !!d.visible);
|
|
if (has && d.panels[0]) sideBtn.title = "Toggle " + d.panels[0].title;
|
|
}
|
|
sideBtn.onclick = () => T.toggleSidebar && T.toggleSidebar();
|
|
T.sidebarState && T.sidebarState().then(renderSidebarState);
|
|
T.onSidebarState && T.onSidebarState(renderSidebarState);
|
|
|
|
// ---- favorites bar (new-tab page only) ----
|
|
let current = { url: "", title: "" }, bookmarks = [];
|
|
function renderBookmarks() {
|
|
const box = $("bookmarks");
|
|
box.innerHTML = bookmarks.length
|
|
? bookmarks.map((b, i) => `<div class="bm" data-i="${i}" title="${(b.url||"").replace(/"/g,""")}"><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);
|
|
});
|
|
updateStar(); syncHeight();
|
|
}
|
|
// 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: () => {
|
|
const nt = prompt("Edit bookmark title", bm.title || bm.url);
|
|
if (nt != null && nt.trim() && nt !== bm.title) {
|
|
// No dedicated update IPC yet — round-trip: remove + re-add with new title at same URL.
|
|
T.removeBookmark(bm.url).then(() => T.addBookmark({ title: nt.trim(), url: bm.url }));
|
|
}
|
|
}});
|
|
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 }) });
|
|
} 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 });
|
|
};
|
|
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 : "";
|
|
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>` : "";
|
|
out += `<div class="tab ${t.active ? "active" : ""}${t.group ? " grp g-" + t.group : ""}" data-id="${t.id}" draggable="true">${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();
|
|
</script>
|
|
</body>
|
|
</html>
|