theseus/quicklinks.html
Local Dev f87173fde4 Theseus 0.3.115: AI picker chevron now visible; strip + picker refresh after favicon fetch
The sidebar AI button's chevron badge grows from a 10 px grey ghost in
the corner to a 14 px acid-coloured circle that reads at a glance, so
Pick AI engine is actually discoverable. Click or Enter still opens
the picker overlay; right-click on the AI button still works too.

Also wires emitQuickLinksState() into the icon-fetch completion path.
Before 0.3.115 the sidebar strip and the picker overlay kept showing
the engine sym emoji until some other state change re-emitted the
quicklinks payload. Now a freshly-fetched favicon lights up in the
strip and the picker on the same tick it finishes caching.
2026-10-26 10:50:47 +02:00

219 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html>
<head><meta charset="utf-8">
<style>
:root {
color-scheme: light dark;
--bg:#0e131c; --ink:#e7eaf1; --mut:#8b98a9; --line2:rgba(255,255,255,.13);
--acid:#d6ff3d; --acid-tint: rgb(from var(--acid) r g b / .14);
}
@media (prefers-color-scheme: light) {
:root { --bg:#eceff3; --ink:#1a1f28; --mut:#5c6676; --line2:rgba(0,0,0,.10); --acid:#0AC18E; --acid-tint: rgb(from var(--acid) r g b / .14); }
}
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow: hidden;
user-select: none; }
/* Icons sit in a vertically-centered column: the two spacers split the
remaining space above and below so the stack reads as "quick access"
centered in the strip, Opera-style. */
.strip { display: flex; flex-direction: column; align-items: center;
padding: 6px 0; height: 100%; box-sizing: border-box;
border-right: 1px solid var(--line2); }
.icons { display: flex; flex-direction: column; align-items: center; gap: 4px;
flex: none; }
.spacer { flex: 1 1 auto; min-height: 10px; }
.icon { width: 34px; height: 34px; border-radius: 8px; display: grid; place-items: center;
cursor: pointer; background: transparent; border: none; padding: 0; color: var(--ink);
position: relative; }
.icon:hover { background: var(--line2); }
.icon:active { transform: scale(0.94); }
.icon.on { background: var(--acid-tint); box-shadow: inset 2px 0 0 var(--acid); }
.icon.dragging { opacity: .4; }
.icon.dropbefore { box-shadow: inset 0 2px 0 var(--acid); }
.icon.dropafter { box-shadow: inset 0 -2px 0 var(--acid); }
.icon svg, .icon img { width: 26px; height: 26px; display: block; }
.icon.letter { font: 600 13px/1 system-ui, sans-serif; color: var(--mut); }
.icon .tt { position: absolute; left: 44px; top: 50%; transform: translateY(-50%);
background: var(--bg); color: var(--ink); padding: 4px 8px; border-radius: 6px;
border: 1px solid var(--line2); white-space: nowrap; font-size: 12px;
pointer-events: none; opacity: 0; transition: opacity .1s; z-index: 10; }
.icon:hover .tt { opacity: 1; transition-delay: .4s; }
.add { color: var(--mut); font: 500 20px/1 system-ui, sans-serif; }
.sep { width: 22px; height: 1px; background: var(--line2); margin: 4px 0; }
.icon.emoji { font-size: 18px; }
.add:hover { color: var(--acid); }
/* Gear + LLM button sit at the bottom of the strip. The LLM button grows
a small chevron badge in the bottom-right that opens the "pick AI"
menu; the main area of the button opens the current default LLM in
the left panel. Right-click on the button is equivalent to the chevron. */
.gear { color: var(--mut); margin-bottom: 2px; }
.gear:hover { color: var(--ink); }
.gear svg { width: 17px; height: 17px; }
.llm { color: var(--mut); margin-bottom: 2px; position: relative; }
.llm:hover { color: var(--ink); }
.llm.on { background: var(--acid-tint); box-shadow: inset 2px 0 0 var(--acid); color: var(--ink); }
.llm .glyph { font-size: 16px; line-height: 1; }
.llm img { width: 18px; height: 18px; display: block; }
/* Chevron is the discoverable "pick AI" affordance — kept small so it
doesn't crowd the AI icon, but with the acid accent so it reads at
a glance and a bigger hit target through padding. */
.llm .chev { position: absolute; right: -2px; bottom: -2px; width: 14px; height: 14px;
display: grid; place-items: center; color: var(--bg); background: var(--acid);
border: 2px solid var(--bg); border-radius: 50%; font-size: 9px; font-weight: 800;
line-height: 1; padding: 0; cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,.5); }
.llm .chev:hover { background: #fff; }
.llm .chev:focus-visible { outline: 2px solid var(--acid); outline-offset: 2px; }
</style></head>
<body>
<div class="strip">
<div class="spacer"></div>
<div class="icons" id="icons"></div>
<div class="spacer"></div>
<button class="icon llm" id="llmBtn" hidden><span class="glyph" id="llmGlyph">✨</span><span class="tt" id="llmTip">AI</span><span class="chev" id="llmChev" role="button" tabindex="0" aria-label="Pick AI engine" title="Pick AI engine">▾</span></button>
<button class="icon gear" id="gearBtn" title="Sidebar settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg><span class="tt">Sidebar settings</span></button>
</div>
<script src="strip-icons.js"></script>
<script>
const Q = window.quicklinks;
const icons = document.getElementById("icons");
// Gear: click opens Settings › Sidebar directly (no popover). The "+" add
// button that used to live below the gear is gone — adding a web app goes
// through Settings › Sidebar now, which the gear opens anyway.
const gearBtn = document.getElementById("gearBtn");
gearBtn.onclick = () => Q && Q.add && Q.add();
// LLM button: click opens the current default LLM in the left panel.
// Right-click (or the small chevron badge) opens the AI picker menu.
const llmBtn = document.getElementById("llmBtn");
const llmGlyph = document.getElementById("llmGlyph");
const llmTip = document.getElementById("llmTip");
const llmChev = document.getElementById("llmChev");
llmBtn.addEventListener("click", (e) => {
if (e.target === llmChev) return; // chevron handles its own click
Q && Q.open && Q.open("llm");
});
llmBtn.addEventListener("contextmenu", (e) => {
e.preventDefault();
const r = llmBtn.getBoundingClientRect();
Q && Q.llmMenu && Q.llmMenu({ x: r.left, y: r.top, width: r.width, height: r.height });
});
llmChev.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); const r = llmBtn.getBoundingClientRect(); Q && Q.llmMenu && Q.llmMenu({ x: r.left, y: r.top, width: r.width, height: r.height }); }
});
llmChev.addEventListener("click", (e) => {
e.stopPropagation();
const r = llmBtn.getBoundingClientRect();
Q && Q.llmMenu && Q.llmMenu({ x: r.left, y: r.top, width: r.width, height: r.height });
});
const { BRAND, svgFor: brandForLink, hostOf } = window.StripIcons;
function letterIcon(b, L) { b.classList.add("letter"); b.prepend(document.createTextNode((L.title || "?").slice(0, 1).toUpperCase())); }
// One order for web apps and extension panels (main keeps it); drag an
// icon up or down the strip to move it.
let order = [];
let dragKey = null;
function wireDrag(b, key) {
b.draggable = true;
b.dataset.key = key;
b.addEventListener("dragstart", (e) => { dragKey = key; b.classList.add("dragging"); try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("application/x-theseus-strip", key); } catch {} });
b.addEventListener("dragend", () => { dragKey = null; b.classList.remove("dragging"); icons.querySelectorAll(".icon").forEach((x) => x.classList.remove("dropbefore", "dropafter")); });
b.addEventListener("dragover", (e) => {
if (!dragKey || dragKey === key) return;
e.preventDefault(); e.dataTransfer.dropEffect = "move";
const r = b.getBoundingClientRect(); const before = (e.clientY - r.top) < r.height / 2;
b.classList.toggle("dropbefore", before); b.classList.toggle("dropafter", !before);
});
b.addEventListener("dragleave", () => b.classList.remove("dropbefore", "dropafter"));
b.addEventListener("drop", (e) => {
e.preventDefault();
if (!dragKey || dragKey === key) return;
const r = b.getBoundingClientRect(); const before = (e.clientY - r.top) < r.height / 2;
const next = order.filter((k) => k !== dragKey);
next.splice(next.indexOf(key) + (before ? 0 : 1), 0, dragKey);
dragKey = null;
Q && Q.reorder && Q.reorder(next);
});
}
function render(data) {
const links = (data && data.links) || [];
const openId = (data && data.openId) || null;
icons.innerHTML = "";
const panels = (data && data.panels) || [];
order = Array.isArray(data && data.order) ? data.order : [...panels.map((P) => P.id), ...links.map((L) => "q:" + L.id)];
const made = new Map();
for (const P of panels) {
const b = document.createElement("button");
b.className = "icon";
if (openId === P.id) b.classList.add("on");
b.title = P.title;
if (/^data:image\//.test(P.icon)) {
const img = document.createElement("img");
img.src = P.icon;
img.alt = "";
b.appendChild(img);
} else {
b.classList.add(P.icon ? "emoji" : "letter");
b.textContent = P.icon || (P.title || "?").slice(0, 1).toUpperCase();
}
const tt = document.createElement("span");
tt.className = "tt"; tt.textContent = P.title;
b.appendChild(tt);
b.onclick = () => Q && Q.open(P.id);
wireDrag(b, P.id);
made.set(P.id, b);
}
for (const L of links) {
const b = document.createElement("button");
b.className = "icon";
if (openId && L.id === openId) b.classList.add("on");
b.title = L.title || L.url;
const svg = brandForLink(L);
const host = hostOf(L.url);
if (svg) {
b.innerHTML = svg;
} else if (host && /^https?:/.test(L.url)) {
const img = document.createElement("img");
img.alt = "";
img.onerror = () => { img.remove(); letterIcon(b, L); };
img.src = new URL("/favicon.ico", L.url).href;
b.appendChild(img);
} else {
letterIcon(b, L);
}
const tt = document.createElement("span");
tt.className = "tt"; tt.textContent = L.title || L.url;
b.appendChild(tt);
b.onclick = () => Q && Q.open(L.id);
wireDrag(b, "q:" + L.id);
made.set("q:" + L.id, b);
}
for (const k of order) { const b = made.get(k); if (b) { icons.appendChild(b); made.delete(k); } }
for (const b of made.values()) icons.appendChild(b);
// LLM button — visible only when at least one LLM engine is enabled.
const llm = (data && data.llm) || null;
const engines = (llm && llm.engines) || [];
if (engines.length) {
llmBtn.hidden = false;
llmBtn.classList.toggle("on", !!(llm && llm.open));
const curId = (llm && llm.current) || engines[0].id;
const cur = engines.find((e) => e.id === curId) || engines[0];
llmTip.textContent = cur.name;
// Prefer the engine's bundled favicon; fall back to the symbol emoji.
llmGlyph.textContent = "";
llmGlyph.innerHTML = "";
if (cur.favicon) {
const img = document.createElement("img");
img.src = cur.favicon; img.alt = "";
img.onerror = () => { llmGlyph.innerHTML = ""; llmGlyph.textContent = cur.sym || "✨"; };
llmGlyph.appendChild(img);
} else {
llmGlyph.textContent = cur.sym || "✨";
}
} else {
llmBtn.hidden = true;
}
}
Q && Q.getState && Q.getState().then(render).catch(() => render(null));
Q && Q.onState && Q.onState(render);
</script>
</body>
</html>