mobiledev/taskflow

TaskFlow, a team task board you run from the web or from Claude and ChatGPT. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 0 remixes · Remix mobiledev/taskflow

app/taskflow/doors/page.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TaskFlow</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='tfg' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%232563eb'/%3E%3Cstop offset='1' stop-color='%237c3aed'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23tfg)'/%3E%3Crect x='7' y='8' width='4.6' height='16' rx='2.3' fill='%23fff'/%3E%3Crect x='13.7' y='8' width='4.6' height='11' rx='2.3' fill='%23fff' fill-opacity='.85'/%3E%3Crect x='20.4' y='8' width='4.6' height='6.5' rx='2.3' fill='%23fff' fill-opacity='.7'/%3E%3C/svg%3E">
<style>
  :root {
    --bg: #f4f6fa; --panel: #ffffff; --ink: #1c2230; --muted: #667085; --line: #e3e7ef;
    --accent: #2563eb; --accent-ink: #ffffff; --col: #eceff5; --late: #d92d20; --late-bg: #fdecea;
    --tag: #eef2ff; --tag-ink: #3538cd; --ok: #16794c; --shadow: 0 1px 2px rgba(16,24,40,.08);
    --scroll: #c3c9d5; color-scheme: light;
  }
  /* Dark follows the system unless the sun/moon toggle picked a theme (data-theme on <html>). */
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      --bg: #12151c; --panel: #1b2029; --ink: #e8ebf2; --muted: #98a2b3; --line: #2b3240;
      --accent: #5b8cff; --col: #171b23; --late: #ff6b5e; --late-bg: #3a1d1b;
      --tag: #262c4a; --tag-ink: #b4bcff; --ok: #4ad295; --shadow: none; color-scheme: dark;
      --scroll: #3d4656;
    }
  }
  :root[data-theme="dark"] {
    --bg: #12151c; --panel: #1b2029; --ink: #e8ebf2; --muted: #98a2b3; --line: #2b3240;
    --accent: #5b8cff; --col: #171b23; --late: #ff6b5e; --late-bg: #3a1d1b;
    --tag: #262c4a; --tag-ink: #b4bcff; --ok: #4ad295; --shadow: none; color-scheme: dark;
    --scroll: #3d4656;
  }
  * { box-sizing: border-box; }
  a { color: inherit; text-decoration: none; }
  input[type="checkbox"] { accent-color: var(--accent); }
  .ic { width: 1.05em; height: 1.05em; vertical-align: -0.18em; flex: none; }
  .icon-btn { width: 34px; height: 34px; padding: 0; display: inline-grid; place-items: center; }
  .icon-btn .ic { width: 18px; height: 18px; vertical-align: 0; }
  body { margin: 0; font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
         background: var(--bg); color: var(--ink); }
  button, input, select, textarea { font: inherit; color: inherit; }
  button { cursor: pointer; border: 1px solid var(--line); background: var(--panel);
           border-radius: 8px; padding: 6px 10px; }
  button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
  button:disabled { opacity: .5; cursor: default; }
  button { transition: background-color .12s, border-color .12s, transform .06s; }
  button:not(:disabled):hover { border-color: var(--accent); background: var(--tag); }
  button.primary:not(:disabled):hover { background: var(--accent); filter: brightness(1.1); }
  button:not(:disabled):active { transform: translateY(1px); }
  :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
  .card { transition: border-color .12s, box-shadow .12s; }
  .card:hover { border-color: var(--accent); }
  input, select, textarea { border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px;
                            background: var(--panel); width: 100%; }
  header { display: flex; align-items: center; gap: 12px; padding: 10px 16px;
           background: var(--panel); border-bottom: 1px solid var(--line); flex-wrap: wrap; }
  .brand { font-weight: 700; font-size: 17px; display: flex; align-items: center; gap: 8px; }
  .brand .logo { width: 26px; height: 26px; flex: none; }
  header select { width: auto; min-width: 180px; }
  header .spacer { flex: 1; }
  header small { color: var(--muted); }
  /* The page is one screen tall: header, board bar, then the board filling the rest. A long
     list scrolls inside itself, so list headers and the board's sideways scroll stay in view. */
  html, body { height: 100%; }
  body { display: flex; flex-direction: column; }
  main { padding: 16px; flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: auto; }
  .bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
  .bar h1 { font-size: 20px; margin: 0 8px 0 0; }
  .role { font-size: 12px; padding: 2px 8px; border-radius: 99px; background: var(--tag);
          color: var(--tag-ink); }
  .board { display: flex; gap: 12px; align-items: flex-start; overflow-x: auto; overflow-y: hidden;
           padding-bottom: 12px; flex: 1; min-height: 0; }
  .col { background: var(--col); border-radius: 12px; width: 272px; flex: 0 0 272px; padding: 8px;
         max-height: 100%; display: flex; flex-direction: column; }
  .col h2 { font-size: 13px; margin: 0 2px 8px 6px; display: flex; align-items: center; gap: 6px;
            min-height: 28px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
  .col h2 .name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
  .col h2 .icon-btn { width: 28px; height: 28px; border-color: transparent; background: transparent; }
  .col h2 input { text-transform: none; letter-spacing: 0; font-size: 13px; padding: 4px 6px; }
  .menu { position: relative; }
  .menu-pop { position: absolute; right: 0; top: 30px; z-index: 5; background: var(--panel);
              border: 1px solid var(--line); border-radius: 10px; padding: 4px; min-width: 160px;
              box-shadow: 0 8px 24px rgba(0,0,0,.14); display: grid; text-transform: none;
              letter-spacing: 0; font-weight: 400; font-size: 14px; }
  .menu-pop button { border: 0; text-align: left; background: transparent; }
  .menu-pop button:disabled { opacity: .45; }
  .danger { color: var(--late); }
  .add-col { background: transparent; border: 2px dashed var(--line); }
  .add-col form { display: flex; gap: 6px; }
  .add-col input { min-width: 0; }
  .board { scrollbar-width: thin; scrollbar-color: var(--scroll) transparent; }
  .board::-webkit-scrollbar { height: 10px; }
  .board::-webkit-scrollbar-thumb { background: var(--scroll); border-radius: 99px; }
  .board::-webkit-scrollbar-track { background: transparent; }
  .danger-zone { margin-top: 24px; padding: 12px; border: 1px solid var(--late); border-radius: 10px; }
  .cards { min-height: 40px; display: flex; flex-direction: column; gap: 6px; flex: 0 1 auto;
           overflow-y: auto; padding: 2px; margin: -2px;
           scrollbar-width: thin; scrollbar-color: var(--scroll) transparent; }  /* always shown: more cards below */
  /* An empty list says so; CSS only, so drag and drop never counts it as a card. */
  .cards:empty::before { content: "No cards yet"; display: block; padding: 10px 4px; text-align: center;
                         font-size: 12.5px; color: var(--muted); border: 1px dashed var(--line);
                         border-radius: 8px; }
  .cards::-webkit-scrollbar { width: 6px; }
  .cards::-webkit-scrollbar-thumb { background: var(--scroll); border-radius: 99px; }
  .col.over { box-shadow: inset 0 0 0 2px var(--accent); }   /* inside: the board's scroll box clips outlines */
  .drop-spot { height: 38px; border: 2px dashed var(--accent); border-radius: 8px;
               background: color-mix(in srgb, var(--accent) 8%, transparent); }
  .card { background: var(--panel); border-radius: 8px; padding: 8px 10px; box-shadow: var(--shadow);
          border: 1px solid var(--line); cursor: pointer; }
  .card[draggable="true"] { cursor: grab; }
  .card.dragging { opacity: .4; }
  .card .num { color: var(--muted); font-size: 12px; margin-right: 4px; }
  .card-title { overflow-wrap: anywhere; font-weight: 500; }
  .labels { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; font-size: 12px; }
  .foot { display: flex; align-items: center; gap: 8px; margin-top: 7px; font-size: 12px;
          color: var(--muted); }
  .foot .bits { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; flex: 1; }
  .foot .bits > span { display: inline-flex; align-items: center; gap: 3px; }
  /* Each person keeps one colour everywhere (--h comes from their email), so a glance tells
     who holds a card. */
  .avatar { width: 24px; height: 24px; border-radius: 50%; display: inline-grid; place-items: center;
            font-size: 10.5px; font-weight: 700; flex: none; text-transform: uppercase;
            background: hsl(var(--h, 230) 75% 88%); color: hsl(var(--h, 230) 60% 28%); }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .avatar { background: hsl(var(--h, 230) 35% 30%);
                                              color: hsl(var(--h, 230) 85% 86%); }
  }
  :root[data-theme="dark"] .avatar { background: hsl(var(--h, 230) 35% 30%); color: hsl(var(--h, 230) 85% 86%); }
  .who { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 100%; }
  .foot .avatar { margin-left: auto; width: 22px; height: 22px; font-size: 9.5px; }
  .who-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px;
              color: var(--muted); }
  .meta { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; font-size: 12px; color: var(--muted); }
  .tag { background: var(--tag); color: var(--tag-ink); border-radius: 99px; padding: 1px 8px; }
  .late { color: var(--late); background: var(--late-bg); border-radius: 99px; padding: 1px 7px; }
  .blocked { color: var(--late); }
  .addcard { margin-top: 8px; display: flex; gap: 6px; }
  .addcard input, .row input, .row select { min-width: 0; }
  .addcard button { flex: 0 0 auto; }
  .side { position: fixed; top: 0; right: 0; height: 100%; width: min(440px, 100%);
          background: var(--panel); border-left: 1px solid var(--line); padding: 16px;
          overflow-y: auto; box-shadow: -8px 0 24px rgba(0,0,0,.12); display: none; }
  .side.open { display: block; }
  .side h3 { margin: 0 0 12px; display: flex; justify-content: space-between; gap: 8px; }
  .field { margin-bottom: 10px; }
  .field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 3px; }
  .row { display: flex; gap: 8px; }
  .row > * { flex: 1; }
  ul.plain { list-style: none; padding: 0; margin: 0; }
  ul.plain li { padding: 5px 0; border-bottom: 1px solid var(--line); display: flex; gap: 8px;
                align-items: center; }
  .hist li { display: block; font-size: 12.5px; }
  .feed { list-style: none; margin: 0; padding: 0; }
  .feed .day { font-size: 11.5px; font-weight: 600; color: var(--muted); text-transform: uppercase;
               letter-spacing: .04em; padding: 10px 0 4px; border: 0; }
  .feed .ev { display: grid; grid-template-columns: 26px 1fr; gap: 8px; padding: 8px 0;
              border-bottom: 1px solid var(--line); font-size: 13px; }
  .feed .ev:last-child { border-bottom: 0; }
  .feed .ev .avatar { width: 24px; height: 24px; }
  .feed .line { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
  .feed .line .ic { color: var(--muted); align-self: center; }
  .feed .ago { color: var(--muted); font-size: 11.5px; margin-left: auto; white-space: nowrap; }
  .feed .what { overflow-wrap: anywhere; }
  .feed .diff { margin: 4px 0 0; padding: 0; list-style: none; font-size: 12px; color: var(--muted);
                display: grid; gap: 2px; }
  .feed .diff b { color: var(--ink); font-weight: 500; }
  .feed .diff .old { text-decoration: line-through; }
  /* Lists in the side panel that grow without limit (history, checklist, members, results)
     scroll inside a box, so the panel's own buttons never drift out of reach. */
  .box { max-height: 220px; overflow-y: auto; border: 1px solid var(--line); border-radius: 8px;
         padding: 0 10px; scrollbar-width: thin; scrollbar-color: var(--scroll) transparent; }
  .box::-webkit-scrollbar { width: 6px; }
  .box::-webkit-scrollbar-thumb { background: var(--scroll); border-radius: 99px; }
  .box li:last-child { border-bottom: 0; }
  h4 .count { color: var(--muted); font-weight: 400; }
  .hist time { color: var(--muted); display: block; font-size: 11.5px; }
  .muted { color: var(--muted); }
  .notice { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
            background: var(--ink); color: var(--bg); padding: 8px 14px; border-radius: 8px;
            display: none; max-width: 90%; }
  .list-view .card { cursor: pointer; margin-bottom: 6px; }
  .empty { padding: 40px 16px; text-align: center; color: var(--muted); }
</style>
<script>
  // Apply a saved theme before the page paints, so there is no flash of the other one.
  try { const t = localStorage.getItem("tf.theme"); if (t) document.documentElement.dataset.theme = t; }
  catch (e) {}
</script>
</head>
<body>
<header>
  <span class="brand"><svg class="logo" viewBox="0 0 32 32" aria-hidden="true"><defs><linearGradient id="tfg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#2563eb"/><stop offset="1" stop-color="#7c3aed"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(#tfg)"/><rect x="7" y="8" width="4.6" height="16" rx="2.3" fill="#fff"/><rect x="13.7" y="8" width="4.6" height="11" rx="2.3" fill="#fff" fill-opacity=".85"/><rect x="20.4" y="8" width="4.6" height="6.5" rx="2.3" fill="#fff" fill-opacity=".7"/></svg>TaskFlow</span>
  <select id="boardPick" aria-label="Board"></select>
  <button id="newBoard">+ Board</button>
  <button id="myWork">My cards</button>
  <button id="lateWork">Overdue</button>
  <span class="spacer"></span>
  <button id="theme" class="icon-btn" aria-label="Switch light or dark theme" title="Switch theme"></button>
  <small id="who"></small>
  <a href="/.dodil/auth/logout"><button>Sign out</button></a>
</header>
<main id="main"><div class="empty">Loading…</div></main>
<aside class="side" id="side"></aside>
<div class="notice" id="notice"></div>

<script>
const $ = (s, el = document) => el.querySelector(s);
const esc = v => String(v ?? "").replace(/[&<>"']/g, c =>
  ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
// Inline SVG icons: the same on every system (emoji change size and look per OS), and they
// take the text colour, so they follow the theme.
const svg = d => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor"
  stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${d}</svg>`;
const ICON = {
  sun: svg('<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>'),
  moon: svg('<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>'),
  blocked: svg('<circle cx="12" cy="12" r="9"/><path d="M7.5 12h9"/>'),
  checklist: svg('<path d="M9 11l3 3 8-8"/><path d="M20 12v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h9"/>'),
  sparkle: svg('<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 17v4M17 19h4"/>'),
  close: svg('<path d="M18 6L6 18M6 6l12 12"/>'),
  calendar: svg('<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 10h18"/>'),
  more: svg('<circle cx="5" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="19" cy="12" r="1.3"/>'),
  plus: svg('<path d="M12 5v14M5 12h14"/>'),
  trash: svg('<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>'),
  arrow: svg('<path d="M5 12h14M13 6l6 6-6 6"/>'),
  pencil: svg('<path d="M4 20h4L19 9l-4-4L4 16v4z"/><path d="M13.5 6.5l4 4"/>'),
  link: svg('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>'),
  check: svg('<path d="M5 12l5 5 9-10"/>'),
  people: svg('<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0M16 4.5a3.5 3.5 0 0 1 0 7M18 14a6 6 0 0 1 3.5 6"/>'),
  board: svg('<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16M15 4v9"/>'),
};

// The server keeps times in UTC; show them in the viewer's own time zone.
function when(at) {
  const d = new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(at) ? at : at + "Z");
  return isNaN(d) ? at : d.toLocaleString([], { year: "numeric", month: "2-digit", day: "2-digit",
                                                 hour: "2-digit", minute: "2-digit" });
}
// ---------------------------------------------------------------- readable history
// The server keeps each change as a sentence plus a {field: [before, after]} diff. This turns
// them into a feed people can scan: who (badge), what, when ("5 min ago"), grouped by day, with
// the before -> after of every field. Stored records and what assistants read are unchanged.
const FIELD = { title: "Title", description: "Description", assignee: "Assignee", due_date: "Due date",
                labels: "Labels", list_name: "List", blocked_by: "Waits on", checklist: "Checklist" };

function ago(at) {
  const d = new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(at) ? at : at + "Z"), s = (Date.now() - d) / 1000;
  if (isNaN(d)) return "";
  if (s < 60) return "just now";
  if (s < 3600) return `${Math.floor(s / 60)} min ago`;
  if (s < 86400 && d.toDateString() === new Date().toDateString()) return `${Math.floor(s / 3600)} h ago`;
  return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function dayOf(at) {
  const d = new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(at) ? at : at + "Z");
  const y = new Date(); y.setDate(y.getDate() - 1);
  if (d.toDateString() === new Date().toDateString()) return "Today";
  if (d.toDateString() === y.toDateString()) return "Yesterday";
  return d.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric", year: "numeric" });
}
function shown(field, v) {
  if (v === null || v === undefined || v === "" || (Array.isArray(v) && !v.length)) return "none";
  if (field === "due_date") return shortDate(v);
  if (field === "assignee") return v.split("@")[0];
  if (Array.isArray(v)) return v.join(", ");
  v = String(v);
  return v.length > 60 ? v.slice(0, 57) + "…" : v;
}
// Icon and short wording for each kind of record, matched on the sentence the server wrote.
function describe(h) {
  const a = h.action;
  const rules = [
    [/^added #(\d+) (['"])(.*)\2 to (.+)$/, m => [ICON.plus, `added #${m[1]} <b>${esc(m[3])}</b> to ${esc(m[4])}`]],
    [/^moved #(\d+) from (.+) to (.+)$/, m => [ICON.arrow, `moved #${m[1]}: ${esc(m[2])} → <b>${esc(m[3])}</b>`]],
    [/^changed .* on #(\d+)$/, m => [ICON.pencil, `edited #${m[1]}`]],
    [/^deleted #(\d+) (['"])(.*)\2$/, m => [ICON.trash, `deleted #${m[1]} <b>${esc(m[3])}</b>`]],
    [/^#(\d+) is blocked by #(\d+)$/, m => [ICON.link, `#${m[1]} now waits on #${m[2]}`]],
    [/^#(\d+) no longer waits on #(\d+)$/, m => [ICON.link, `#${m[1]} no longer waits on #${m[2]}`]],
    [/^added (\d+) checklist item\(s\) to #(\d+)$/, m => [ICON.checklist, `added ${m[1]} checklist item${m[1] === "1" ? "" : "s"} to #${m[2]}`]],
    [/^(ticked|unticked) (['"])(.*)\2 on #(\d+)$/, m => [ICON.check, `${m[1]} <b>${esc(m[3])}</b> on #${m[4]}`]],
    [/^unassigned #(\d+)$/, m => [ICON.people, `unassigned #${m[1]}`]],
    [/^invited (\S+) as (\w+)$/, m => [ICON.people, `invited ${esc(m[1])} as <b>${esc(m[2])}</b>`]],
    [/^changed (\S+) from (\w+) to (\w+)$/, m => [ICON.people, `made ${esc(m[1])} a <b>${esc(m[3])}</b> (was ${esc(m[2])})`]],
    [/^removed (\S+)$/, m => [ICON.people, `removed ${esc(m[1])} from the board`]],
    [/^created board (['"])(.*)\1$/, m => [ICON.board, `created the board <b>${esc(m[2])}</b>`]],
    [/^added list (['"])(.*)\1$/, m => [ICON.board, `added the list <b>${esc(m[2])}</b>`]],
    [/^renamed list (['"])(.*)\1 to (['"])(.*)\3$/, m => [ICON.board, `renamed the list ${esc(m[2])} → <b>${esc(m[4])}</b>`]],
    [/^deleted list (['"])(.*)\1$/, m => [ICON.board, `deleted the list <b>${esc(m[2])}</b>`]],
  ];
  for (const [re, f] of rules) { const m = a.match(re); if (m) return f(m); }
  return [ICON.pencil, esc(a)];
}
function feed(items) {
  if (!items.length) return `<p class="muted">Nothing yet.</p>`;
  let day = "", out = "";
  for (const h of items) {
    const d = dayOf(h.at);
    if (d !== day) { day = d; out += `<li class="day">${esc(d)}</li>`; }
    const [icon, what] = describe(h);
    const skip = h.action.startsWith("moved") ? ["list_name"] : ["checklist", "blocked_by"];
    const diff = Object.entries(h.changes || {}).filter(([k]) => !skip.includes(k) && FIELD[k]);
    const name = h.actor === state.me ? "You" : h.actor.split("@")[0];
    out += `<li class="ev">${avatar(h.actor).replace('class="avatar"', `class="avatar" title="${esc(h.actor)}"`)}
      <div><div class="line"><b title="${esc(h.actor)}">${esc(name)}</b>${icon}
        <span class="what">${what}</span><span class="ago" title="${esc(when(h.at))}">${esc(ago(h.at))}</span></div>
        ${diff.length ? `<ul class="diff">${diff.map(([k, [before, after]]) =>
          `<li>${FIELD[k]}: ${before === null || before === undefined || before === "" ||
            (Array.isArray(before) && !before.length) ? "" : `<span class="old">${esc(shown(k, before))}</span> → `}<b>${esc(shown(k, after))}</b></li>`).join("")}</ul>` : ""}
      </div></li>`;
  }
  return `<ul class="feed">${out}</ul>`;
}

const state = { boards: [], board: null, me: "", dragging: false, renderLater: false };

// While a form's request is on its way the form is locked (buttons greyed) and further submits
// are ignored, so a double click on Add makes one card, not two. Unlocks once the network is
// quiet again; buttons marked data-guard get the same treatment.
let pending = 0;
const locked = new Set();
function lock(el) {
  el.setAttribute("aria-busy", "true");
  (el.matches("button") ? [el] : el.querySelectorAll("button")).forEach(b => b.disabled = true);
  locked.add(el);
  setTimeout(() => { if (!pending) unlockAll(); });    // a handler that sent nothing
}
function unlockAll() {
  locked.forEach(el => { el.removeAttribute("aria-busy");
    (el.matches("button") ? [el] : el.querySelectorAll("button")).forEach(b => b.disabled = false); });
  locked.clear();
}
document.addEventListener("submit", e => {
  if (e.target.getAttribute("aria-busy") === "true") { e.preventDefault(); e.stopImmediatePropagation(); return; }
  lock(e.target);
}, true);
document.addEventListener("click", e => {
  const b = e.target.closest("button[data-guard]");
  if (!b) return;
  if (b.getAttribute("aria-busy") === "true") { e.preventDefault(); e.stopImmediatePropagation(); return; }
  lock(b);
}, true);

async function api(path, opts = {}) {
  pending++;
  try { return await callApi(path, opts); }
  finally { if (--pending === 0) unlockAll(); }
}

async function callApi(path, opts) {
  const res = await fetch(path, { headers: { "content-type": "application/json" }, ...opts,
                                  body: opts.body ? JSON.stringify(opts.body) : undefined });
  if (res.status === 401) { location.href = "/.dodil/auth/login?return_to=/"; throw new Error(); }
  const data = await res.json().catch(() => ({}));
  if (!res.ok) { notice(data.detail || "Something went wrong"); throw new Error(data.detail); }
  return data;
}
function notice(text) {
  const n = $("#notice"); n.textContent = text; n.style.display = "block";
  clearTimeout(n.t); n.t = setTimeout(() => n.style.display = "none", 3500);
}
const canEdit = () => state.board && state.board.role !== "viewer";
const isOwner = () => state.board && state.board.role === "owner";

// ---------------------------------------------------------------- boards
async function loadBoards(selectId) {
  state.boards = await api("/api/boards");
  const pick = $("#boardPick");
  pick.innerHTML = state.boards.length
    ? state.boards.map(b => `<option value="${esc(b.id)}">${esc(b.title)} (${esc(b.role)})</option>`).join("")
    : `<option value="">No boards yet</option>`;
  const id = selectId || localStorage.getItem("tf.board");
  const chosen = state.boards.find(b => b.id === id) || state.boards[0];
  if (chosen) { pick.value = chosen.id; await openBoard(chosen.id); }
  else $("#main").innerHTML = `<div class="empty">Create your first board with “+ Board”.</div>`;
}

async function openBoard(id) {
  try { localStorage.setItem("tf.board", id); } catch (e) {}
  state.board = await api(`/api/boards/${encodeURIComponent(id)}`);
  // Re-rendering replaces the card being dragged and kills the drag (a refresh from the last
  // drop often lands while the next drag is under way), so wait until the drag ends.
  if (state.dragging) state.renderLater = true; else renderBoard();
}

const shortDate = iso => new Date(iso + "T00:00:00").toLocaleDateString([], { month: "short", day: "numeric" });
const hue = email => [...email].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) % 360, 7);
const avatar = email => `<span class="avatar" style="--h:${hue(email)}">${esc(initials(email))}</span>`;
// The person, as a coloured badge plus the start of their address; hover shows the full email.
const who = email => `<span class="who" title="${esc(email)}">${avatar(email)}<span class="who-name">${esc(email.split("@")[0])}</span></span>`;
const initials = email => email.split("@")[0].split(/[._-]+/).filter(Boolean).map(w => w[0]).join("").slice(0, 2)
                          || email[0];

// A card reads top to bottom, one kind of information per row: title, labels, then dates and
// progress with who holds it at the right.
function cardChip(c) {
  const bits = [];
  if (c.due_date) bits.push(c.overdue
    ? `<span class="late" title="Overdue: due ${esc(c.due_date)}">${ICON.calendar} ${esc(shortDate(c.due_date))}</span>`
    : `<span title="Due ${esc(c.due_date)}">${ICON.calendar} ${esc(shortDate(c.due_date))}</span>`);
  if (c.checklist_progress) bits.push(`<span title="Checklist">${ICON.checklist} ${esc(c.checklist_progress)}</span>`);
  if (c.blocked) bits.push(`<span class="blocked" title="Waiting on ${esc(c.blocked_by.join(", "))}">${ICON.blocked} blocked</span>`);
  // Who holds it: their colour and initials only, as on most boards; hover or open the card
  // for the full address.
  const holder = c.assignee ? avatar(c.assignee).replace('class="avatar"',
                               `class="avatar" title="${esc(c.assignee)}"`) : "";
  return `<div class="card" draggable="${canEdit()}" data-id="${esc(c.id)}">
      <div class="card-title"><span class="num">#${c.number}</span>${esc(c.title)}</div>
      ${c.labels.length ? `<div class="labels">${c.labels.map(l => `<span class="tag">${esc(l)}</span>`).join("")}</div>` : ""}
      ${bits.length || holder ? `<div class="foot"><div class="bits">${bits.join("")}</div>${holder}</div>` : ""}</div>`;
}

function renderBoard() {
  const b = state.board;
  $("#main").innerHTML = `
    <div class="bar"><h1>${esc(b.title)}</h1><span class="role">${esc(b.role)}</span>
      <button id="members">Members (${b.members.length})</button>
      <button id="activity">Activity</button>
      <button id="summary">Daily summary</button>
      ${isOwner() ? `<button id="boardSettings">Settings</button>` : ""}</div>
    <div class="board">${b.lists.map(l => `
      <section class="col"><h2><span class="name">${esc(l.name)}</span><span>${l.cards.length}</span>
          ${isOwner() ? `<span class="menu"><button class="icon-btn list-menu" data-list="${esc(l.name)}"
            data-count="${l.cards.length}" aria-label="List actions for ${esc(l.name)}">${ICON.more}</button></span>` : ""}</h2>
        <div class="cards" data-list="${esc(l.name)}">${l.cards.map(cardChip).join("")}</div>
        ${canEdit() ? `<form class="addcard" data-list="${esc(l.name)}">
          <input name="t" placeholder="Add a card" maxlength="200"><button>Add</button></form>` : ""}
      </section>`).join("")}
      ${isOwner() ? `<section class="col add-col"><form id="newList">
          <input name="name" placeholder="Add a list" maxlength="40" aria-label="New list name">
          <button class="icon-btn" aria-label="Add list">${ICON.plus}</button></form></section>` : ""}</div>`;
  $("#members").onclick = showMembers;
  $("#activity").onclick = showActivity;
  $("#summary").onclick = showSummary;
  if ($("#newList")) $("#newList").onsubmit = async e => {
    e.preventDefault(); const name = e.target.name.value.trim(); if (!name) return;
    await api(`/api/boards/${b.id}/lists`, { method: "POST", body: { name } });
    await openBoard(b.id); $("#newList input").focus();
  };
  if ($("#boardSettings")) $("#boardSettings").onclick = showBoardSettings;
  document.querySelectorAll(".list-menu").forEach(btn => btn.onclick = e => {
    e.stopPropagation(); listMenu(btn);
  });
  document.querySelectorAll(".addcard").forEach(f => f.onsubmit = async e => {
    e.preventDefault(); const title = f.t.value.trim(); if (!title) return;
    const list = f.dataset.list;
    f.t.value = "";
    try { await api(`/api/boards/${b.id}/cards`, { method: "POST", body: { title, list_name: list } }); }
    catch (err) { f.t.value = title; return; }
    await openBoard(b.id);
    const again = document.querySelector(`.addcard[data-list="${CSS.escape(list)}"] input`);
    if (again) again.focus();                           // ready for the next card
  });
  document.querySelectorAll(".card").forEach(el => el.onclick = () => showCard(el.dataset.id));
  if (canEdit()) wireDragAndDrop();
}

// ---------------------------------------------------------------- drag and drop
// The dragged card never leaves its place (moving the source node makes Chrome end the drag);
// a dashed "drop spot" shows where it will land. The whole column is the drop target.
function wireDragAndDrop() {
  let dragged = null;
  const spot = document.createElement("div");
  spot.className = "drop-spot";
  const clear = () => { spot.remove(); document.querySelectorAll(".col.over")
                                         .forEach(c => c.classList.remove("over")); };
  document.querySelectorAll(".card").forEach(el => {
    el.addEventListener("dragstart", e => {
      dragged = el; state.dragging = true; e.dataTransfer.effectAllowed = "move";
      e.dataTransfer.setData("text/plain", el.dataset.id);
      setTimeout(() => el.classList.add("dragging"));
    });
    el.addEventListener("dragend", () => {
      el.classList.remove("dragging"); dragged = null; clear(); state.dragging = false;
      if (state.renderLater) { state.renderLater = false; renderBoard(); }
    });
  });
  document.querySelectorAll(".col:not(.add-col)").forEach(col => {
    const zone = col.querySelector(".cards");
    col.addEventListener("dragover", e => {
      if (!dragged) return;
      e.preventDefault(); e.dataTransfer.dropEffect = "move";
      if (!col.classList.contains("over")) { clear(); col.classList.add("over"); }
      const before = [...zone.querySelectorAll(".card:not(.dragging)")]
        .find(c => e.clientY < c.getBoundingClientRect().top + c.offsetHeight / 2);
      if (before) { if (spot.nextSibling !== before) zone.insertBefore(spot, before); }
      else if (zone.lastElementChild !== spot) zone.appendChild(spot);
    });
    col.addEventListener("drop", async e => {
      e.preventDefault(); if (!dragged) return;
      const index = [...zone.children].filter(c => c !== dragged).indexOf(spot);
      const card = dragged, id = card.dataset.id;
      // Show the move at once; the server saves it in the background. The refresh after it
      // brings back the true board, so a refused move puts the card back where it was.
      zone.insertBefore(card, spot);
      card.classList.remove("dragging");
      clear();
      try { await api(`/api/cards/${id}/move`, { method: "POST",
                      body: { list_name: zone.dataset.list, index: Math.max(0, index) } }); }
      finally { openBoard(state.board.id); }
    });
  });
}

// ---------------------------------------------------------------- list menu
function closeMenus() { document.querySelectorAll(".menu-pop").forEach(m => m.remove()); }
document.addEventListener("click", e => { if (!e.target.closest(".menu-pop")) closeMenus(); });

function listMenu(btn) {
  closeMenus();
  const name = btn.dataset.list, count = +btn.dataset.count, b = state.board;
  const pop = document.createElement("div");
  pop.className = "menu-pop";
  pop.innerHTML = `<button data-act="rename">Rename list</button>
    <button data-act="delete" class="danger" ${count ? "disabled" : ""}
      title="${count ? "Move its cards out first" : ""}">${count ? `Delete (has ${count} card${count > 1 ? "s" : ""})` : "Delete list"}</button>`;
  btn.parentElement.appendChild(pop);
  pop.querySelector('[data-act="rename"]').onclick = () => {
    closeMenus();
    const label = btn.closest("h2").querySelector(".name");
    label.innerHTML = `<input value="${esc(name)}" maxlength="40" aria-label="List name">`;
    const input = label.querySelector("input"); input.focus(); input.select();
    let done = false;
    const save = async () => {
      if (done) return; done = true;
      const next = input.value.trim();
      if (next && next !== name) {
        try { await api(`/api/boards/${b.id}/lists/${encodeURIComponent(name)}`, { method: "PATCH", body: { new_name: next } }); }
        catch (e) {}
      }
      openBoard(b.id);
    };
    input.onkeydown = e => { if (e.key === "Enter") save(); if (e.key === "Escape") { done = true; openBoard(b.id); } };
    input.onblur = save;
  };
  const del = pop.querySelector('[data-act="delete"]');
  del.onclick = async () => {
    if (del.dataset.armed !== "1") { del.dataset.armed = "1"; del.textContent = "Click again to delete"; return; }
    closeMenus();
    await api(`/api/boards/${b.id}/lists/${encodeURIComponent(name)}`, { method: "DELETE" });
    openBoard(b.id);
  };
}

function showBoardSettings() {
  const b = state.board;
  side(`<h3><span>Board settings</span>${closeBtn}</h3>
    <p class="muted">Lists: rename or delete them from the ${ICON.more} button on each list.
      New lists go before Done; the Done list counts as finished work.</p>
    <div class="danger-zone"><b class="danger">Delete this board</b>
      <p class="muted">Deletes every card, list and history entry on it, for everyone. This can't be undone.
        Type the board's name, <b>${esc(b.title)}</b>, to confirm.</p>
      <form id="delBoard"><div class="row"><input name="title" autocomplete="off" aria-label="Board name">
        <button class="danger">Delete board</button></div></form></div>`);
  $("#delBoard").onsubmit = async e => {
    e.preventDefault();
    await api(`/api/boards/${b.id}/delete`, { method: "POST", body: { title: e.target.title.value } });
    $("#side").classList.remove("open"); state.board = null;
    try { localStorage.removeItem("tf.board"); } catch (e) {}
    notice(`Deleted ${b.title}`); loadBoards();
  };
}

function showNewBoard() {
  side(`<h3><span>New board</span>${closeBtn}</h3>
    <form id="nb"><div class="field"><label>Name</label>
      <input name="title" maxlength="80" required placeholder="e.g. Marketing"></div>
      <div class="field"><label>Lists (comma separated)</label>
      <input name="lists" value="To do, Doing, Done"></div>
      <button class="primary">Create board</button></form>`);
  const f = $("#nb"); f.title.focus();
  f.onsubmit = async e => {
    e.preventDefault();
    const lists = f.lists.value.split(",").map(x => x.trim()).filter(Boolean);
    const nb = await api("/api/boards", { method: "POST", body: { title: f.title.value, lists: lists.length ? lists : null } });
    $("#side").classList.remove("open"); loadBoards(nb.id);
  };
}

// ---------------------------------------------------------------- side panel
function side(html) { const s = $("#side"); s.innerHTML = html; s.classList.add("open");
  $(".close", s).onclick = () => s.classList.remove("open"); }
const closeBtn = `<button class="close icon-btn" aria-label="Close">${ICON.close}</button>`;
// Close the panel with Esc or a click on empty space (clicks on cards and controls keep working).
document.addEventListener("click", e => {
  const s = $("#side");
  if (!s.classList.contains("open")) return;
  if (e.target.closest("#side, .card, button, a, input, select, textarea, label, #notice")) return;
  s.classList.remove("open");
});
document.addEventListener("keydown", e => { if (e.key === "Escape") $("#side").classList.remove("open"); });

async function showCard(id) {
  const c = await api(`/api/cards/${encodeURIComponent(id)}`);
  const ro = canEdit() ? "" : "disabled";
  const people = state.board.members.filter(m => m.role !== "viewer");
  const others = state.board.lists.flatMap(l => l.cards).filter(x => x.id !== c.id);
  side(`<h3><span>#${c.number} · ${esc(c.list_name)}</span>${closeBtn}</h3>
    <form id="cardForm">
      <div class="field"><label>Title</label><input name="title" value="${esc(c.title)}" ${ro} maxlength="200"></div>
      <div class="field"><label>Description</label><textarea name="description" rows="4" ${ro}>${esc(c.description)}</textarea></div>
      <div class="row">
        <div class="field"><label>Assignee</label><select name="assignee" ${ro}>
          <option value="">Nobody</option>${people.map(p => `<option ${p.email === c.assignee ? "selected" : ""}
            value="${esc(p.email)}">${esc(p.email)}</option>`).join("")}</select></div>
        <div class="field"><label>Due date</label><input type="date" name="due_date" value="${esc(c.due_date || "")}" ${ro}></div>
      </div>
      <div class="field"><label>Labels (comma separated)</label><input name="labels" value="${esc(c.labels.join(", "))}" ${ro}></div>
      ${canEdit() ? `<button class="primary">Save</button>` : ""}
    </form>
    <h4>Checklist ${c.checklist_progress ? `<span class="muted">${esc(c.checklist_progress)}</span>` : ""}</h4>
    <ul class="plain${c.checklist.length ? " box" : ""}">${c.checklist.map((it, i) => `<li><input type="checkbox" style="width:auto" data-i="${i + 1}"
      ${it.done ? "checked" : ""} ${ro}> ${esc(it.text)}</li>`).join("") || `<li class="muted">No items</li>`}</ul>
    ${canEdit() ? `<div class="row" style="margin-top:8px"><input id="newItem" placeholder="New item">
      <button id="addItem" data-guard>Add</button><button id="aiItems" title="Dodil Models">${ICON.sparkle} AI checklist</button></div>` : ""}
    <h4>Blocked by</h4>
    <ul class="plain">${c.blocked_by.map(n => `<li>${esc(n)} ${canEdit() ? `<button data-unblock="${esc(n)}">remove</button>` : ""}</li>`).join("")
      || `<li class="muted">Nothing</li>`}</ul>
    ${canEdit() && others.length ? `<div class="row" style="margin-top:8px"><select id="blocker">${others.map(o =>
      `<option value="${esc(o.id)}">#${o.number} ${esc(o.title)}</option>`).join("")}</select>
      <button id="addBlocker" data-guard>Add</button></div>` : ""}
    <p><button id="similar">Find similar cards</button></p><div id="similarOut"></div>
    <h4>History <span class="count">(${c.history.length}${c.history.length >= 20 ? " latest" : ""})</span></h4>
    <div class="box">${feed(c.history)}</div>
    ${canEdit() ? `<p style="margin-top:20px"><button id="delCard" class="danger">${ICON.trash} Delete card</button></p>` : ""}`);
  if ($("#delCard")) $("#delCard").onclick = async e => {
    const btn = e.currentTarget;
    if (btn.dataset.armed !== "1") { btn.dataset.armed = "1"; btn.textContent = "Click again to delete #" + c.number; return; }
    await api(`/api/cards/${c.id}`, { method: "DELETE" });
    $("#side").classList.remove("open"); notice(`Deleted #${c.number}`); openBoard(state.board.id);
  };

  const reopen = async () => { await openBoard(state.board.id); showCard(c.id); };
  const form = $("#cardForm");
  form.onsubmit = async e => {
    e.preventDefault();
    await api(`/api/cards/${c.id}`, { method: "PATCH", body: {
      title: form.title.value, description: form.description.value,
      assignee: form.assignee.value || null, due_date: form.due_date.value || null,
      labels: form.labels.value.split(",").map(s => s.trim()).filter(Boolean) } });
    reopen();
  };
  document.querySelectorAll("#side input[type=checkbox]").forEach(box => box.onchange = async () => {
    await api(`/api/cards/${c.id}/checklist/${box.dataset.i}`, { method: "POST", body: { done: box.checked } });
    reopen();
  });
  if ($("#addItem")) $("#addItem").onclick = async () => {
    const text = $("#newItem").value.trim(); if (!text) return;
    await api(`/api/cards/${c.id}/checklist`, { method: "POST", body: { items: [text] } }); reopen();
  };
  if ($("#aiItems")) $("#aiItems").onclick = async e => {
    e.target.disabled = true; e.target.textContent = "Thinking…";
    try { await api(`/api/cards/${c.id}/checklist/ai`, { method: "POST" }); } finally { reopen(); }
  };
  if ($("#addBlocker")) $("#addBlocker").onclick = async () => {
    await api(`/api/cards/${c.id}/blockers`, { method: "POST", body: { blocked_by: $("#blocker").value } }); reopen();
  };
  document.querySelectorAll("[data-unblock]").forEach(btn => btn.onclick = async () => {
    await api(`/api/cards/${c.id}/blockers/${encodeURIComponent(btn.dataset.unblock.slice(1))}`, { method: "DELETE" });
    reopen();
  });
  $("#similar").onclick = async e => {
    e.target.disabled = true;
    const found = await api(`/api/cards/${c.id}/similar`).finally(() => e.target.disabled = false);
    $("#similarOut").innerHTML = found.length ? `<ul class="plain box">${found.map(f =>
      `<li>#${f.number} ${esc(f.title)} <span class="muted">${esc(f.list_name)}</span></li>`).join("")}</ul>`
      : `<p class="muted">Nothing close.</p>`;
  };
}

async function showMembers() {
  const b = state.board;
  side(`<h3><span>Members of ${esc(b.title)}</span>${closeBtn}</h3>
    <ul class="plain box" style="max-height:260px">${b.members.map(m => `<li>${avatar(m.email)}<span style="flex:1;min-width:0;overflow-wrap:anywhere">${esc(m.email)}</span>
      ${isOwner() && m.role !== "owner"
        ? `<select data-role="${esc(m.email)}" aria-label="Role of ${esc(m.email)}" style="width:auto">
             ${["member", "viewer"].map(r => `<option value="${r}" ${r === m.role ? "selected" : ""}>${r}</option>`).join("")}
           </select><button data-rm="${esc(m.email)}">remove</button>`
        : `<span class="role">${esc(m.role)}</span>`}</li>`).join("")}</ul>
    ${isOwner() ? `<form id="invite" style="margin-top:12px"><div class="field"><label>Invite by email</label>
      <input name="email" type="email" required></div><div class="row"><select name="role">
      <option value="member">member: can edit</option><option value="viewer">viewer: read only</option></select>
      <button class="primary">Invite</button></div>
      <p class="muted">Members can edit cards; viewers can only look. Change a role with its dropdown.</p></form>`
      : `<p class="muted">Only the owner manages members.</p>`}`);
  document.querySelectorAll("[data-role]").forEach(sel => sel.onchange = async () => {
    try { await api(`/api/boards/${b.id}/members`, { method: "POST",
                    body: { email: sel.dataset.role, role: sel.value } });
          notice(`${sel.dataset.role} is now a ${sel.value}`); }
    finally { await openBoard(b.id); showMembers(); }
  });
  if ($("#invite")) $("#invite").onsubmit = async e => {
    e.preventDefault(); const f = e.target;
    await api(`/api/boards/${b.id}/members`, { method: "POST", body: { email: f.email.value, role: f.role.value } });
    await openBoard(b.id); showMembers();
  };
  document.querySelectorAll("[data-rm]").forEach(btn => btn.onclick = async () => {
    if (!confirm(`Remove ${btn.dataset.rm}? Their cards become unassigned.`)) return;
    await api(`/api/boards/${b.id}/members/${encodeURIComponent(btn.dataset.rm)}`, { method: "DELETE" });
    await openBoard(b.id); showMembers();
  });
}

async function showActivity() {
  const items = await api(`/api/boards/${state.board.id}/activity?limit=60`);
  side(`<h3><span>Activity</span>${closeBtn}</h3>${feed(items)}`);
}

async function showSummary() {
  side(`<h3><span>Daily summary</span>${closeBtn}</h3><p class="muted">Asking Dodil Models…</p>`);
  const s = await api(`/api/boards/${state.board.id}/summary`);
  side(`<h3><span>Daily summary</span>${closeBtn}</h3><p style="white-space:pre-wrap">${esc(s.summary)}</p>`);
}

async function showList(title, cards) {
  $("#main").innerHTML = `<div class="bar"><h1>${esc(title)}</h1><button id="back">Back to board</button></div>
    <div class="list-view" style="max-width:640px">${cards.map(c => `<div class="card" data-id="${esc(c.id)}"
      data-board="${esc(c.board_id)}"><span class="num">#${c.number}</span>${esc(c.title)}
      <div class="meta"><span class="tag">${esc(c.board)}</span><span>${esc(c.list_name)}</span>
      ${c.due_date ? `<span class="${c.overdue ? "late" : ""}">due ${esc(c.due_date)}</span>` : ""}</div></div>`).join("")
      || `<div class="empty">Nothing here. Nice.</div>`}</div>`;
  $("#back").onclick = () => state.board ? renderBoard() : loadBoards();
  document.querySelectorAll(".list-view .card").forEach(el => el.onclick = async () => {
    $("#boardPick").value = el.dataset.board; await openBoard(el.dataset.board); showCard(el.dataset.id);
  });
}

// ---------------------------------------------------------------- start
$("#boardPick").onchange = e => e.target.value && openBoard(e.target.value);
$("#newBoard").onclick = showNewBoard;
$("#myWork").onclick = async () => showList("My open cards", await api("/api/me/cards"));
$("#lateWork").onclick = async () => showList("Overdue", await api("/api/overdue"));
api("/api/me").then(m => { state.me = m.email; $("#who").textContent = m.email; });
// ---------------------------------------------------------------- theme
const isDark = () => (document.documentElement.dataset.theme ||
  (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")) === "dark";
const paintToggle = () => { $("#theme").innerHTML = isDark() ? ICON.sun : ICON.moon; };
$("#theme").onclick = () => {
  const next = isDark() ? "light" : "dark";
  document.documentElement.dataset.theme = next;
  try { localStorage.setItem("tf.theme", next); } catch (e) {}
  paintToggle();
};
paintToggle();
loadBoards();
</script>
</body>
</html>