diff --git a/chrome.html b/chrome.html index d848578..cc1c1a2 100644 --- a/chrome.html +++ b/chrome.html @@ -242,27 +242,27 @@ .secbadge.insecure { color: #f6768a; } .secbadge.warn { color: #f6ad55; } /* kept for legacy call sites */ input { padding: 7px 6px; border-radius: 999px; border: none; background: transparent; color: inherit; font-size: 13.5px; outline: none; } - #url { flex: 1; } - /* Registry indicator at the END of the address bar. Two shapes: - - Toggle (segmented control) when this host could be served by EITHER - registry — a single pill split in half, active side filled, inactive - side transparent and clickable to switch. - - Single pill when there's no alternative (pure ICANN or a BCNR-unique TLD).*/ - #reg { display: inline-flex; align-items: center; } - .reg { font-size: 10.5px; letter-spacing: .03em; padding: 2px 10px; white-space: nowrap; - cursor: default; user-select: none; transition: background .12s, color .12s, border-color .12s; } - /* Single-chip form (non-collision hosts) */ - .reg.single { border-radius: 999px; border: 1px solid; } - .reg.single.bcdn.active { background: rgb(from var(--acid) r g b / .15); color: var(--acid); border-color: color-mix(in srgb, var(--acid) 33%, transparent); } - .reg.single.icann.active { background: rgba(76,158,255,.15); color: #4c9eff; border-color: #4c9eff55; } - /* Toggle (segmented control) form for collision candidates */ - .tog { display: inline-flex; border-radius: 999px; overflow: hidden; border: 1px solid #ffffff26; } - .tog .reg { border: 0; } - .tog .reg + .reg { border-left: 1px solid #ffffff26; } - .tog .reg.active.bcdn { background: rgb(from var(--acid) r g b / .18); color: var(--acid); } - .tog .reg.active.icann { background: rgba(76,158,255,.18); color: #4c9eff; } - .tog .reg.switch { background: transparent; color: #7b8494; cursor: pointer; } - .tog .reg.switch:hover { color: #e5e7eb; background: rgba(255,255,255,.05); } + /* min-width: 0 is what keeps the trailing controls INSIDE the pill. An + has an intrinsic minimum (~20 characters) that a flex item + won't shrink below by default, so on a narrow window the input held + its width and pushed the registry button and the star out past the + bar's right edge. */ + #url { flex: 1 1 0; min-width: 0; } + .secbadge, .pwchip, .star, .regbtn { flex: none; } + /* Ariadne's Thread button at the END of the address bar. One icon in + place of the old BCDN/ICANN chips: tinted acid when the page is served + from BCDN, blue for ICANN, neutral otherwise. A small caret marks a + collision candidate (the name exists on both registries). Click opens + a native menu: switch registry, remember the choice, collision policy, + resolver settings. */ + .regbtn { display: inline-flex; align-items: center; gap: 1px; height: 24px; padding: 0 3px 0 4px; border-radius: 6px; + border: none; background: transparent; cursor: pointer; color: var(--dim); } + .regbtn:hover { background: var(--line2); } + .regbtn svg { width: 18px; height: 18px; } + .regbtn.bcdn { color: var(--acid); } + .regbtn.icann { color: #4c9eff; } + .regbtn .cv { display: none; font-size: 8px; color: var(--dim); } + .regbtn.cand .cv { display: inline; } .star { border: none; background: transparent; cursor: pointer; color: var(--dim); font-size: 15px; padding: 2px 4px; border-radius: 6px; } .star:hover { background: var(--line2); color: var(--ink); } .star.on { color: #ffd23d; } /* Update-available chip — shows when the releases manifest names a newer @@ -410,7 +410,12 @@ - + @@ -1060,30 +1065,27 @@ else if (d.kind === "error") { cls = "secbadge insecure"; title = "Resolution error"; } b.className = cls; b.title = title; } + // Ariadne's Thread button: tint = registry serving the page, caret = the + // name exists on both registries. The menu itself is native (main.js + // registry-menu-popup) so it can't be clipped by the chrome view. function setReg(d) { const r = $("reg"); - if (!d || d.kind === "home") { r.hidden = true; r.innerHTML = ""; return; } - // Keep chips visible during "resolving" so the switch feels like a toggle, + if (!d || d.kind === "home") { r.hidden = true; r.className = "regbtn"; r.title = "Ariadne's Thread"; return; } + // Keep the last state during "resolving" so a switch reads as a toggle, // not a disappear-and-reappear (user complaint 2026-08-02). if (d.kind === "resolving") { r.hidden = false; return; } r.hidden = false; - // 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 = `BCDN`; - const icann = `ICANN`; - r.innerHTML = `${bcdn}${icann}`; - 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 = `ICANN`; - } else { - r.innerHTML = `BCDN`; - } + const on = d.kind === "ok" ? "bcdn" : d.kind === "web" ? "icann" : ""; + r.className = "regbtn" + (on ? " " + on : "") + (isCand ? " cand" : ""); + r.title = on === "bcdn" ? "Serving from BCDN" + (isCand ? " · also exists on ICANN" : "") + " — click for options" + : on === "icann" ? "Serving from ICANN" + (isCand ? " · also exists on BCDN" : "") + " — click for options" + : "Ariadne's Thread — click for options"; } + $("reg").onclick = () => { + const b = $("reg").getBoundingClientRect(); + if (T.registryMenuPopup) T.registryMenuPopup({ x: Math.round(b.left), y: Math.round(b.bottom + 2) }).catch(() => {}); + }; // ---- Tor ---- let torShown = false; diff --git a/link-status.html b/link-status.html index 9f82665..63e7b2b 100644 --- a/link-status.html +++ b/link-status.html @@ -8,15 +8,24 @@ color: #b9c2d0; font-size: 11.5px; padding: 4px 10px 4px 8px; display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: ltr; } + /* Off-screen twin used only to measure the URL's natural width. The + visible pill is capped at 100% of this view, so measuring IT would + just report whatever width the view already has — the pill could + never grow past its first 100px and long links got cut short. */ + .measure { position: absolute; left: 0; top: 0; visibility: hidden; max-width: none; } @media (prefers-color-scheme: light) { .pill { background: #ffffff; color: #3c4453; border-color: rgba(0,0,0,.15); } } -
+ diff --git a/main.js b/main.js index 9b90b5d..dcaefa5 100644 --- a/main.js +++ b/main.js @@ -2118,7 +2118,11 @@ function positionPwFill() { function positionLinkStatus() { if (!linkStatus || !win) return; const { width, height } = win.getContentBounds(); - const w = Math.min(Math.max(120, linkStatusW), Math.max(200, width - 20)); + // The pill may grow to (almost) the full width of the tab area — long + // URLs stay readable — and ellipsises past that. It never runs under + // the sidebar. + const tabW = Math.max(0, width - (sidebarVisible ? sidebarW : 0)); + const w = Math.min(Math.max(120, linkStatusW), Math.max(200, tabW - 16)); const h = Math.max(20, linkStatusH); linkStatus.setBounds({ x: 0, y: Math.max(0, height - h), width: w, height: h }); } @@ -3618,10 +3622,11 @@ ipcMain.handle("reload", (_e, hard) => { }); ipcMain.handle("stop", () => { const t = activeTab(); try { t?.view.webContents.stop(); } catch {} setLoading(t, false); }); ipcMain.handle("toggle-tor", () => { torState === "off" ? startTor() : stopTor(); }); -ipcMain.handle("open-settings", (_e, section) => { - // Optional section slug ("passwords", "addons", …) lands directly on - // that sidebar entry when the page loads. settings.html watches for a - // fragment on load and an IPC message when already loaded. +// Open (or focus) the Settings tab. Optional section slug ("passwords", +// "addons", …) lands directly on that sidebar entry when the page loads. +// settings.html watches for a fragment on load and an IPC message when +// already loaded. +function openSettingsTab(section) { const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i.test(section) ? section.toLowerCase() : ""; const ex = tabs.find((t) => t.settings); if (ex) { @@ -3630,7 +3635,8 @@ ipcMain.handle("open-settings", (_e, section) => { return; } createTab(null, { settings: true, settingsSection: slug }); -}); +} +ipcMain.handle("open-settings", (_e, section) => openSettingsTab(section)); ipcMain.handle("toggle-site-info", (_e, rect) => { if (popVisible) return showPopover(false); if (rect) popPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; @@ -3642,7 +3648,8 @@ ipcMain.handle("popover-resize", (_e, h) => { popH = Math.max(90, Math.min(380, // ---- Collision-mode: per-tab live switcher + policy control ----------------- // Flip the active tab between BCNR and ICANN for its current host, optionally // remembering the choice per-name / per-TLD (like the OS "Open with…" flow). -ipcMain.handle("collision-switch", async (_e, arg) => { +ipcMain.handle("collision-switch", (_e, arg) => switchRegistry(arg)); +async function switchRegistry(arg) { const t = activeTab(); if (!t?.prov?.host) return null; const host = t.prov.host, tld = tldOf(host); // Accept both "bcdn" (client-facing product name) and "bcnr" (internal key). @@ -3683,6 +3690,54 @@ ipcMain.handle("collision-switch", async (_e, arg) => { finally { t.internalNav = false; setLoading(t, false); } if (t.id === activeId) pushNav(t.prov); emitTabs(); +} +// Ariadne's Thread menu — the registry button at the end of the address +// bar. Replaces the BCDN/ICANN segmented chips: one icon that never +// overflows the bar, a native popup with the switch, the per-name / per-TLD +// memory, the collision policy, and a jump to the resolver settings. +ipcMain.handle("registry-menu-popup", (e, rect) => { + if (chrome && e.sender !== chrome.webContents) throw new Error("registry-menu-popup: untrusted sender"); + const t = activeTab(); + const prov = t?.prov || null; + const host = String(prov?.host || ""); + const tld = String(prov?.tld || (host ? tldOf(host) : "") || ""); + const onBcdn = prov?.kind === "ok"; + const onIcann = prov?.kind === "web"; + const isCand = !!tld && (onBcdn || onIcann) && !isBcnrNativeTld(tld); + const current = onBcdn ? "bcnr" : "icann"; + const policyItem = (value, label) => ({ + label, type: "radio", checked: settings.collisionPolicy === value, + click: () => { settings.collisionPolicy = value; saveSettings(); }, + }); + const template = [ + { label: host ? `Ariadne's Thread · ${host}` : "Ariadne's Thread", enabled: false }, + { type: "separator" }, + { label: "Serve from BCDN", type: "radio", checked: onBcdn, enabled: isCand || onBcdn, + click: () => { if (!onBcdn) switchRegistry({ choice: "bcnr", remember: "no" }).catch(() => {}); } }, + { label: "Serve from ICANN", type: "radio", checked: onIcann, enabled: isCand || onIcann, + click: () => { if (!onIcann) switchRegistry({ choice: "icann", remember: "no" }).catch(() => {}); } }, + { label: "Remember", enabled: !!host, submenu: [ + { label: host ? `Always open ${host} this way` : "Always open this site this way", enabled: isCand, + click: () => rememberCollision(host, tld, current, "name") }, + { label: tld ? `Always open .${tld} names this way` : "Always open this TLD this way", enabled: isCand, + click: () => rememberCollision(host, tld, current, "tld") }, + { type: "separator" }, + { label: "Forget remembered choices", click: () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); } }, + ] }, + { type: "separator" }, + { label: "When a name exists on both registries", submenu: [ + policyItem("bcnr-first", "BCDN first"), + policyItem("icann-first", "ICANN first"), + policyItem("soft", "Ask each time"), + ] }, + { type: "separator" }, + { label: "Ariadne's Thread settings…", click: () => openSettingsTab("plugins") }, + ]; + const popup = Menu.buildFromTemplate(template); + const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 }; + const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0))); + const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0))); + popup.popup({ window: win, x, y }); }); ipcMain.handle("collision-state", () => ({ policy: settings.collisionPolicy, diff --git a/preload.js b/preload.js index 387480c..78fb883 100644 --- a/preload.js +++ b/preload.js @@ -24,7 +24,7 @@ contextBridge.exposeInMainWorld("theseus", { reload: (hard) => ipcRenderer.invoke("reload", !!hard), stop: () => ipcRenderer.invoke("stop"), toggleTor: () => ipcRenderer.invoke("toggle-tor"), - openSettings: () => ipcRenderer.invoke("open-settings"), + openSettings: (section) => ipcRenderer.invoke("open-settings", section), getSettings: () => ipcRenderer.invoke("settings-get"), setSetting: (key, val) => ipcRenderer.invoke("settings-set", key, val), onSettingsUpdate: (cb) => ipcRenderer.on("settings-update", (_e, d) => cb(d)), @@ -87,6 +87,9 @@ contextBridge.exposeInMainWorld("theseus", { // layering entirely. Pass the button's viewport-rect so main can anchor. toolbarMenuPopup: (addonId, rect) => ipcRenderer.invoke("toolbar-menu-popup", addonId, rect), tabContextMenuPopup: (tabId, rect) => ipcRenderer.invoke("tab-context-menu-popup", tabId, rect), + // Ariadne's Thread menu behind the registry button at the end of the + // address bar (BCDN/ICANN switch, remembered choices, policy, settings). + registryMenuPopup: (rect) => ipcRenderer.invoke("registry-menu-popup", rect), // Chrome listens so it can drop the "active" tint when the native menu // dismisses (Esc, outside click, item picked — main fires all three). onToolbarMenuClosed: (cb) => ipcRenderer.on("toolbar-menu-closed", () => cb()),