feat(theseus/chrome): Ariadne's Thread registry menu, address-bar overflow fix, full-width link pill

- Link-status pill: it measured its own width inside a view already
  capped at 100 px, so it could never grow and long hrefs were cut short.
  An off-screen twin now reports the natural width; main caps it to the
  tab area (never under the sidebar) and the pill ellipsises past that.
- Address bar at narrow widths: the URL input's intrinsic minimum width
  pushed the registry chips and the star out past the bar. #url now has
  min-width: 0 and the trailing controls are fixed-size flex items.
- The BCDN/ICANN segmented chips are replaced by one Ariadne's Thread
  icon (spiral + tail) at the end of the bar: acid when served from BCDN,
  blue for ICANN, caret when the name exists on both. Click opens a
  native menu (registry-menu-popup): switch registry, remember per name /
  per TLD, forget choices, collision policy, and a jump to the Plug-ins
  settings section. Reuses the existing switch / remember / policy paths
  (collision-switch body extracted to switchRegistry, open-settings to
  openSettingsTab). preload's openSettings now forwards a section slug.
This commit is contained in:
Local Dev 2026-09-09 11:40:46 +02:00
parent c9a3db26ce
commit 8a99c0959c
4 changed files with 119 additions and 50 deletions

View file

@ -242,27 +242,27 @@
.secbadge.insecure { color: #f6768a; } .secbadge.insecure { color: #f6768a; }
.secbadge.warn { color: #f6ad55; } /* kept for legacy call sites */ .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; } input { padding: 7px 6px; border-radius: 999px; border: none; background: transparent; color: inherit; font-size: 13.5px; outline: none; }
#url { flex: 1; } /* min-width: 0 is what keeps the trailing controls INSIDE the pill. An
/* Registry indicator at the END of the address bar. Two shapes: <input> has an intrinsic minimum (~20 characters) that a flex item
- Toggle (segmented control) when this host could be served by EITHER won't shrink below by default, so on a narrow window the input held
registry — a single pill split in half, active side filled, inactive its width and pushed the registry button and the star out past the
side transparent and clickable to switch. bar's right edge. */
- Single pill when there's no alternative (pure ICANN or a BCNR-unique TLD).*/ #url { flex: 1 1 0; min-width: 0; }
#reg { display: inline-flex; align-items: center; } .secbadge, .pwchip, .star, .regbtn { flex: none; }
.reg { font-size: 10.5px; letter-spacing: .03em; padding: 2px 10px; white-space: nowrap; /* Ariadne's Thread button at the END of the address bar. One icon in
cursor: default; user-select: none; transition: background .12s, color .12s, border-color .12s; } place of the old BCDN/ICANN chips: tinted acid when the page is served
/* Single-chip form (non-collision hosts) */ from BCDN, blue for ICANN, neutral otherwise. A small caret marks a
.reg.single { border-radius: 999px; border: 1px solid; } collision candidate (the name exists on both registries). Click opens
.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); } a native menu: switch registry, remember the choice, collision policy,
.reg.single.icann.active { background: rgba(76,158,255,.15); color: #4c9eff; border-color: #4c9eff55; } resolver settings. */
/* Toggle (segmented control) form for collision candidates */ .regbtn { display: inline-flex; align-items: center; gap: 1px; height: 24px; padding: 0 3px 0 4px; border-radius: 6px;
.tog { display: inline-flex; border-radius: 999px; overflow: hidden; border: 1px solid #ffffff26; } border: none; background: transparent; cursor: pointer; color: var(--dim); }
.tog .reg { border: 0; } .regbtn:hover { background: var(--line2); }
.tog .reg + .reg { border-left: 1px solid #ffffff26; } .regbtn svg { width: 18px; height: 18px; }
.tog .reg.active.bcdn { background: rgb(from var(--acid) r g b / .18); color: var(--acid); } .regbtn.bcdn { color: var(--acid); }
.tog .reg.active.icann { background: rgba(76,158,255,.18); color: #4c9eff; } .regbtn.icann { color: #4c9eff; }
.tog .reg.switch { background: transparent; color: #7b8494; cursor: pointer; } .regbtn .cv { display: none; font-size: 8px; color: var(--dim); }
.tog .reg.switch:hover { color: #e5e7eb; background: rgba(255,255,255,.05); } .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 { 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; } .star:hover { background: var(--line2); color: var(--ink); } .star.on { color: #ffd23d; }
/* Update-available chip — shows when the releases manifest names a newer /* Update-available chip — shows when the releases manifest names a newer
@ -410,7 +410,12 @@
</svg> </svg>
</button> </button>
<input id="url" placeholder="Ask a search engine or enter web address" spellcheck="false"> <input id="url" placeholder="Ask a search engine or enter web address" spellcheck="false">
<span class="reg" id="reg" hidden></span> <button class="regbtn" id="reg" title="Ariadne's Thread" hidden>
<!-- Ariadne's thread: a spiral of semicircles (radii 1 → 5,
centres alternating 8 / 9) unwinding into a loose tail. -->
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M9 7.5 A1 1 0 0 0 7 7.5 A2 2 0 0 0 11 7.5 A3 3 0 0 0 5 7.5 A4 4 0 0 0 13 7.5 A5 5 0 0 0 3 7.5 C3 11.2 6 14.6 13.6 13.4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
<span class="cv"></span>
</button>
<button class="pwchip" id="pwchip" title="Fill password for this site" hidden><span id="pwchipCount"></span><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="9" r="3.2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8.8 8.6 L14 8.6 M13.2 8.6 L13.2 11 M11.5 8.6 L11.5 10.4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button> <button class="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> <button class="star" id="star" title="Save this page"></button>
</div> </div>
@ -1060,30 +1065,27 @@
else if (d.kind === "error") { cls = "secbadge insecure"; title = "Resolution error"; } else if (d.kind === "error") { cls = "secbadge insecure"; title = "Resolution error"; }
b.className = cls; b.title = title; 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) { function setReg(d) {
const r = $("reg"); const r = $("reg");
if (!d || d.kind === "home") { r.hidden = true; r.innerHTML = ""; return; } if (!d || d.kind === "home") { r.hidden = true; r.className = "regbtn"; r.title = "Ariadne's Thread"; return; }
// Keep chips visible during "resolving" so the switch feels like a toggle, // Keep the last state during "resolving" so a switch reads as a toggle,
// not a disappear-and-reappear (user complaint 2026-08-02). // not a disappear-and-reappear (user complaint 2026-08-02).
if (d.kind === "resolving") { r.hidden = false; return; } if (d.kind === "resolving") { r.hidden = false; return; }
r.hidden = false; 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 isCand = !!d.tld && (d.kind === "ok" || d.kind === "web") && d.bcnrNativeTld === false;
const active = d.kind === "ok" ? "bcdn" : d.kind === "web" ? "icann" : null; const on = d.kind === "ok" ? "bcdn" : d.kind === "web" ? "icann" : "";
if (isCand) { r.className = "regbtn" + (on ? " " + on : "") + (isCand ? " cand" : "");
const bcdn = `<span class="reg bcdn ${active === "bcdn" ? "active" : "switch"}" data-c="bcdn" title="${active === "bcdn" ? "Serving from BCDN" : "Switch to BCDN"}">BCDN</span>`; r.title = on === "bcdn" ? "Serving from BCDN" + (isCand ? " · also exists on ICANN" : "") + " — click for options"
const icann = `<span class="reg icann ${active === "icann" ? "active" : "switch"}" data-c="icann" title="${active === "icann" ? "Serving from ICANN" : "Switch to ICANN"}">ICANN</span>`; : on === "icann" ? "Serving from ICANN" + (isCand ? " · also exists on BCDN" : "") + " — click for options"
r.innerHTML = `<span class="tog">${bcdn}${icann}</span>`; : "Ariadne's Thread — click for options";
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>`;
}
} }
$("reg").onclick = () => {
const b = $("reg").getBoundingClientRect();
if (T.registryMenuPopup) T.registryMenuPopup({ x: Math.round(b.left), y: Math.round(b.bottom + 2) }).catch(() => {});
};
// ---- Tor ---- // ---- Tor ----
let torShown = false; let torShown = false;

View file

@ -8,15 +8,24 @@
color: #b9c2d0; font-size: 11.5px; padding: 4px 10px 4px 8px; color: #b9c2d0; font-size: 11.5px; padding: 4px 10px 4px 8px;
display: inline-block; max-width: 100%; overflow: hidden; display: inline-block; max-width: 100%; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; direction: ltr; } 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) { @media (prefers-color-scheme: light) {
.pill { background: #ffffff; color: #3c4453; border-color: rgba(0,0,0,.15); } .pill { background: #ffffff; color: #3c4453; border-color: rgba(0,0,0,.15); }
} }
</style></head> </style></head>
<body><span class="pill" id="p"></span> <body><span class="pill" id="p"></span><span class="pill measure" id="m" aria-hidden="true"></span>
<script> <script>
const p = document.getElementById("p"); const p = document.getElementById("p");
const m = document.getElementById("m");
window.linkstatus.onUrl((url) => { window.linkstatus.onUrl((url) => {
p.textContent = String(url || ""); const s = String(url || "");
requestAnimationFrame(() => { try { window.linkstatus.resize(p.offsetWidth + 2, p.offsetHeight + 2); } catch (e) {} }); p.textContent = s; m.textContent = s;
// Ask main for the natural width; main clamps to the tab area and the
// visible pill ellipsises whatever doesn't fit.
requestAnimationFrame(() => { try { window.linkstatus.resize(m.offsetWidth + 2, p.offsetHeight + 2); } catch (e) {} });
}); });
</script></body></html> </script></body></html>

69
main.js
View file

@ -2118,7 +2118,11 @@ function positionPwFill() {
function positionLinkStatus() { function positionLinkStatus() {
if (!linkStatus || !win) return; if (!linkStatus || !win) return;
const { width, height } = win.getContentBounds(); 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); const h = Math.max(20, linkStatusH);
linkStatus.setBounds({ x: 0, y: Math.max(0, height - h), width: w, height: h }); 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("stop", () => { const t = activeTab(); try { t?.view.webContents.stop(); } catch {} setLoading(t, false); });
ipcMain.handle("toggle-tor", () => { torState === "off" ? startTor() : stopTor(); }); ipcMain.handle("toggle-tor", () => { torState === "off" ? startTor() : stopTor(); });
ipcMain.handle("open-settings", (_e, section) => { // Open (or focus) the Settings tab. Optional section slug ("passwords",
// Optional section slug ("passwords", "addons", …) lands directly on // "addons", …) lands directly on that sidebar entry when the page loads.
// that sidebar entry when the page loads. settings.html watches for a // settings.html watches for a fragment on load and an IPC message when
// fragment on load and an IPC message when already loaded. // already loaded.
function openSettingsTab(section) {
const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i.test(section) ? section.toLowerCase() : ""; const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i.test(section) ? section.toLowerCase() : "";
const ex = tabs.find((t) => t.settings); const ex = tabs.find((t) => t.settings);
if (ex) { if (ex) {
@ -3630,7 +3635,8 @@ ipcMain.handle("open-settings", (_e, section) => {
return; return;
} }
createTab(null, { settings: true, settingsSection: slug }); createTab(null, { settings: true, settingsSection: slug });
}); }
ipcMain.handle("open-settings", (_e, section) => openSettingsTab(section));
ipcMain.handle("toggle-site-info", (_e, rect) => { ipcMain.handle("toggle-site-info", (_e, rect) => {
if (popVisible) return showPopover(false); if (popVisible) return showPopover(false);
if (rect) popPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; 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 ----------------- // ---- Collision-mode: per-tab live switcher + policy control -----------------
// Flip the active tab between BCNR and ICANN for its current host, optionally // 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). // 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 t = activeTab(); if (!t?.prov?.host) return null;
const host = t.prov.host, tld = tldOf(host); const host = t.prov.host, tld = tldOf(host);
// Accept both "bcdn" (client-facing product name) and "bcnr" (internal key). // 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); } finally { t.internalNav = false; setLoading(t, false); }
if (t.id === activeId) pushNav(t.prov); if (t.id === activeId) pushNav(t.prov);
emitTabs(); 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", () => ({ ipcMain.handle("collision-state", () => ({
policy: settings.collisionPolicy, policy: settings.collisionPolicy,

View file

@ -24,7 +24,7 @@ contextBridge.exposeInMainWorld("theseus", {
reload: (hard) => ipcRenderer.invoke("reload", !!hard), reload: (hard) => ipcRenderer.invoke("reload", !!hard),
stop: () => ipcRenderer.invoke("stop"), stop: () => ipcRenderer.invoke("stop"),
toggleTor: () => ipcRenderer.invoke("toggle-tor"), toggleTor: () => ipcRenderer.invoke("toggle-tor"),
openSettings: () => ipcRenderer.invoke("open-settings"), openSettings: (section) => ipcRenderer.invoke("open-settings", section),
getSettings: () => ipcRenderer.invoke("settings-get"), getSettings: () => ipcRenderer.invoke("settings-get"),
setSetting: (key, val) => ipcRenderer.invoke("settings-set", key, val), setSetting: (key, val) => ipcRenderer.invoke("settings-set", key, val),
onSettingsUpdate: (cb) => ipcRenderer.on("settings-update", (_e, d) => cb(d)), 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. // layering entirely. Pass the button's viewport-rect so main can anchor.
toolbarMenuPopup: (addonId, rect) => ipcRenderer.invoke("toolbar-menu-popup", addonId, rect), toolbarMenuPopup: (addonId, rect) => ipcRenderer.invoke("toolbar-menu-popup", addonId, rect),
tabContextMenuPopup: (tabId, rect) => ipcRenderer.invoke("tab-context-menu-popup", tabId, 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 // Chrome listens so it can drop the "active" tint when the native menu
// dismisses (Esc, outside click, item picked — main fires all three). // dismisses (Esc, outside click, item picked — main fires all three).
onToolbarMenuClosed: (cb) => ipcRenderer.on("toolbar-menu-closed", () => cb()), onToolbarMenuClosed: (cb) => ipcRenderer.on("toolbar-menu-closed", () => cb()),