sirenamc/stackline

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

A remix template · 0 remixes · Remix sirenamc/stackline

app/boards/doors/page.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Stackline</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT@9..144,500..700,50..100&family=Hanken+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/paper.css">
<style>
/* The board: lists are trays on the paper, cards are numbered slips you can pick up. */
button { font: 500 14.5px var(--sans); background: var(--slip); border: 1px solid var(--rule); color: var(--ink); border-radius: 999px; padding: 5px 13px; cursor: pointer; }
button:hover { border-color: var(--rule-strong); }
button.primary { font-weight: 600; background: var(--ink); color: var(--paper); border-color: var(--ink); padding: 7px 17px; }
button.ghost { background: none; border-color: transparent; color: var(--ink-faded); }
input, select, textarea { background: var(--slip); border: 1px solid var(--rule); border-radius: 8px; padding: 6px 8px; width: 100%; font: 15px var(--sans); }
select { padding: 5px 8px; width: auto; }
.desk-tools { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; }
.desk-tools .search { width: 13rem; }
.desk-tools .me { color: var(--ink-faded); font-size: 14px; display: flex; gap: 12px; align-items: center; }
.desk-tools .me span { max-width: 15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.demo { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-top: 4px; padding: 9px 14px; background: var(--butter); color: #23211c; border-radius: 10px; font-size: 14.5px; }
.demo button { background: rgba(255,255,255,.6); border-color: transparent; color: #23211c; }
.demo b { font-weight: 600; }

/* the board's own headline */
.bar { display: flex; align-items: baseline; gap: 8px 14px; flex-wrap: wrap; padding: 18px 0 2px; }
.bar h2 { margin: 0; font: 600 clamp(30px, 3.6vw, 44px)/1.05 var(--serif); font-variation-settings: "SOFT" 100; letter-spacing: -1px; }
.role { font-size: 13px; color: var(--ink-faded); border: 1px solid var(--rule); border-radius: 99px; padding: 1px 9px; }
.bar .tools { display: flex; gap: 8px; flex-wrap: wrap; margin-left: auto; }

/* phrases this board answers to in Claude */
.ask { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin: 10px 0 2px; font-size: 14px; }
.ask > span { color: var(--ink-faded); margin-right: 2px; }
.ask button { border-radius: 99px; padding: 3px 11px; font-size: 13.5px; }
.ask button::before { content: "\201C"; } .ask button::after { content: "\201D"; }

/* the lists: tabbed folders, one colour each; cards are index cards with the number on top */
.cols { display: flex; align-items: flex-start; gap: 18px; margin-top: 30px; overflow-x: auto; min-height: 55vh; padding: 0 2px 10px; }
.col { --tab: var(--sage); flex: 0 0 290px; position: relative; padding: 12px 10px 10px; background: color-mix(in srgb, var(--tab) 38%, var(--paper)); border-radius: 0 12px 12px 12px; display: flex; flex-direction: column; margin-top: 22px; }
.col:nth-child(5n+2) { --tab: var(--butter); } .col:nth-child(5n+3) { --tab: var(--blush); }
.col:nth-child(5n+4) { --tab: var(--sky); } .col:nth-child(5n+5) { --tab: var(--lilac); }
.col h2 { position: absolute; left: 0; top: -26px; margin: 0; background: var(--tab); border-radius: 10px 10px 0 0; padding: 4px 14px 2px; }
.col h2 .tag { display: flex; gap: 10px; align-items: baseline; font: 600 14.5px var(--sans); color: #23211c; }
.col h2 .name { cursor: text; }
.col h2 .n { font: 500 13px var(--sans); color: rgba(35,33,28,.65); }
.col.done h2 .name::after { content: " \2713"; }
.cards { flex: 1; min-height: 2.5rem; display: flex; flex-direction: column; gap: 9px; border-radius: 8px; }
.cards.over { outline: 2px dashed var(--ink); outline-offset: 3px; }

.card { position: relative; padding: 10px 12px 11px; background: var(--slip); border-radius: 4px 10px 10px 10px; box-shadow: 0 1px 0 rgba(60,40,10,.08), 0 6px 14px -10px rgba(60,40,10,.35); cursor: pointer; transition: transform .12s ease; }
.card[draggable=true] { cursor: grab; }
.card:hover { transform: translateY(-1px) rotate(-.4deg); }
.card.dragging { opacity: .35; }
.card.late { box-shadow: inset 3px 0 0 var(--red), 0 6px 14px -10px rgba(60,40,10,.35); }
.card .num { font: 600 12px var(--sans); color: var(--ink-faded); letter-spacing: .3px; }
.card .t { font: 600 17px/1.25 var(--serif); font-variation-settings: "SOFT" 100; margin: 2px 0 7px; word-wrap: break-word; }
.meta { display: flex; flex-wrap: wrap; align-items: center; gap: 5px 6px; font-size: 12.5px; color: var(--ink-faded); }
.chip { background: var(--paper); border-radius: 99px; padding: 0 8px; line-height: 1.7; }
.chip.label { background: color-mix(in srgb, var(--lilac) 45%, var(--paper)); color: var(--ink); }
.chip.soon { color: var(--amber); font-weight: 600; }
.chip.blocked { color: var(--violet); font-weight: 600; background: color-mix(in srgb, var(--lilac) 35%, var(--paper)); }
.chip.late { color: var(--red); font-weight: 600; background: color-mix(in srgb, var(--blush) 45%, var(--paper)); }
.who { margin-left: auto; width: 25px; height: 25px; border-radius: 50%; display: grid; place-items: center; font: 600 10.5px var(--sans); color: #23211c; background: var(--tab, var(--sage)); }
.add input { margin-top: 9px; background: transparent; border: 1px dashed rgba(35,33,28,.3); }
.newlist { background: none; border: 1.5px dashed var(--rule-strong); border-radius: 12px; margin-top: 22px; }
.newlist input { background: transparent; border: 0; }

/* an empty desk: three ways to start */
.empty { padding: 2.5rem 1rem; margin: 0 auto; max-width: 52rem; }
.empty .lead { font: 600 34px/1.15 var(--serif); font-variation-settings: "SOFT" 100; margin: 0 0 6px; text-align: center; }
.empty > p { text-align: center; color: var(--ink-faded); margin: 0 0 22px; }
.starts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.start { background: var(--slip); border-radius: 4px 14px 14px 14px; box-shadow: 0 8px 22px -16px rgba(60,40,10,.45); padding: 18px; display: flex; flex-direction: column; gap: 8px; }
.start h3 { margin: 0; font: 600 20px var(--serif); font-variation-settings: "SOFT" 100; }
.start:nth-child(1) { border-top: 8px solid var(--sage); } .start:nth-child(2) { border-top: 8px solid var(--butter); } .start:nth-child(3) { border-top: 8px solid var(--blush); }
.start p { margin: 0; color: var(--ink-faded); font-size: 14.5px; flex: 1; }
.plan-form { display: grid; gap: 10px; }
.plan-form label { font-size: 13px; color: var(--ink-faded); display: block; margin-bottom: 3px; }

/* card drawer and panels */
.drawer { position: fixed; inset: 0 0 0 auto; width: min(500px, 100vw); background: var(--slip); border-left: 1px solid var(--rule-strong); box-shadow: -10px 0 30px rgba(0,0,0,.2); transform: translateX(100%); transition: transform .2s ease; z-index: 10; display: flex; flex-direction: column; }
.drawer.open { transform: none; }
.drawer .head { display: flex; justify-content: space-between; align-items: center; padding: 12px 18px; border-bottom: 1px solid var(--rule); font-size: 14px; color: var(--ink-faded); }
.drawer .body { padding: 16px 18px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; }
.drawer label { font-size: 13px; color: var(--ink-faded); display: block; margin-bottom: 3px; }
.drawer #fTitle { font: 600 21px var(--serif); font-variation-settings: "SOFT" 100; }
.row { display: flex; gap: 10px; } .row > * { flex: 1; }
.check { display: flex; align-items: center; gap: 8px; padding: 2px 0; }
.check input { width: auto; accent-color: var(--accent); }
.check.done span { text-decoration: line-through; color: var(--ink-faded); }
.hist { font-size: 14px; color: var(--ink-faded); border-left: 2px solid var(--rule); padding-left: 10px; }
.hist div { margin: 6px 0; } .hist b { color: var(--ink); font-weight: 600; }
.list-plain { margin: 0; padding-left: 18px; } .list-plain li { margin: 4px 0; }
.panel { position: fixed; inset: auto 18px 18px auto; width: min(460px, calc(100vw - 36px)); max-height: 72vh; overflow: auto; background: var(--paper); border: 1px solid var(--rule); border-radius: 14px; box-shadow: 0 10px 30px rgba(0,0,0,.22); padding: 14px 18px; z-index: 9; display: none; }
.panel.open { display: block; }
.panel h3 { margin: 0 0 10px; font: 600 20px var(--serif); font-variation-settings: "SOFT" 100; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--rule); padding-bottom: 6px; }
.toast { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); background: var(--ink); color: var(--paper); padding: 9px 18px; border-radius: 999px; z-index: 20; display: none; max-width: 90vw; }
.muted { color: var(--ink-faded); }
.pre { white-space: pre-wrap; line-height: 1.55; }
@media (max-width: 760px) {
  .desk-tools .search { width: 100%; }
  .bar .tools { margin-left: 0; }
  .ask { flex-wrap: nowrap; overflow-x: auto; white-space: nowrap; }
  .ask button { flex: none; }
  .cols { flex-direction: column; overflow-x: visible; }
  .col { flex-basis: auto; width: 100%; margin-top: 30px; }
  .starts { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) { .drawer, .card { transition: none; } .card:hover { transform: none; } }
</style>
</head>
<body>
<div class="sheet"><div class="paper">
<header class="masthead">
  <a class="brand" href="/" aria-label="Stackline">
    <svg width="40" height="40" viewBox="0 0 40 40" aria-hidden="true"><path d="M9 6h8l2 3h14a2 2 0 0 1 2 2v12H7V8a2 2 0 0 1 2-2z" fill="var(--blush)"/><path d="M6 13h12l2 3h13a2 2 0 0 1 2 2v12H4V15a2 2 0 0 1 2-2z" fill="var(--butter)"/><path d="M4 20h7l2 3h21a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V22a2 2 0 0 1 2-2z" fill="var(--sage)"/><path d="M8 29h14" stroke="var(--ink)" stroke-width="2.2" stroke-linecap="round"/></svg>
    <h1 class="nameplate">Stackline</h1>
  </a>
  <nav class="desk-tools" aria-label="Boards">
    <select id="boardSel" aria-label="Board"></select>
    <button id="newBoardBtn">New board</button>
    <input id="search" class="search" type="search" placeholder="Search cards" aria-label="Search cards">
    <button id="plateBtn">My plate</button>
    <button id="overdueBtn">Overdue</button>
  </nav>
  <span class="grow"></span>
  <span class="desk-tools"><span class="me"><span id="who"></span><a href="/.dodil/auth/logout">Sign out</a></span>
    <button type="button" class="switch" id="themeToggle" aria-pressed="false">Dark</button></span>
</header>
<div class="demo" id="demo" hidden></div>

<div class="bar" id="bar" hidden>
  <h2 id="title"></h2><span class="role" id="role"></span>
  <div class="tools">
    <button id="membersBtn">Members</button>
    <button id="activityBtn">Activity</button>
    <button id="summaryBtn">Daily summary</button>
  </div>
</div>
<div class="ask" id="ask" hidden></div>
<main class="cols" id="cols"><div class="empty">Setting the page…</div></main>
</div></div>

<aside class="drawer" id="drawer" aria-label="Card">
  <div class="head"><b id="dTitle"></b><button class="ghost" id="dClose" aria-label="Close">✕</button></div>
  <div class="body" id="dBody"></div>
</aside>
<section class="panel" id="panel"><h3><span id="pTitle"></span><button class="ghost" id="pClose" aria-label="Close">✕</button></h3><div id="pBody"></div></section>
<div class="toast" id="toast"></div>

<script>
const $ = s => document.querySelector(s);
const esc = v => String(v ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const state = {board: null, boards: [], me: ""};

async function api(path, opts = {}) {
  const r = await fetch(path, {headers: {"content-type": "application/json"}, ...opts,
    body: opts.body ? JSON.stringify(opts.body) : undefined});
  if (r.status === 401) { location.href = "/.dodil/auth/login?return_to=/"; throw new Error("sign in"); }
  const data = await r.json().catch(() => ({}));
  if (!r.ok) { toast(data.detail || r.statusText); throw new Error(data.detail); }
  return data;
}
function toast(msg) { const t = $("#toast"); t.textContent = msg; t.style.display = "block";
  clearTimeout(t._h); t._h = setTimeout(() => t.style.display = "none", 3200); }
const canEdit = () => state.board && state.board.role !== "viewer";
const isOwner = () => state.board && state.board.role === "owner";
const initials = e => { const parts = (e || "?").split("@")[0].split(/[._-]+/).filter(Boolean);
  return esc((parts.length > 1 ? parts[0][0] + parts[1][0] : parts[0].slice(0, 2)).toUpperCase()); };

const fmtDate = d => new Date(d + "T00:00:00").toLocaleDateString("en-GB", {day: "numeric", month: "short"});
function dueChip(c) {
  if (!c.due_date) return "";
  const days = Math.round((new Date(c.due_date) - new Date(new Date().toISOString().slice(0,10))) / 864e5);
  if (c.overdue) return "";
  return `<span class="chip ${!c.done && days <= 2 ? "soon" : ""}">due ${esc(fmtDate(c.due_date))}</span>`;
}
function lateStamp(c) {
  if (!c.overdue) return "";
  const d = Math.round((new Date(new Date().toISOString().slice(0,10)) - new Date(c.due_date)) / 864e5);
  return `<span class="chip late" title="Due ${esc(fmtDate(c.due_date))}">${d} d late</span>`;
}
function cardHtml(c) {
  const blocked = (c.open_blockers || []).length;
  const ticks = c.checklist.length ? `<span title="Checklist">☑ ${c.checklist.filter(i => i.done).length}/${c.checklist.length}</span>` : "";
  return `<div class="card ${c.overdue ? "late" : ""}" tabindex="0" draggable="${canEdit()}" data-n="${c.number}" aria-label="Card ${c.number}: ${esc(c.title)}">
    <span class="num">#${c.number}</span>
    <div class="t">${esc(c.title)}</div>
    <div class="meta">${lateStamp(c)}${dueChip(c)}${blocked ? `<span class="chip blocked">waits on ${c.open_blockers.map(n => "#" + n).join(", ")}</span>` : ""}
      ${c.labels.map(l => `<span class="chip label">${esc(l)}</span>`).join("")}${ticks}
      ${c.assignee ? `<span class="who" title="${esc(c.assignee)}">${initials(c.assignee)}</span>` : ""}</div></div>`;
}

async function loadBoards(select) {
  state.boards = await api("/api/boards");
  const sel = $("#boardSel");
  sel.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 = select || location.hash.slice(1) || (state.boards[0] && state.boards[0].id);
  if (id) { sel.value = id; await showBoard(id); }
  else { $("#bar").hidden = true; $("#ask").hidden = true;
    $("#cols").innerHTML = `<div class="empty"><p class="lead">Start your first board</p><p>Or ask a board owner to invite ${esc(state.me)}.</p>${startsHtml()}</div>`; wireStarts(); }
}

// Three ways to start, on the empty desk and behind "New board".
function startsHtml() {
  return `<div class="starts">
    <div class="start"><h3>Try the example</h3><p>A website launch with nine cards, two of them late and a chain of blockers. Every feature has something to show.</p><button class="primary" id="sExample">Open the example board</button></div>
    <div class="start"><h3>Plan from one sentence</h3><form class="plan-form" id="sPlan">
      <div><label for="pTitleIn">Board name</label><input id="pTitleIn" name="title" required placeholder="Site launch"></div>
      <div><label for="pGoal">What is it for?</label><input id="pGoal" name="goal" required placeholder="Launch our new website"></div>
      <div><label for="pDate">By when?</label><input id="pDate" name="deadline" type="date" required></div>
      <button class="primary">Draft the board</button><p class="muted" style="margin:0">Dodil Models drafts the cards, due dates and blockers. You edit them after.</p></form></div>
    <div class="start"><h3>Start empty</h3><p>To do, Doing and Done, and nothing else.</p><form id="sEmpty" class="plan-form"><input name="title" required placeholder="Board name" aria-label="Board name"><button>Create the board</button></form></div>
  </div>`;
}
function wireStarts() {
  $("#sExample").onclick = async () => { $("#sExample").disabled = true;
    try { const b = await api("/api/example", {method: "POST"}); $("#panel").classList.remove("open"); loadBoards(b.id); } finally { $("#sExample").disabled = false; } };
  const d = new Date(Date.now() + 21 * 864e5); $("#pDate").value = d.toISOString().slice(0, 10);
  $("#sPlan").onsubmit = async e => { e.preventDefault(); const f = e.target, btn = f.querySelector("button");
    btn.disabled = true; btn.textContent = "Drafting…";
    try { const b = await api("/api/boards/plan", {method: "POST", body: {title: f.title.value, goal: f.goal.value, deadline: f.deadline.value}});
      $("#panel").classList.remove("open"); toast(`${b.created_cards.length} cards drafted. Change anything you like.`); loadBoards(b.id); }
    finally { btn.disabled = false; btn.textContent = "Draft the board"; } };
  $("#sEmpty").onsubmit = async e => { e.preventDefault();
    const b = await api("/api/boards", {method: "POST", body: {title: e.target.title.value}}); $("#panel").classList.remove("open"); loadBoards(b.id); };
}

async function showBoard(id) {
  const b = await api("/api/boards/" + encodeURIComponent(id));
  state.board = b; location.hash = b.id;
  $("#bar").hidden = false; $("#title").textContent = b.title; $("#role").textContent = b.role;
  $("#membersBtn").textContent = `Members (${b.members.length})`;
  $("#summaryBtn").disabled = false;
  askClaude(b);
  $("#cols").innerHTML = b.lists.map(l => `<section class="col ${l.is_done ? "done" : ""}" data-list="${esc(l.id)}">
      <h2><span class="tag"><span class="name" data-list="${esc(l.id)}" title="${canEdit() ? "Double-click to rename" : ""}">${esc(l.name)}</span><span class="n">${l.cards.length}</span></span></h2>
      <div class="cards" data-list="${esc(l.id)}">${l.cards.map(cardHtml).join("")}</div>
      ${canEdit() ? `<form class="add" data-list="${esc(l.name)}"><input name="t" placeholder="+ Add a card" aria-label="New card in ${esc(l.name)}"></form>` : ""}
    </section>`).join("") + (canEdit() ? `<section class="col newlist"><form id="newList"><input name="n" placeholder="+ Add a list"></form></section>` : "");
  wire();
}

// The phrases this board answers to in Claude or ChatGPT, built from its own lists and cards.
function askClaude(b) {
  const open = b.lists.filter(l => !l.is_done);
  const any = b.lists.flatMap(l => l.cards).find(c => !c.done);
  const done = b.lists.find(l => l.is_done);
  const phrases = ["What's on my plate this week?", `What's overdue on ${b.title}?`];
  if (any && done) phrases.push(`Move card ${any.number} to ${done.name}`);
  if (open[1] && canEdit()) phrases.push(`Add "Send invoice" to ${open[1].name} on ${b.title}`);
  phrases.push(`Give me today's stand-up for ${b.title}`);
  phrases.push(`Set up a board for our product launch on the 30th, it's me and Ola`);
  const el = $("#ask"); el.hidden = false;
  el.innerHTML = `<span>Ask Claude</span>` + phrases.map(p => `<button type="button" title="Copy">${esc(p)}</button>`).join("");
  el.querySelectorAll("button").forEach(btn => btn.onclick = async () => {
    try { await navigator.clipboard.writeText(btn.textContent); toast("Copied. Paste it into Claude with the Stackline connector."); }
    catch (e) { toast(btn.textContent); }
  });
}

function wire() {
  document.querySelectorAll(".card").forEach(el => {
    el.onclick = () => openCard(el.dataset.n);
    el.onkeydown = e => { if (e.key === "Enter") openCard(el.dataset.n); };
    el.ondragstart = e => { e.dataTransfer.setData("application/x-card", el.dataset.n); e.dataTransfer.effectAllowed = "move"; el.classList.add("dragging"); };
    el.ondragend = () => el.classList.remove("dragging");
  });
  // The whole column takes the drop (not only its card area), and it never lands as text in an input.
  document.querySelectorAll("section.col[data-list]").forEach(col => {
    const zone = col.querySelector(".cards");
    col.ondragover = e => { if (!canEdit()) return; e.preventDefault(); zone.classList.add("over"); };
    col.ondragleave = e => { if (!col.contains(e.relatedTarget)) zone.classList.remove("over"); };
    col.ondrop = async e => {
      e.preventDefault(); zone.classList.remove("over");
      const n = e.dataTransfer.getData("application/x-card") || e.dataTransfer.getData("text/plain");
      if (!/^\d+$/.test(n)) return;
      const others = [...zone.querySelectorAll(".card")].filter(c => c.dataset.n !== n);
      let pos = others.findIndex(c => e.clientY < c.getBoundingClientRect().top + c.offsetHeight / 2);
      if (pos < 0) pos = others.length;
      await api(`/api/cards/${n}/move`, {method: "POST", body: {list_name: zone.dataset.list, position: pos}});
      showBoard(state.board.id);
    };
  });
  document.querySelectorAll("form.add").forEach(f => f.onsubmit = async e => {
    e.preventDefault(); const t = f.t.value.trim(); if (!t) return;
    await api(`/api/boards/${state.board.id}/cards`, {method: "POST", body: {title: t, list_name: f.dataset.list}});
    await showBoard(state.board.id); const again = document.querySelector(`form.add[data-list="${CSS.escape(f.dataset.list)}"] input`); again && again.focus();
  });
  const nl = $("#newList");
  if (nl) nl.onsubmit = async e => { e.preventDefault(); const n = nl.n.value.trim(); if (!n) return;
    await api(`/api/boards/${state.board.id}/lists`, {method: "POST", body: {name: n}}); showBoard(state.board.id); };
  if (canEdit()) document.querySelectorAll("h2 .name").forEach(s => s.ondblclick = async () => {
    const name = prompt("Rename list", s.textContent); if (!name || name === s.textContent) return;
    await api(`/api/boards/${state.board.id}/lists/${s.dataset.list}`, {method: "PATCH", body: {name}}); showBoard(state.board.id);
  });
}

async function openCard(n) {
  const c = await api("/api/cards/" + n);
  const b = state.board && state.board.id === c.board_id ? state.board : await api("/api/boards/" + c.board_id);
  const ro = b.role === "viewer" ? "disabled" : "";
  $("#dTitle").textContent = `Card #${c.number}, ${c.list} on ${c.board}`;
  const people = b.members.map(m => `<option value="${esc(m.email)}" ${m.email === c.assignee ? "selected" : ""}>${esc(m.email)}</option>`).join("");
  $("#dBody").innerHTML = `
    <div><label>Title</label><input id="fTitle" value="${esc(c.title)}" ${ro}></div>
    <div><label>Description</label><textarea id="fDesc" rows="4" ${ro}>${esc(c.description)}</textarea></div>
    <div class="row">
      <div><label>Assignee</label><select id="fWho" ${ro}><option value="">Nobody</option>${people}</select></div>
      <div><label>Due date</label><input id="fDue" type="date" value="${esc(c.due_date || "")}" ${ro}></div>
    </div>
    <div><label>Labels (comma separated)</label><input id="fLabels" value="${esc(c.labels.join(", "))}" ${ro}></div>
    ${ro ? `<p class="muted">You're a viewer on this board, so it's read-only for you.</p>` : `<div class="row"><button class="primary" id="fSave">Save</button><button id="fDel">Delete card</button></div>`}
    <div><label>Checklist</label><div id="ck">${c.checklist.map((i, k) => `<div class="check ${i.done ? "done" : ""}"><input type="checkbox" data-k="${k}" ${i.done ? "checked" : ""} ${ro}><span>${esc(i.text)}</span></div>`).join("") || `<span class="muted">No checklist yet.</span>`}</div>
      ${ro ? "" : `<div class="row" style="margin-top:.4rem"><input id="ckNew" placeholder="Add an item"><button id="ckAI" title="Uses Dodil Models">Suggest with AI</button></div><div id="ckSug"></div>`}</div>
    <div><label>Blocked by</label>${c.blocked_by.length ? c.blocked_by.map(x => `<span class="chip ${c.open_blockers && c.open_blockers.includes(x) ? "blocked" : ""}">#${x}${ro ? "" : ` <a href="#" data-unblock="${x}">✕</a>`}</span>`).join(" ") : `<span class="muted">Nothing.</span>`}
      ${ro ? "" : `<form id="blk" class="row" style="margin-top:.4rem"><input name="n" placeholder="Card number, e.g. 7"><button>Add blocker</button></form>`}
      <button class="ghost" id="chain" style="padding-left:0">What is blocking this?</button><div id="chainOut"></div></div>
    <div><label>Similar cards</label><button id="sim">Find cards like this</button><div id="simOut"></div></div>
    <div><label>History</label><div class="hist">${c.history.map(h => `<div><b>${esc(h.who)}</b> ${esc(h.action)}${h.field && (h.before || h.after) ? ` ${esc(h.field)}: ${esc(h.before || "(none)")} → ${esc(h.after || "(none)")}` : ""}<br>${esc((h.at || "").replace("T", " "))}</div>`).join("") || "No changes yet."}</div></div>`;
  $("#drawer").classList.add("open");
  const reopen = async () => { await openCard(n); if (state.board) showBoard(state.board.id); };
  if (!ro) {
    $("#fSave").onclick = async () => {
      await api("/api/cards/" + n, {method: "PATCH", body: {title: $("#fTitle").value, description: $("#fDesc").value,
        due_date: $("#fDue").value, labels: $("#fLabels").value.split(",").map(s => s.trim()).filter(Boolean)}});
      const who = $("#fWho").value || null;
      if (who !== (c.assignee || null)) await api(`/api/cards/${n}/assign`, {method: "POST", body: {assignee: who}});
      toast("Saved"); reopen();
    };
    $("#fDel").onclick = async () => { if (!confirm(`Delete card #${n}?`)) return;
      await api("/api/cards/" + n, {method: "DELETE"}); closeDrawer(); showBoard(state.board.id); };
    document.querySelectorAll("#ck input[type=checkbox]").forEach(cb => cb.onchange = async () => {
      await api(`/api/cards/${n}/checklist/${cb.dataset.k}`, {method: "POST", body: {done: cb.checked}}); reopen(); });
    $("#ckNew").onkeydown = async e => { if (e.key !== "Enter" || !e.target.value.trim()) return;
      await api(`/api/cards/${n}/checklist`, {method: "POST", body: {items: [e.target.value.trim()]}}); reopen(); };
    $("#ckAI").onclick = async () => { $("#ckAI").disabled = true; $("#ckSug").innerHTML = `<p class="muted">Thinking…</p>`;
      try { const s = await api(`/api/cards/${n}/checklist/suggest`, {method: "POST"});
        $("#ckSug").innerHTML = `<ul class="list-plain">${s.suggested_checklist.map(x => `<li>${esc(x)}</li>`).join("")}</ul><button class="primary" id="ckKeep">Add these</button>`;
        $("#ckKeep").onclick = async () => { await api(`/api/cards/${n}/checklist`, {method: "POST", body: {items: s.suggested_checklist}}); reopen(); };
      } catch (e) { $("#ckSug").innerHTML = ""; } $("#ckAI").disabled = false; };
    $("#blk").onsubmit = async e => { e.preventDefault(); await api(`/api/cards/${n}/blockers`, {method: "POST", body: {blocked_by: e.target.n.value}}); reopen(); };
    document.querySelectorAll("[data-unblock]").forEach(a => a.onclick = async e => { e.preventDefault();
      await api(`/api/cards/${n}/blockers/${a.dataset.unblock}`, {method: "DELETE"}); reopen(); });
  }
  $("#chain").onclick = async () => { const r = await api(`/api/cards/${n}/blocking`);
    $("#chainOut").innerHTML = r.ready ? `<p class="muted">Nothing is blocking it.</p>` : `<ul class="list-plain">${r.blocking.map(x => `<li>#${x.number} ${esc(x.title)} <span class="muted">(${esc(x.list)}${x.assignee ? ", " + esc(x.assignee) : ""})</span></li>`).join("")}</ul>`; };
  $("#sim").onclick = async () => { $("#simOut").innerHTML = `<p class="muted">Comparing meanings…</p>`;
    try { const r = await api(`/api/cards/${n}/similar`);
      $("#simOut").innerHTML = r.length ? `<ul class="list-plain">${r.map(x => `<li><a href="#" data-open="${x.number}">#${x.number} ${esc(x.title)}</a> <span class="muted">on ${esc(x.board)}, ${Math.round(x.similarity * 100)}% alike</span></li>`).join("")}</ul>` : `<p class="muted">No other cards yet.</p>`;
      document.querySelectorAll("[data-open]").forEach(a => a.onclick = e => { e.preventDefault(); openCard(a.dataset.open); });
    } catch (e) { $("#simOut").innerHTML = ""; } };
}
function closeDrawer() { $("#drawer").classList.remove("open"); }

function panel(title, html) { $("#pTitle").textContent = title; $("#pBody").innerHTML = html; $("#panel").classList.add("open"); }
const line = c => `<li><a href="#" data-open="${c.number}">#${c.number} ${esc(c.title)}</a> <span class="muted">${esc(c.list)} on ${esc(c.board)}${c.due_date ? ", due " + esc(fmtDate(c.due_date)) : ""}${c.days_late ? `, ${c.days_late} d late` : ""}</span></li>`;
function wireOpen() { document.querySelectorAll("#pBody [data-open]").forEach(a => a.onclick = e => { e.preventDefault(); openCard(a.dataset.open); }); }

function newBoard() { panel("New board", startsHtml()); $("#panel").style.width = "min(900px, calc(100vw - 36px))"; wireStarts(); }

$("#newBoardBtn").onclick = newBoard;
$("#boardSel").onchange = e => e.target.value && showBoard(e.target.value);
$("#dClose").onclick = closeDrawer;
$("#pClose").onclick = () => $("#panel").classList.remove("open");
const _panel = panel; panel = (t, h) => { $("#panel").style.width = ""; _panel(t, h); };
document.addEventListener("keydown", e => { if (e.key === "Escape") { closeDrawer(); $("#panel").classList.remove("open"); } });
$("#plateBtn").onclick = async () => { const p = await api("/api/me/plate");
  const sec = (h, xs) => xs.length ? `<p><b>${h}</b></p><ul class="list-plain">${xs.map(line).join("")}</ul>` : "";
  panel("My plate this week", (sec("Overdue", p.overdue) + sec("Due in the next 7 days", p.due_soon) + sec("No due date", p.no_due_date)) || `<p class="muted">Nothing assigned to you. Nice.</p>`); wireOpen(); };
$("#overdueBtn").onclick = async () => { const xs = await api("/api/overdue");
  panel("Overdue", xs.length ? `<ul class="list-plain">${xs.map(line).join("")}</ul>` : `<p class="muted">Nothing is overdue.</p>`); wireOpen(); };
$("#search").onkeydown = async e => { if (e.key !== "Enter" || !e.target.value.trim()) return;
  const xs = await api("/api/search?q=" + encodeURIComponent(e.target.value));
  panel(`Search: ${e.target.value}`, xs.length ? `<ul class="list-plain">${xs.map(line).join("")}</ul>` : `<p class="muted">No cards match.</p>`); wireOpen(); };
$("#activityBtn").onclick = async () => { const xs = await api(`/api/boards/${state.board.id}/activity`);
  panel("Activity", `<div class="hist">${xs.map(h => `<div>${esc(h.text)}<br><span class="muted">${esc((h.at || "").replace("T", " "))}</span></div>`).join("") || "No activity yet."}</div>`); };
$("#summaryBtn").onclick = async () => { $("#summaryBtn").disabled = true; panel("Daily summary", `<p class="muted">Writing the stand-up…</p>`);
  try { const s = await api(`/api/boards/${state.board.id}/summary`); panel(`Daily summary for ${s.board}`, `<div class="pre">${esc(s.summary)}</div>`); }
  finally { $("#summaryBtn").disabled = false; } };
$("#membersBtn").onclick = () => {
  const b = state.board;
  panel("Members", `<ul class="list-plain">${b.members.map(m => `<li>${esc(m.email)} <span class="muted">${esc(m.role)}</span>${isOwner() && m.role !== "owner" ? ` <a href="#" data-rm="${esc(m.email)}">remove</a>` : ""}</li>`).join("")}</ul>
    ${isOwner() ? `<form id="inv" style="margin-top:.8rem"><div class="row"><input name="e" type="email" placeholder="email@team.com" required><select name="r"><option>member</option><option>viewer</option></select></div><button class="primary" style="margin-top:.5rem">Invite</button><p class="muted">They sign in with this email to see the board.</p></form>` : `<p class="muted">Only the owner manages members.</p>`}`);
  const f = $("#inv");
  if (f) f.onsubmit = async e => { e.preventDefault(); await api(`/api/boards/${b.id}/members`, {method: "POST", body: {email: f.e.value, role: f.r.value}}); await showBoard(b.id); $("#membersBtn").click(); };
  document.querySelectorAll("[data-rm]").forEach(a => a.onclick = async e => { e.preventDefault(); if (!confirm(`Remove ${a.dataset.rm}?`)) return;
    await api(`/api/boards/${b.id}/members/${encodeURIComponent(a.dataset.rm)}`, {method: "DELETE"}); await showBoard(b.id); $("#membersBtn").click(); });
};

{
  const root = document.documentElement, btn = document.getElementById("themeToggle");
  const setTheme = night => {
    root.dataset.theme = night ? "night" : "day";
    btn.textContent = night ? "Light" : "Dark";
    btn.setAttribute("aria-pressed", String(night));
    try { localStorage.setItem("stackline-theme", root.dataset.theme); } catch (e) {}
  };
  let saved = null; try { saved = localStorage.getItem("stackline-theme"); } catch (e) {}
  setTheme(saved ? saved === "night" : matchMedia("(prefers-color-scheme: dark)").matches);
  btn.onclick = () => setTheme(root.dataset.theme !== "night");
}

(async () => { const me = await api("/api/me"); state.me = me.email; $("#who").textContent = me.email;
  if (me.demo) { const d = $("#demo"); d.hidden = false;
    d.innerHTML = `<span><b>Public demo.</b> You are ${esc(me.email)}. Owner, member and viewer share the Launch board; only owner and member see Ops.</span><button id="resetDemo">Reset the demo</button>`;
    $("#resetDemo").onclick = async () => { if (!confirm("Put the demo boards back as they were?")) return;
      await api("/api/demo/reset", {method: "POST"}); toast("Demo reset"); location.hash = ""; loadBoards(); }; }
  loadBoards(); })();
</script>
</body>
</html>