Demo / the vault
Tools/review-surface/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Review surface</title>
<style>
:root {
--ink: #1b1a17;
--mute: #6b6862;
--line: #e0ddd6;
--paper: #faf9f6;
--card: #ffffff;
--accent: #2f5d50;
--warn: #8a5a1e;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--paper); color: var(--ink);
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
main { max-width: 760px; margin: 0 auto; padding: 40px 20px 120px; }
header { border-bottom: 1px solid var(--line); padding-bottom: 18px; margin-bottom: 28px; }
h1 { font-size: 21px; margin: 0 0 6px; letter-spacing: -0.01em; }
.sub { color: var(--mute); font-size: 14px; }
.sub code { font-size: 13px; background: #f0eee8; padding: 1px 5px; border-radius: 3px; }
.queuepick { margin-top: 12px; font-size: 14px; }
.queuepick select { font: inherit; font-size: 14px; padding: 4px 6px; border: 1px solid var(--line); border-radius: 5px; background: #fff; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 18px 20px; margin-bottom: 14px; }
.card.done { background: #f6f5f1; border-color: #ded9cd; }
.eyebrow { font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--mute); margin-bottom: 8px; }
.ask { font-size: 17px; font-weight: 600; margin: 0 0 10px; }
.context { color: var(--mute); font-size: 14.5px; margin: 0 0 12px; white-space: pre-wrap; }
.handles { margin-bottom: 14px; }
.handle { display: inline-block; font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace;
background: #f0eee8; color: #4a4741; padding: 2px 7px; border-radius: 4px; margin-right: 6px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
button { font: inherit; font-size: 14.5px; padding: 7px 14px; border-radius: 6px; border: 1px solid var(--line);
background: #fff; color: var(--ink); cursor: pointer; }
button:hover { border-color: #bdb8ab; }
button.yes { background: var(--accent); border-color: var(--accent); color: #fff; }
button.yes:hover { background: #26493f; }
button.link { border: none; background: none; color: var(--mute); padding: 4px 0; text-decoration: underline; font-size: 13.5px; }
.defer { margin-top: 12px; display: none; }
.defer.open { display: block; }
input[type=text], textarea { width: 100%; font: inherit; font-size: 14.5px; padding: 8px 10px;
border: 1px solid var(--line); border-radius: 6px; background: #fff; color: var(--ink); }
textarea { min-height: 64px; resize: vertical; }
label.f { display: block; font-size: 13px; color: var(--mute); margin: 10px 0 4px; }
.answered { font-size: 14px; }
.answered b { color: var(--accent); }
.answered .warn { color: var(--warn); }
.meta { color: var(--mute); font-size: 13px; margin-top: 4px; }
.meta code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.empty { color: var(--mute); padding: 40px 0; }
.err { color: #8a1e1e; font-size: 14px; margin-top: 8px; }
</style>
</head>
<body>
<main>
<header>
<h1 id="title">Review surface</h1>
<div class="sub" id="sub"></div>
<div class="queuepick" id="pick"></div>
</header>
<div id="list"></div>
</main>
<script>
let STATE = [], CURRENT = 0;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
async function load() {
STATE = await (await fetch('/api/state')).json();
render();
}
function render() {
const q = STATE[CURRENT];
if (!q) {
document.getElementById('sub').textContent = '';
document.getElementById('list').innerHTML = '<div class="empty">No disposition queue in Efforts/dispositions/. Hobbs writes one with each brief and each weekly review.</div>';
return;
}
const done = q.asks.filter((a) => a.answered).length;
document.getElementById('title').textContent = q.title || q.kind;
document.getElementById('sub').innerHTML =
esc(q.date) + ' · ' + done + ' of ' + q.asks.length + ' answered · <code>' + esc(q.source) + '</code>';
document.getElementById('pick').innerHTML = STATE.length > 1
? 'Queue: <select onchange="CURRENT=+this.value;render()">' +
STATE.map((s, i) => '<option value="' + i + '"' + (i === CURRENT ? ' selected' : '') + '>' + esc(s.file) + '</option>').join('') +
'</select>'
: '';
document.getElementById('list').innerHTML = q.asks.map(card).join('');
}
function card(a) {
const h = (a.tasks || []).map((t) => '<span class="handle">' + esc(t) + '</span>').join('');
if (a.answered) {
const r = a.answered;
const eff = (r.effects && r.effects.done || []);
const ref = (r.effects && r.effects.refused || []);
return '<div class="card done">' +
'<div class="eyebrow">' + esc(a.section) + '</div>' +
'<p class="ask">' + esc(a.ask) + '</p>' +
'<div class="answered"><b>' + esc(r.answer) + '</b>' +
(r.trigger ? ' — trigger: “' + esc(r.trigger) + '”' : '') +
(r.note ? ' — note: “' + esc(r.note) + '”' : '') + '</div>' +
'<div class="meta">' +
(eff.length ? esc(eff.join('; ')) + ' · ' : '') +
(r.intention ? 'intention: <code>' + esc(r.intention) + '</code> · ' : '') +
'commit <code>' + esc(r.commit || '—') + '</code>' +
(ref.length ? '<div class="err">refused: ' + esc(ref.join('; ')) + '</div>' : '') +
'</div>' +
'<button class="link" onclick="reopen(\'' + a.id + '\')">change</button>' +
'</div>';
}
return '<div class="card" id="c-' + a.id + '">' +
'<div class="eyebrow">' + esc(a.section) + '</div>' +
'<p class="ask">' + esc(a.ask) + '</p>' +
(a.context ? '<p class="context">' + esc(a.context) + '</p>' : '') +
(h ? '<div class="handles">' + h + '</div>' : '') +
'<div class="row">' +
'<button class="yes" onclick="send(\'' + a.id + '\',\'yes\')">' + esc(a.yes_label || 'Yes') + '</button>' +
'<button onclick="send(\'' + a.id + '\',\'no\')">' + esc(a.no_label || 'No') + '</button>' +
'<button onclick="openDefer(\'' + a.id + '\')">Defer…</button>' +
'<button class="link" onclick="openNote(\'' + a.id + '\')">add a note</button>' +
'</div>' +
'<div class="defer" id="d-' + a.id + '">' +
'<label class="f">What brings it back? A date, or the thing that has to happen first.</label>' +
'<input type="text" id="t-' + a.id + '" placeholder="e.g. once Pike is granted or refused">' +
'<div class="row" style="margin-top:10px"><button class="yes" onclick="send(\'' + a.id + '\',\'defer\')">Defer</button></div>' +
'</div>' +
'<div class="defer" id="n-' + a.id + '">' +
'<label class="f">Note — your words, handed to Hobbs as an intention.</label>' +
'<textarea id="x-' + a.id + '"></textarea>' +
'</div>' +
'<div class="err" id="e-' + a.id + '"></div>' +
'</div>';
}
const openDefer = (id) => document.getElementById('d-' + id).classList.toggle('open');
const openNote = (id) => document.getElementById('n-' + id).classList.toggle('open');
function reopen(id) {
const q = STATE[CURRENT];
q.asks = q.asks.map((a) => a.id === id ? { ...a, answered: null } : a);
render();
}
async function send(id, choice) {
const q = STATE[CURRENT];
const trigger = (document.getElementById('t-' + id) || {}).value || '';
const note = (document.getElementById('x-' + id) || {}).value || '';
const err = document.getElementById('e-' + id);
err.textContent = '';
document.querySelectorAll('#c-' + id + ' button').forEach((b) => b.disabled = true);
const res = await fetch('/api/answer', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ file: q.file, ask: id, answer: choice, trigger, note }),
});
const body = await res.json();
if (!res.ok) {
err.textContent = body.error;
document.querySelectorAll('#c-' + id + ' button').forEach((b) => b.disabled = false);
return;
}
await load();
}
load();
</script>
</body>
</html>