Files
news/news/templates/triage.html
T
connor 8ed007a75f Add a theme toggle to the nav, and centralise the palette
Three states rather than two: system (follow the OS), light, dark. A two-state
toggle cannot express "just do what my laptop does", which is the state most
people actually want.

Two details that are easy to get wrong and were both handled deliberately:

- The dark rules are guarded with :root:not([data-theme="light"]). Without that
  guard, choosing light does nothing on a machine whose OS is set to dark,
  because the media query keeps winning -- the toggle appears to work in one
  direction only.
- The stored theme is applied by an inline script placed ahead of any rendered
  content, so there is no flash of the wrong palette on load. A deferred script
  would repaint after first paint, visibly.

localStorage access is wrapped in try/catch: it throws outright in some
private-browsing modes, and a theme preference is not worth a broken page.

The palette moved out of all four templates into _theme.html, which also removes
four near-identical copies of the nav CSS that had already started to drift.
Tests assert the toggle, the guard, the pre-paint ordering, and that no page
redeclares the palette locally.
2026-09-08 18:00:01 -04:00

165 lines
7.3 KiB
HTML

<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Triage</title>
{% include "_theme.html" %}
<style>
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font:15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
main { max-width:52rem; margin:0 auto; padding:1.25rem 1rem 6rem; }
header { display:flex; flex-wrap:wrap; gap:.75rem; align-items:baseline;
justify-content:space-between; border-bottom:1px solid var(--rule);
padding-bottom:.6rem; margin-bottom:1rem; }
h1 { font-size:1.15rem; margin:0; letter-spacing:-.01em; }
.stats, .filters { color:var(--muted); font-size:.83rem; }
.filters a { color:var(--accent); text-decoration:none; margin-left:.6rem; }
.subbar { display:flex; gap:1rem; flex-wrap:wrap; justify-content:space-between;
margin:-.4rem 0 1rem; }
.filters a.on { text-decoration:underline; }
article { background:var(--card); border:1px solid var(--rule); border-radius:9px;
padding:.7rem .85rem; margin-bottom:.5rem; scroll-margin-top:1rem; }
article.sel { border-color:var(--accent); background:var(--sel); }
article.done { opacity:.4; }
.top { display:flex; gap:.6rem; align-items:baseline; justify-content:space-between; }
h2 { font-size:.98rem; line-height:1.35; margin:0 0 .3rem; font-weight:600; }
h2 a { color:inherit; text-decoration:none; }
h2 a:hover { text-decoration:underline; }
.tags { display:flex; flex-wrap:wrap; gap:.4rem; margin:.35rem 0; }
.tag { font-size:.7rem; text-transform:uppercase; letter-spacing:.04em;
color:var(--muted); border:1px solid var(--rule); border-radius:99px;
padding:.05rem .45rem; white-space:nowrap; }
.tag.lane-alert\:important { color:var(--neg); border-color:currentColor; }
.tag.lane-alert\:interest { color:var(--accent); border-color:currentColor; }
.tag.demote { color:var(--warn); border-color:currentColor; }
.srcs { list-style:none; padding:0; margin:.35rem 0 0; }
.srcs li { font-size:.83rem; margin:.12rem 0; color:var(--muted); }
.srcs a { color:inherit; text-decoration:none; border-bottom:1px solid var(--rule); }
.acts { display:flex; gap:.4rem; margin-top:.5rem; }
button { font:inherit; font-size:.83rem; padding:.22rem .7rem; cursor:pointer;
border:1px solid var(--rule); background:transparent; color:inherit;
border-radius:6px; }
button.yes:hover { border-color:var(--pos); color:var(--pos); }
button.no:hover { border-color:var(--neg); color:var(--neg); }
button[disabled] { opacity:.45; cursor:default; }
kbd { font:inherit; font-size:.72rem; border:1px solid var(--rule);
border-bottom-width:2px; border-radius:4px; padding:0 .25rem; color:var(--muted); }
.help { position:fixed; bottom:0; left:0; right:0; background:var(--card);
border-top:1px solid var(--rule); padding:.45rem 1rem; font-size:.78rem;
color:var(--muted); display:flex; gap:1rem; justify-content:center; }
.empty { color:var(--muted); font-style:italic; padding:2rem 0; }
@media (max-width:34rem) { .help { display:none; } }
</style>
<main>
<header>
<h1>Triage</h1>
{% include "_nav.html" %}
</header>
<div class="subbar">
<span class="stats">{{ rows|length }} shown · {{ labelled }} labelled · {{ total }} clusters</span>
<span class="filters">show
<a class="{{ 'on' if show == 'unlabelled' }}" href="?show=unlabelled">unlabelled</a>
<a class="{{ 'on' if show == 'all' }}" href="?show=all">all</a>
</span>
</div>
{% for r in rows %}
<article id="c{{ loop.index0 }}" data-id="{{ r.id }}"
data-yes="{{ r.tok_yes }}" data-no="{{ r.tok_no }}"
class="{{ 'done' if r.labelled }}">
<h2><a href="{{ r.sources[0].url if r.sources else '#' }}" target="_blank"
rel="noopener">{{ r.headline }}</a></h2>
<div class="tags">
<span class="tag lane-{{ r.lane }}">{{ r.lane }}</span>
<span class="tag">{{ r.topic }} · int {{ '%.2f'|format(r.interest) }}</span>
<span class="tag">{{ r.importance_topic }} · imp {{ '%.2f'|format(r.importance) }}</span>
<span class="tag">{{ r.origins }} origin{{ '' if r.origins == 1 else 's' }}</span>
{% if r.demote %}<span class="tag demote">demoted: {{ r.demote }}</span>{% endif %}
</div>
<ul class="srcs">
{% for s in r.sources %}
<li><a href="{{ s.url }}" target="_blank" rel="noopener">{{ s.title }}</a>
— {{ s.name }}</li>
{% endfor %}
</ul>
<div class="acts">
<button class="yes" data-kind="interested">Interested <kbd>i</kbd></button>
<button class="no" data-kind="not_interested">Not interested <kbd>n</kbd></button>
</div>
</article>
{% else %}
<p class="empty">Nothing left to label. Try <a href="?show=all">show all</a>.</p>
{% endfor %}
{% if not signed %}
<p class="empty">NEWS_FEEDBACK_SECRET is unset, so labelling is disabled.</p>
{% endif %}
</main>
<div class="help">
<span><kbd>j</kbd>/<kbd>k</kbd> move</span>
<span><kbd>i</kbd> interested</span>
<span><kbd>n</kbd> not interested</span>
<span><kbd>o</kbd> open</span>
</div>
<script>
const cards = [...document.querySelectorAll('article')];
let cur = 0;
const select = i => {
if (!cards.length) return;
cur = Math.max(0, Math.min(i, cards.length - 1));
cards.forEach(c => c.classList.remove('sel'));
cards[cur].classList.add('sel');
cards[cur].scrollIntoView({block: 'nearest', behavior: 'smooth'});
};
async function label(card, kind) {
const btns = card.querySelectorAll('button');
btns.forEach(b => b.disabled = true);
try {
const token = kind === 'interested' ? card.dataset.yes : card.dataset.no;
// Same public, HMAC-signed endpoint the phone uses. `redirect: 'error'`
// matters: without it an expired session would follow a redirect to the
// login page, return 200, and this would report success having recorded
// nothing.
const r = await fetch(`/api/feedback/${card.dataset.id}/${kind}/${token}?src=web`,
{method: 'POST', redirect: 'error'});
if (!r.ok) throw new Error(r.status);
const body = await r.json();
if (!body.ok) throw new Error(body.error || 'rejected');
card.classList.add('done');
// Advance, so a run of judgements is one keystroke each.
if (cards[cur] === card) select(cur + 1);
} catch (e) {
btns.forEach(b => b.disabled = false);
card.style.borderColor = 'var(--neg)';
console.error('feedback failed', e);
}
}
cards.forEach((card, i) => {
card.addEventListener('click', () => select(i));
card.querySelectorAll('button').forEach(b =>
b.addEventListener('click', ev => { ev.stopPropagation(); label(card, b.dataset.kind); }));
});
document.addEventListener('keydown', ev => {
if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
const card = cards[cur];
switch (ev.key) {
case 'j': select(cur + 1); break;
case 'k': select(cur - 1); break;
case 'i': if (card) label(card, 'interested'); break;
case 'n': if (card) label(card, 'not_interested'); break;
case 'o': if (card) card.querySelector('h2 a')?.click(); break;
default: return;
}
ev.preventDefault();
});
select(0);
</script>