blob: ecada77236fd0e62e3d480be1075066ebe5f1624 [file]
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Board ยท amem</title>
<!-- the reader's theme and contrast, set before the first paint; storage is a convenience -->
<script>try { const s = localStorage, r = document.documentElement; if (s.getItem('amem-theme')) r.dataset.theme = s.getItem('amem-theme'); if (s.getItem('amem-contrast')) r.dataset.contrast = s.getItem('amem-contrast'); } catch (_) { /* private window */ }</script>
<link rel="stylesheet" href="tokens.css">
<style>
.file fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.file-grid { display: grid; gap: var(--am-space-4); grid-template-columns: minmax(0, 1fr); }
.file-foot { display: flex; align-items: center; gap: var(--am-space-3); flex-wrap: wrap; }
.groups { display: grid; gap: var(--am-space-8); }
.group { display: grid; gap: var(--am-space-2); }
.group > .am-hint { padding: var(--am-space-3) var(--am-space-4); border: var(--am-line) dashed var(--am-rule); border-radius: var(--am-radius); }
#handoff-text { min-height: 8rem; }
@media (min-width: 720px) {
.file-grid { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); }
.file-grid .wide { grid-column: 1 / -1; }
}
</style>
</head>
<body class="am-body">
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><defs>
<symbol id="i-mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.5 3.5H3.5v17h4M16.5 3.5h4v17h-4"/><path d="M8 8.5h8M8 12h8M8 15.5h5"/></symbol>
<symbol id="i-running" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.2"/><circle cx="8" cy="8" r="3" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-connecting" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.2" stroke-dasharray="2.7 2.2"/><circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-done" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.2"/><path d="M5.3 8.2l1.9 1.9 3.6-3.9"/></symbol>
<symbol id="i-ok" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 8.6l3.3 3.2L13.2 4.6"/></symbol>
<symbol id="i-fail" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4"/></symbol>
<symbol id="i-warn" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.2l6.3 11.2H1.7Z"/><path d="M8 6.6v3M8 11.6v.05"/></symbol>
<symbol id="i-absent" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2" stroke-dasharray="0.1 2.6"/></symbol>
<symbol id="i-refused" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2"/><path d="M3.7 12.3l8.6-8.6"/></symbol>
<symbol id="i-ceiling" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 2.8h11"/><path d="M8 13.6V5.8M5.2 8.5L8 5.7l2.8 2.8"/></symbol>
<symbol id="i-admit" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 13.8V2.4h7.2v11.4"/><path d="M1.6 8.1h6.6M6 5.9l2.2 2.2L6 10.3"/></symbol>
<symbol id="i-handoff" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 9.6h3.2l1 1.7h3.6l1-1.7H14v3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z"/><path d="M8 2v5.6M5.7 5.4L8 7.7l2.3-2.3"/></symbol>
<symbol id="i-lease" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.2"/><path d="M8 4.6V8l2.4 1.5"/></symbol>
<symbol id="i-queued" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 4.6h10M3 8h10M3 11.4h6"/></symbol>
<symbol id="i-invalid" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2"/><path d="M5 8h6"/></symbol>
<symbol id="i-superseded" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.4 8h8.4M8 5l3 3-3 3"/><path d="M13.6 3.2v9.6"/></symbol>
<symbol id="i-demoted" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.2v8.2M4.9 7.4L8 10.5l3.1-3.1"/><path d="M3 13.6h10"/></symbol>
<symbol id="i-erased" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.4" y="2.4" width="11.2" height="11.2" rx="1.6" stroke-dasharray="2.2 2.2"/></symbol>
<symbol id="i-copy" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><rect x="5.6" y="5.6" width="8" height="8" rx="1.4"/><path d="M10.4 5.6V3.8a1.3 1.3 0 0 0-1.3-1.3H3.8a1.3 1.3 0 0 0-1.3 1.3v5.3a1.3 1.3 0 0 0 1.3 1.3h1.8"/></symbol>
<symbol id="i-terminal" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.8" y="2.6" width="12.4" height="10.8" rx="1.6"/><path d="M4.6 6.2l2 1.8-2 1.8M8.4 10h3"/></symbol>
<symbol id="i-search" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="7" cy="7" r="4.4"/><path d="M10.3 10.3l3.5 3.5"/></symbol>
<symbol id="i-entry" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2.8" y="1.8" width="10.4" height="12.4" rx="1.4"/><path d="M5.4 5.4h5.2M5.4 8h5.2M5.4 10.6h3"/></symbol>
<symbol id="i-start" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M4.6 2.9v10.2L12.8 8Z"/></symbol>
<symbol id="i-label-0" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.2"><rect x="1.6" y="9.6" width="3" height="4.6" rx=".5" fill="currentColor"/><rect x="6.5" y="6.4" width="3" height="7.8" rx=".5" fill="none"/><rect x="11.4" y="2.6" width="3" height="11.6" rx=".5" fill="none"/></symbol>
<symbol id="i-label-1" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.2"><rect x="1.6" y="9.6" width="3" height="4.6" rx=".5" fill="currentColor"/><rect x="6.5" y="6.4" width="3" height="7.8" rx=".5" fill="currentColor"/><rect x="11.4" y="2.6" width="3" height="11.6" rx=".5" fill="none"/></symbol>
<symbol id="i-label-2" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.2"><rect x="1.6" y="9.6" width="3" height="4.6" rx=".5" fill="currentColor"/><rect x="6.5" y="6.4" width="3" height="7.8" rx=".5" fill="currentColor"/><rect x="11.4" y="2.6" width="3" height="11.6" rx=".5" fill="currentColor"/></symbol>
<symbol id="i-trust-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.6"/></symbol>
<symbol id="i-trust-1" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.6"/><path d="M8 2.4a5.6 5.6 0 0 0 0 11.2Z" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-trust-2" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.6" fill="currentColor"/></symbol>
<symbol id="i-pair" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="4.8" cy="8" r="2.8"/><path d="M7.6 8h6.6M11.2 8v2.4M13.6 8v1.8"/></symbol>
<symbol id="i-busy" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M8 1.8a6.2 6.2 0 1 0 6.2 6.2"/></symbol>
</defs></svg>
<a class="am-skip" href="#main">Skip to the page</a>
<header class="am-top">
<a class="am-mark" href="index.html"><svg class="am-glyph" viewBox="0 0 24 24" aria-hidden="true"><use href="#i-mark"/></svg>amem</a>
<nav class="am-nav" aria-label="Pages">
<a href="index.html">Sessions</a>
<a href="board.html" aria-current="page">Board</a>
<a href="reader.html">Reader</a>
</nav>
<div class="am-top-end" id="pairing"></div>
</header>
<main class="am-main" id="main">
<div class="am-head">
<h1 class="am-title">Board</h1>
<p class="am-lead">The tasks on the ledger by status, each under the task it was filed from. A task addressed to a person is marked: claim it, do it, and complete it with a handoff. <span id="domain"></span></p>
</div>
<div id="unpaired" class="am-alert am-alert--await" role="status" hidden>
<svg class="am-glyph" aria-hidden="true"><use href="#i-pair"/></svg>
<p><b>Not paired.</b> <span id="unpaired-why"></span></p>
<div class="am-alert-act"><div class="am-cmd"><code><span class="p">$ </span>amem</code></div></div>
</div>
<div id="error" class="am-alert" role="alert" hidden>
<svg class="am-glyph" aria-hidden="true"><use href="#i-fail"/></svg>
<p><b id="error-what"></b> <span id="error-why"></span></p>
<div class="am-alert-act"><button class="am-btn am-btn--secondary" type="button" id="error-close">Dismiss</button></div>
</div>
<section class="am-panel file" aria-labelledby="file-h">
<h2 class="am-section-title" id="file-h">File a task</h2>
<form id="file">
<fieldset id="file-fields" class="file-grid">
<div class="am-field"><label for="f-title">Title</label>
<input class="am-ctrl" id="f-title" name="title" required autocomplete="off" placeholder="One line: what is to be done">
</div>
<div class="am-field"><label for="f-req">Requirements</label>
<input class="am-ctrl is-mono" id="f-req" name="requirements" autocomplete="off" spellcheck="false" placeholder="human, tool:github">
<span class="am-hint">Comma separated. <span class="am-mono">human</span> addresses it to a person.</span>
</div>
<div class="am-field wide"><label for="f-body">Body</label>
<textarea class="am-ctrl" id="f-body" name="body" placeholder="What is to be done, in Markdown"></textarea>
</div>
<div class="file-foot wide">
<button class="am-btn am-btn--primary" type="submit" id="file-go">File task</button>
<span class="am-hint" id="file-hint" aria-live="polite">It lands as proposed, waiting for admission.</span>
</div>
</fieldset>
</form>
</section>
<div class="groups" id="lists" aria-busy="true"></div>
</main>
<dialog class="am-dialog" id="handoff" aria-labelledby="handoff-h">
<form method="dialog" id="handoff-form">
<h2 class="am-section-title" id="handoff-h">Complete with a handoff</h2>
<p>Complete <b id="handoff-title"></b> with a handoff: what was done, what remains, what the next one should know.</p>
<div class="am-field"><label for="handoff-text">Handoff</label>
<textarea class="am-ctrl" id="handoff-text" required></textarea></div>
<div class="am-dialog-foot">
<button class="am-btn am-btn--secondary" value="cancel" formnovalidate>Cancel</button>
<button class="am-btn am-btn--primary" value="ok" id="handoff-ok">Complete</button>
</div>
</form>
</dialog>
<script src="amem.js"></script>
<script>
// ---- small DOM helpers: task text is anyone's, so it is only ever set as text ----
const $ = (id) => document.getElementById(id);
const NS = 'http://www.w3.org/2000/svg';
function el(tag, cls, ...kids) {
const e = document.createElement(tag);
if (cls) e.className = cls;
for (const k of kids) if (k !== null && k !== undefined && k !== false) e.append(k);
return e;
}
function glyph(name, label) {
const s = document.createElementNS(NS, 'svg');
s.setAttribute('class', 'am-glyph');
if (label) { s.setAttribute('role', 'img'); s.setAttribute('aria-label', label); } else s.setAttribute('aria-hidden', 'true');
const u = document.createElementNS(NS, 'use');
u.setAttribute('href', `#i-${name}`);
s.append(u);
return s;
}
const state = (tone, name, label) => el('span', `am-state am-state--${tone}`, glyph(name, label));
const id = (t) => el('span', 'am-id', t);
// ---- the ledger's states, in its own words, each with its gutter glyph ----
const ORDER = ['claimed', 'admitted', 'proposed', 'completed', 'invalidated'];
const WORDS = { claimed: 'Claimed, under a lease', admitted: 'Admitted, claimable', proposed: 'Proposed, waiting for admission', completed: 'Completed, with a handoff', invalidated: 'Invalidated' };
const GUTTER = { claimed: ['live', 'lease'], admitted: ['ink', 'queued'], proposed: ['await', 'admit'], completed: ['ok', 'done'], invalidated: ['ink', 'invalid'] };
const EMPTY = {
claimed: ['Nothing claimed. A session claims an admitted task with ', el('code', null, 'amem work'), '; Claim here takes one for a person.'],
admitted: 'Nothing claimable. Admit a proposal, or file a task above.',
proposed: 'No proposals waiting.',
completed: 'Nothing completed yet.',
invalidated: 'Nothing invalidated.',
};
function parentOf(t) {
const m = /^(task|continues|retries):(.+)$/.exec(t.origin || '');
return m ? m[2] : null;
}
function relation(t) {
const o = t.origin || '';
if (o.startsWith('task:')) return 'subagent of';
if (o.startsWith('continues:')) return 'continues';
if (o.startsWith('retries:')) return 'retries';
return o ? 'from' : '';
}
// every call goes to the server through the page's key
async function api(method, path, body) {
const r = await amem.fetch(path, { method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
const text = await r.text();
if (r.status === 401) { paired = false; showPairing(); }
if (!r.ok) throw new Error(text || r.statusText);
return text ? JSON.parse(text) : null;
}
function fail(what, why) {
$('error-what').textContent = what;
$('error-why').textContent = why;
$('error').hidden = false;
}
$('error-close').addEventListener('click', () => { $('error').hidden = true; });
// what a page says when it has no key; a hosted copy also says where amem has to run
const unpairedWhy = () => { const w = amem.unpaired(); return amem.hosted && !/own machine/.test(w) ? `${w} This copy of the pages runs in your browser and talks to amem on your own machine: run amem there, and it opens this page paired.` : w; };
let paired = false;
let boardDomain = '';
function showPairing() {
const end = $('pairing');
if (paired) {
let host = amem.server() || location.origin;
try { host = new URL(host).host; } catch (_) { /* as it is */ }
end.replaceChildren('Paired with ', id(host));
$('unpaired').hidden = true;
return;
}
end.replaceChildren(state('await', 'pair'), 'Not paired');
$('unpaired-why').textContent = unpairedWhy();
$('unpaired').hidden = false;
$('file-fields').disabled = true;
$('file-hint').textContent = 'Pair this browser first, as the note above says.';
$('lists').removeAttribute('aria-busy');
$('lists').replaceChildren(el('div', 'am-empty', el('b', null, 'Tasks show here once this browser is paired'), el('p', null, 'Nothing on this page reaches amem until then.')));
}
let all = [];
async function refresh() {
if (!paired) return;
let data;
try { data = await api('GET', '/api/tasks'); } catch (e) { if (paired) fail('The board did not load.', e.message); return; }
$('domain').textContent = data.domain ? `Domain ${data.domain}.` : '';
boardDomain = data.domain || '';
all = data.tasks;
const byId = new Map(all.map((t) => [t.id, t]));
const depth = (t) => { let d = 0, p = parentOf(t); const seen = new Set(); while (p && byId.has(p) && !seen.has(p)) { seen.add(p); d++; p = parentOf(byId.get(p)); } return d; };
// a refresh keeps a body that was open open
const open = new Set([...document.querySelectorAll('.am-task-body[open]')].map((d) => d.dataset.id));
const lists = $('lists');
lists.removeAttribute('aria-busy');
lists.replaceChildren();
for (const status of ORDER) {
const tasks = all.filter((t) => t.status === status);
const h = el('h2', 'am-sub-title', WORDS[status], el('small', null, String(tasks.length)));
h.id = `g-${status}`;
const group = el('section', 'group', h);
group.setAttribute('aria-labelledby', h.id);
lists.append(group);
if (!tasks.length) { group.append(el('p', 'am-hint', ...[].concat(EMPTY[status]))); continue; }
const list = el('div', 'am-list');
group.append(list);
// parents before children, each child right after its parent where both share a status
const roots = tasks.filter((t) => !parentOf(t) || !tasks.some((x) => x.id === parentOf(t)));
const place = (t) => { list.append(render(t, depth(t), open)); tasks.filter((c) => parentOf(c) === t.id).forEach(place); };
roots.forEach(place);
}
}
function render(t, d, open) {
const human = (t.requirements || []).includes('human');
const row = el('div', `am-task${d ? ' is-child' : ''}`);
row.style.setProperty('--depth', String(Math.min(d, 5)));
const [tone, g] = GUTTER[t.status] || ['ink', 'queued'];
const rel = relation(t);
const parent = parentOf(t);
const meta = el('span', 'am-task-meta');
if (human) meta.append(el('b', null, 'For a person'));
if (rel) meta.append(el('span', null, `${rel} `, id(parent || t.origin)));
if ((t.depends_on || []).length) meta.append(el('span', null, 'after ', ...t.depends_on.flatMap((x, i) => (i ? [', ', id(x)] : [id(x)]))));
if ((t.requirements || []).length) meta.append(el('span', null, `needs ${t.requirements.join(', ')}`));
if (t.holder) meta.append(el('span', null, `held by ${t.holder}`));
if (t.handoff) {
const a = el('a', null, id(t.handoff));
// the reader, on this domain's bundle at the owner's handling, opened at the handoff
const q = new URLSearchParams({ handling: 'restricted', bundle: `/bundle/${boardDomain}/restricted`, open: t.handoff });
a.href = `reader.html?${q}`;
meta.append(el('span', null, 'handoff ', a));
}
if (t.reason) meta.append(el('span', null, t.reason));
const when = el('span', 'am-time', `${(t.created || '').slice(0, 16).replace('T', ' ')}Z`);
meta.append(el('span', null, `filed by ${t.created_by}, `, when));
const acts = [];
if (t.status === 'proposed') acts.push(['Admit', 'secondary', () => act('admit', t)]);
if (t.status === 'admitted') acts.push(['Claim', 'secondary', () => act('claim', t)]);
if (t.status === 'claimed') acts.push(['Complete', 'secondary', () => completeWith(t)]);
if (t.status !== 'completed' && t.status !== 'invalidated') acts.push(['Invalidate', 'danger', () => act('invalidate', t, { reason: 'closed from the board' })]);
if (t.status === 'invalidated' || t.status === 'completed') acts.push(['Retry', 'secondary', () => act('retry', t)]);
const buttons = el('span', 'am-task-acts', ...acts.map(([word, role, fn]) => {
const b = el('button', `am-btn am-btn--${role}`, word);
b.type = 'button';
b.setAttribute('aria-label', `${word}: ${t.title}`);
b.addEventListener('click', fn);
return b;
}));
const body = el('details', 'am-task-body', el('summary', null, 'Body'), el('pre', null, t.body || ''));
body.dataset.id = t.id;
if (open.has(t.id)) body.open = true;
row.append(state(tone, g, t.status), el('span', 'am-task-title', el('span', null, t.title), id(t.id)), buttons, meta, body);
return row;
}
async function act(what, t, body) {
try { await api('POST', `/api/tasks/${encodeURIComponent(t.id)}/${what}`, body); $('error').hidden = true; await refresh(); }
catch (e) { fail(`Could not ${what} ${t.id}.`, e.message); }
}
function completeWith(t) {
$('handoff-title').textContent = `${t.title} (${t.id})`;
$('handoff-text').value = '';
const dlg = $('handoff');
dlg.returnValue = '';
dlg.showModal();
dlg.onclose = async () => {
if (dlg.returnValue !== 'ok') return;
await act('complete', t, { handoff: $('handoff-text').value });
};
}
$('file').addEventListener('submit', async (e) => {
e.preventDefault();
const f = new FormData($('file'));
const requirements = String(f.get('requirements') || '').split(',').map((s) => s.trim()).filter(Boolean);
const hint = $('file-hint');
try {
const r = await api('POST', '/api/tasks', { title: f.get('title'), body: f.get('body') || '', requirements });
$('file').reset();
hint.className = 'am-hint';
hint.replaceChildren(r && r.deduplicated ? 'Already on the ledger: ' : 'Filed: ', id(r && r.task ? r.task.id : ''), r && r.deduplicated ? '.' : ', proposed and waiting for admission.');
await refresh();
} catch (err) {
hint.className = 'am-err';
hint.replaceChildren(glyph('fail'), `Not filed: ${err.message}`);
}
});
(async () => {
await amem.ready;
paired = amem.paired();
showPairing();
if (!paired) return;
await refresh();
setInterval(refresh, 5000);
})();
</script>
</body>
</html>