Every text-[color:var(--ink)]/50, border-[color:var(--ink)]/10 and
bg-[color:var(--ink)]/5 in the templates compiled to nothing. Tailwind v3
cannot put an opacity on an arbitrary var() colour, so all 538 of them were
dropped from output.css without a word. Meta text rendered at full ink, card
borders fell back to Tailwind's default grey (bright white rings at night),
and the washes, backdrops and hover pills simply weren't there, on the news
site, the casino and the adventure pages alike.
The palette is now written once per phase and casino room as bare rgb
channels (--ink-rgb and friends), the plain vars are built from them, and
tailwind.config.js exposes ink, muted, accent, warn and card as real colours.
The classes are text-ink/80, border-ink/10, bg-accent/20 now, and they compile.
Restoring the intended opacities as-is would have traded flat for illegible:
ink at 50% measures 2.7 to 2.9:1 on the lit phases. So secondary text gets
its own colour, --muted, per phase and per casino room, taken as faint as it
can go while still clearing 4.5:1 on both the card and the page. Everything
that was ink at 35 to 70% is text-muted. The 20-30% ones are separators and
empty-cell dashes and stay faint.
The bg-theme-* chips carry white text everywhere they appear, and eight of
them measured 2.5 to 3.2:1. Same hues, taken just dark enough to pass (4.6 to
4.7:1). eu, lego and adventure already passed and are unchanged, and the
underline and border colours keep the old pastels since they carry no text.
Static files came out of the embedded FS with no Last-Modified, no ETag and
no Cache-Control, so every page view downloaded the stylesheet and every
script in full, and the service worker's "cheap 304" never happened. Each
file is hashed once now. Templates link through {{asset}}, which appends
?v=<hash>; a request naming the current hash is served immutable for a year,
and everything else gets the hash as its ETag and revalidates to a 304. The
service worker serves versioned URLs cache-first, drops older versions of the
same file as new ones land, and moves to cache v6.
190 lines
6.6 KiB
JavaScript
190 lines
6.6 KiB
JavaScript
// Cmd+K / Ctrl+K command palette. Hits /api/search and renders editorial-card
|
|
// results. Vanilla JS, no deps; styles come from output.css (Tailwind).
|
|
(function () {
|
|
const overlay = document.getElementById("pete-search");
|
|
if (!overlay) return;
|
|
const input = overlay.querySelector("[data-search-input]");
|
|
const list = overlay.querySelector("[data-search-list]");
|
|
const meta = overlay.querySelector("[data-search-meta]");
|
|
|
|
let activeIndex = -1;
|
|
let items = [];
|
|
let inflight = null;
|
|
let debounceTimer = 0;
|
|
let lastQuery = "";
|
|
|
|
function open() {
|
|
if (!overlay.classList.contains("hidden")) return;
|
|
overlay.classList.remove("hidden");
|
|
document.body.classList.add("overflow-hidden");
|
|
requestAnimationFrame(() => input.focus());
|
|
}
|
|
|
|
function close() {
|
|
overlay.classList.add("hidden");
|
|
document.body.classList.remove("overflow-hidden");
|
|
input.value = "";
|
|
list.innerHTML = "";
|
|
meta.textContent = "";
|
|
items = [];
|
|
activeIndex = -1;
|
|
lastQuery = "";
|
|
}
|
|
|
|
function escapeHTML(s) {
|
|
return String(s == null ? "" : s)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
// safeURL rejects any href whose scheme isn't http(s). Stored article_url
|
|
// comes from feeds we don't control; a hostile feed publishing
|
|
// `javascript:fetch(...)` would otherwise execute on click.
|
|
function safeURL(s) {
|
|
const raw = String(s == null ? "" : s).trim();
|
|
if (/^https?:\/\//i.test(raw)) return raw;
|
|
return "";
|
|
}
|
|
|
|
function render(results) {
|
|
items = results || [];
|
|
activeIndex = items.length > 0 ? 0 : -1;
|
|
if (items.length === 0) {
|
|
list.innerHTML = "";
|
|
return;
|
|
}
|
|
const html = items.map((r, i) => {
|
|
const thumb = r.thumb_url
|
|
? `<div class="hidden sm:block w-20 h-20 shrink-0 overflow-hidden rounded-2xl bg-ink/5">
|
|
<img src="${escapeHTML(r.thumb_url)}" alt="${escapeHTML(r.headline || "")}" loading="lazy" decoding="async"
|
|
class="h-full w-full object-cover">
|
|
</div>`
|
|
: `<div class="hidden sm:flex w-20 h-20 shrink-0 items-center justify-center rounded-2xl bg-ink/5 text-2xl">${escapeHTML(r.channel_emoji || "·")}</div>`;
|
|
const postedRing = r.posted ? ` border-theme-${escapeHTML(r.channel_theme)}` : "";
|
|
const href = safeURL(r.article_url);
|
|
if (!href) return "";
|
|
return `
|
|
<a href="${escapeHTML(href)}" target="_blank" rel="noopener noreferrer"
|
|
data-idx="${i}"
|
|
class="search-result group flex items-start gap-4 rounded-2xl p-3 transition border-2 border-transparent hover:bg-ink/5${postedRing}">
|
|
${thumb}
|
|
<div class="min-w-0 flex-1 space-y-1">
|
|
<div class="flex items-center gap-2 text-xs flex-wrap">
|
|
<span class="inline-flex items-center rounded-full bg-theme-${escapeHTML(r.channel_theme)} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider">
|
|
<span aria-hidden="true" class="mr-1">${escapeHTML(r.channel_emoji)}</span>${escapeHTML(r.channel_title)}
|
|
</span>
|
|
<span class="text-muted font-semibold">${escapeHTML(r.source)}</span>
|
|
<span class="text-muted">${escapeHTML(r.time_ago)}</span>
|
|
</div>
|
|
<h3 class="font-display text-base sm:text-lg font-semibold leading-snug">${escapeHTML(r.headline)}</h3>
|
|
${r.lede ? `<p class="text-sm text-muted line-clamp-2">${escapeHTML(r.lede)}</p>` : ""}
|
|
</div>
|
|
</a>`;
|
|
}).join("");
|
|
list.innerHTML = html;
|
|
paintActive();
|
|
}
|
|
|
|
function paintActive() {
|
|
const nodes = list.querySelectorAll(".search-result");
|
|
nodes.forEach((n, i) => {
|
|
if (i === activeIndex) {
|
|
n.classList.add("bg-ink/5", "border-ink/15");
|
|
n.scrollIntoView({ block: "nearest" });
|
|
} else {
|
|
n.classList.remove("bg-ink/5", "border-ink/15");
|
|
}
|
|
});
|
|
}
|
|
|
|
async function runQuery(q) {
|
|
if (q === lastQuery) return;
|
|
lastQuery = q;
|
|
if (!q) {
|
|
render([]);
|
|
meta.textContent = "type to search · esc to close";
|
|
return;
|
|
}
|
|
if (inflight) inflight.abort();
|
|
const ctrl = new AbortController();
|
|
inflight = ctrl;
|
|
meta.textContent = "searching…";
|
|
try {
|
|
const res = await fetch("/api/search?q=" + encodeURIComponent(q), { signal: ctrl.signal });
|
|
if (!res.ok) throw new Error("search failed: " + res.status);
|
|
const data = await res.json();
|
|
if (ctrl !== inflight) return;
|
|
render(data.results || []);
|
|
const n = (data.results || []).length;
|
|
meta.textContent = n === 0 ? "no matches" : `${n} result${n === 1 ? "" : "s"}`;
|
|
} catch (err) {
|
|
if (err.name === "AbortError") return;
|
|
console.error(err);
|
|
meta.textContent = "search error";
|
|
} finally {
|
|
if (ctrl === inflight) inflight = null;
|
|
}
|
|
}
|
|
|
|
input.addEventListener("input", () => {
|
|
clearTimeout(debounceTimer);
|
|
const q = input.value.trim();
|
|
debounceTimer = window.setTimeout(() => runQuery(q), 120);
|
|
});
|
|
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
if (items.length === 0) return;
|
|
activeIndex = (activeIndex + 1) % items.length;
|
|
paintActive();
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
if (items.length === 0) return;
|
|
activeIndex = (activeIndex - 1 + items.length) % items.length;
|
|
paintActive();
|
|
} else if (e.key === "Enter") {
|
|
if (activeIndex >= 0 && items[activeIndex]) {
|
|
e.preventDefault();
|
|
const href = safeURL(items[activeIndex].article_url);
|
|
if (href) window.open(href, "_blank", "noopener");
|
|
}
|
|
}
|
|
});
|
|
|
|
overlay.addEventListener("click", (e) => {
|
|
if (e.target === overlay) close();
|
|
});
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
const isK = e.key === "k" || e.key === "K";
|
|
if ((e.metaKey || e.ctrlKey) && isK) {
|
|
e.preventDefault();
|
|
if (overlay.classList.contains("hidden")) open();
|
|
else close();
|
|
return;
|
|
}
|
|
if (e.key === "Escape" && !overlay.classList.contains("hidden")) {
|
|
close();
|
|
}
|
|
if (e.key === "/" && overlay.classList.contains("hidden")) {
|
|
const tag = (document.activeElement && document.activeElement.tagName) || "";
|
|
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
|
e.preventDefault();
|
|
open();
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll("[data-search-trigger]").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
open();
|
|
});
|
|
});
|
|
|
|
meta.textContent = "type to search · esc to close";
|
|
})();
|