Files
stack/agents/dewey/work/wui/mockups/slice1/app.js
T
jason.woltjeandClaude Opus 5.5 1f4882d2b4 docs(wui): Dewey's design work: brand board, five mockups, slice 1 S5 views and checks
Brand board revisions, the five dashboard mockups with their verify
script and evidence, the slice 1 S5 design note (SLICE1-VIEWS.md, rev 3
plus the S2c section 10 update), the Console mockup of inbox, tasks,
agents and trail (mockups/slice1/) and checks/slice1-verify.mjs (16
checks; check 16 pinned to bus at d27042fa). Also the CHAT-03 progress
log and the 2026-09-26 dirty-tree manifest. Design and evidence only;
no package code. Row 40 (#1522), committed on its own at Sage's
direction, 2026-10-09.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 17:54:50 -05:00

424 lines
40 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Slice 1 S5 mockup: inbox, tasks, agents and trail in the live Console shell.
// Two layers. `reads` turns the arrays of the Q1 read module (packages/bus views, lead
// decision 56, Q1; q1.js stands in for it) into view objects, and the renderers below see
// only those objects. What no Q1 verb returns comes from S1Q1.missing and is labelled where
// it shows. Nothing on this page writes anywhere.
(() => {
'use strict';
const $ = id => document.getElementById(id);
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// The CHAT-02 rules from the live app: C0 controls show as control pictures, C1 and bidi
// controls as [U+XXXX]. Newlines and tabs stay. Markdown stays as its source.
const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g, UNSEEN = /[\u0080-\u009f؜‎‏‪-‮⁦-⁩]/g;
const inert = v => String(v ?? '').replace(CONTROL, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(UNSEEN, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}]`);
const txt = v => esc(inert(v)); // every agent- or tracker-authored string goes through this
const announce = text => { $('announce').textContent = text; };
const FULL = window.S1;
const EMPTY = { ...FULL, role_claims: [], decisions: [], decision_events: [], messages: [], deliveries: [], task_snapshots: [], events: [] };
// ---------------------------------------------------------------- read layer
// Q is the Q1 module's four reads (q1.js stands in for packages/bus views). M is what no Q1 verb
// returns yet; every view that shows something from M says so. Nothing here reads a raw table.
function reads(S) {
const Q = S1Q1.views(S), M = S1Q1.missing(S), L = M.business, NOW = Date.parse(S.NOW);
const CLOSE = ['resolved', 'withdrawn', 'expired'];
const of = (rows, table) => rows.filter(r => r.table === table);
const byOldest = (a, b) => a.at.localeCompare(b.at);
// One decision from its trail. An inbox row already carries the authorization context; for any
// other decision it comes from the trail's decision.raise event, the rule the broker's
// #decisionView uses. Seen and DM state come from the trail too (one trail read per decision).
function decision(id, row) {
const tr = Q.trail(id), d = of(tr, 'decisions').find(x => x.id === id);
if (!d) return null;
const ev = of(tr, 'decision_events').filter(x => x.decision === id), closed = ev.filter(x => CLOSE.includes(x.op)).at(-1) || null;
const raise = of(tr, 'events').find(e => e.kind === 'action.allowed' && e.body.operation === 'decision.raise' && e.body.decision === id);
const mids = new Set(of(tr, 'messages').filter(m => m.decision === id).map(m => m.id));
const dm = of(tr, 'deliveries').find(x => mids.has(x.message) && x.transport === 'discord-dm' && x.op === 'delivered');
return {
id, at: d.at, class: d.class, action: d.action, routeTo: d.route_to, question: d.question,
options: d.options, recommendation: d.recommendation, taskRef: d.task_ref, requirementRef: d.requirement_ref,
blocking: !!d.blocking, raisedBy: { role: d.raised_by_role, run: d.raised_by_run },
authorization: row ? row.authorization : raise ? { action: d.action, target: raise.body.target, approvalChoice: raise.body.approvalChoice } : null,
seen: ev.filter(x => x.op === 'seen').map(x => ({ at: x.at, by: x.by, via: x.via })),
closed: closed && { op: closed.op, at: closed.at, by: closed.by, choice: closed.choice, note: closed.note, via: closed.via },
dm: dm ? { at: dm.at } : null, supersedes: d.supersedes,
};
}
function inbox() {
return {
// The module orders gated first, then oldest; every human-routed decision is gated, so this only lifts blocking ones.
human: Q.inbox().map(r => decision(r.id, r)).sort((a, b) => (b.blocking - a.blocking) || byOldest(a, b)),
roles: M.decisions.roles.map(r => decision(r.id, r)).sort(byOldest),
recent: M.decisions.closed.map(r => decision(r.id, r)).sort((a, b) => b.closed.at.localeCompare(a.closed.at)),
missingSource: M.decisions.source,
};
}
function chain(id) { // earlier versions through `supersedes`, newest first, one trail read each
const out = []; let d = decision(id);
while (d?.supersedes) { d = decision(d.supersedes); if (d) out.push(d); }
return out;
}
// Tasks: one row per task from task_current (schema v3b), detail from the task's trail.
function task(row) {
const ref = row.task_ref, f = row.fields, tr = Q.trail(ref);
const evs = of(tr, 'events').filter(e => e.subject === ref), snaps = of(tr, 'task_snapshots');
const created = evs.find(e => e.kind === 'task.created');
const conflict = evs.filter(e => e.kind === 'task.conflict').map(e => ({ event: e.id, ...e.body }))
.find(c => !of(tr, 'decision_events').some(x => x.decision === c.decision && CLOSE.includes(x.op))) || null;
const missingEv = evs.filter(e => e.kind === 'task.missing').at(-1);
const shape = f.gone ? snaps.filter(s => !s.fields.gone).at(-1)?.fields || {} : f;
// task_current already skips stale reads, so a current row from a poll is a change the stack didn't make.
const ext = evs.filter(e => e.kind === 'task.changed.external').at(-1);
const external = !f.gone && row.source === 'poll' ? { kind: snaps.some(s => s.source === 'self') ? 'changed' : 'foreign', via: row.via, readAt: row.read_at, fields: ext ? ext.body.fields : [], event: ext?.id || null } : null;
const [, project, id] = ref.match(/^vikunja:(\d+)\/(\d+)$/);
const bucketOf = x => x.gone ? `gone (${x.gone})` : L.buckets[x.bucket] || `bucket ${x.bucket}`;
return {
ref, project: +project, id: +id, index: shape.index ?? +id, title: shape.title ?? null, description: shape.description ?? '',
bucket: f.gone ? null : bucketOf(f), done: !!f.done,
assignee: (shape.assignees || []).map(a => L.bots[a] || `user ${a}`), requirement: created?.body.requirement ?? null, request: created?.body.request ?? null,
due: shape.due_date, priority: shape.priority ?? 0, percent: shape.percent_done ?? 0,
changedAt: row.at, external, conflict,
missing: f.gone ? { reason: f.gone, at: row.at, project: missingEv?.body.project ?? null } : null,
review: evs.filter(e => e.kind.startsWith('review.')).map(e => ({ kind: e.kind, at: e.at, ...e.body })),
// Rows after the current one are reads task_current skipped (lead decision 59).
snapshots: snaps.map(s => ({ at: s.at, source: s.source, via: s.via, readAt: s.read_at, role: s.role, stale: s.seq > row.seq, bucket: bucketOf(s.fields) })),
url: `${L.tracker.baseUrl}/tasks/${id}`,
};
}
function tasks() {
const ts = Q.tasks().map(task);
return {
freshness: M.status,
groups: Object.values(L.buckets).map(b => ({ bucket: b, rows: ts.filter(t => t.bucket === b).sort((x, y) => y.changedAt.localeCompare(x.changedAt)) })),
missing: ts.filter(t => t.missing),
};
}
// Agents: holders from the module; instances, launch state, sessions and credential notices from M.
function agents() {
const claims = Q.agents(), inst = Object.fromEntries(L.instances.map(i => [i.instance, i]));
const family = role => L.families[inst[role]?.model] || 'unknown';
const live = M.sessions.live.map(l => ({ ...l, family: family(l.role) }));
const counts = {}; for (const l of live) counts[l.family] = (counts[l.family] || 0) + 1;
return {
limits: Object.entries(L.launch.max).map(([fam, max]) => ({ family: fam, live: counts[fam] || 0, max })),
launch: M.launch, launchRule: L.launch,
instances: L.instances.map(i => {
const c = claims.find(r => r.role === i.instance) || null;
const creds = {};
for (const n of M.credentials.notices) if (n.instance === i.instance) creds[n.service] = { kind: n.kind, at: n.at, expires: n.expires || null };
return {
...i,
holder: c && { run: c.holder_run, harness: c.harness, address: c.address, at: c.at, advisory: c.harness === 't3' },
session: live.find(l => l.role === i.instance) || null,
credentials: Object.entries(creds).map(([service, v]) => ({ service, ...v })),
};
}),
ended: M.sessions.ended.sort((a, b) => b.at.localeCompare(a.at)),
};
}
// Trail: the module's rows, oldest first (time, table, seq), each turned into one line with a filter group.
const group = k => k === 'human.input' ? 'request' : k.startsWith('session.') || k.startsWith('launch.') || k.startsWith('claim.') ? 'launch' : k.startsWith('task.') || k.startsWith('snapshot.') ? 'task' : k.startsWith('review.') ? 'review' : k.startsWith('action.') || k.startsWith('decision.') ? 'decision' : 'other';
const H = L.human, cap = s => s[0].toUpperCase() + s.slice(1);
function line(r, ctx) {
const out = { at: r.at, table: r.table, actor: null, by: 'broker', decision: null, note: null };
if (r.table === 'events') {
const b = r.body;
out.kind = r.kind; out.decision = b.decision || null;
if (r.actor_role) out.actor = { role: r.actor_role, run: r.actor_run }; else if (r.kind === 'human.input' || r.kind.startsWith('launch.')) out.by = 'human';
const map = {
'session.launched': () => `Session started: ${r.actor_role} as ${r.actor_run} (${b.harness}${b.address ? `, ${b.address}` : ''})`,
'session.ended': () => `Session ended: ${r.actor_role} ${r.actor_run} (${b.reason}, exit ${b.exit})`,
'human.input': () => b.kind === 'answer' ? (b.op === 'resolved' ? `${H} chose “${b.choice}” from the CLI` : `${H} ${b.op === 'seen' ? 'marked it seen' : b.op} from the CLI`)
: b.kind === 'instruction' ? `Request from ${H}, recorded from the CLI` : `Admin command from ${H}, CLI`,
'task.created': () => `Created #${b.index} for ${b.requirement}`,
'task.assigned': () => `Assigned to ${b.role}`,
'task.state': () => `Moved from ${b.from} to ${b.to}`,
'task.closed': () => `Closed with verdict ${b.verdict}`,
'task.changed.external': () => b.fields.includes('created') ? 'Created in Vikunja, not by the stack' : `Changed in Vikunja: ${b.fields.join(', ')}`,
'task.conflict': () => `Conflict on ${b.field}: queue says ${b.queue}, Vikunja says ${b.tracker}`,
'task.missing': () => `No longer readable in Vikunja (${b.reason}${b.project ? `, now in project ${b.project}` : ''})`,
'review.requested': () => `Review requested, round ${b.round}`,
'review.verdict': () => `Review verdict: ${b.verdict} (round ${b.round})`,
'action.refused': () => `Refused: ${b.code}`,
'action.allowed': () => b.operation === 'decision.raise' ? `Raised with this context: choosing “${b.approvalChoice}” approves ${b.action}${b.target ? ` on ${b.target}` : ''}` : `Allowed ${b.action}${b.target ? ` on ${b.target}` : ''}${b.class ? ` (${b.class})` : ''}`,
'launch.revoked': () => 'Launching revoked from the CLI',
'launch.restored': () => 'Launching restored from the CLI',
'credential.expiring': () => `${b.service} credential for ${b.instance} expires ${b.expires}`,
'credential.expired': () => `${b.service} credential for ${b.instance} expired`,
'credential.changed': () => `${b.service} credential for ${b.instance} changed`,
};
out.text = (map[r.kind] || (() => r.kind))();
if (r.kind === 'human.input' && ctx.asked.has(r.id)) out.note = 'the request task.created names (lead decision 56, Q3)';
} else if (r.table === 'messages') {
out.kind = `message.${r.class.toLowerCase()}`; out.decision = r.decision;
if (r.from_role === 'human') out.by = 'human'; else out.actor = { role: r.from_role, run: r.from_run };
out.text = `${r.from_role === 'human' ? H : r.from_role} to ${r.to_role}: ${r.body}`;
out.groupAs = r.from_role === 'human' ? 'request' : r.decision ? 'decision' : 'other';
} else if (r.table === 'deliveries') {
const m = ctx.messages[r.message];
out.kind = `delivery.${r.op}`; out.decision = m?.decision || null;
out.text = `Message ${r.op}${r.holder_run ? ` to ${r.holder_run}` : ''}${r.transport ? ` by ${r.transport}` : ''}${r.transport === 'discord-dm' ? ' (DM to the human)' : ''}`;
out.groupAs = m?.from_role === 'human' ? 'request' : m?.decision ? 'decision' : 'other';
} else if (r.table === 'decisions') {
out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run };
out.text = `Raised a ${r.class} decision for ${r.route_to}: ${r.question.split('\n')[0]}`;
} else if (r.table === 'decision_events') {
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.by === H ? 'human' : 'broker';
out.text = r.op === 'resolved' ? `${r.by} chose “${r.choice}” via ${r.via}` : r.op === 'seen' ? `${r.by} saw it via ${r.via}` : `${cap(r.op)} by ${r.by}${r.note ? `: ${r.note}` : ''}`;
} else if (r.table === 'task_snapshots') {
out.kind = `snapshot.${r.source}`;
const bucket = r.fields.gone ? `gone (${r.fields.gone})` : L.buckets[r.fields.bucket] || `bucket ${r.fields.bucket}`;
if (r.source === 'self') out.actor = { role: r.role, run: r.run };
out.text = r.source === 'self' ? `Written: ${bucket}` : `Read from Vikunja (${r.via} read sent ${r.read_at.slice(11, 19)}): ${bucket}`;
if (ctx.current != null && r.seq > ctx.current) out.note = 'stale read, not current (task_current skips it)';
} else if (r.table === 'role_claims') {
out.kind = `claim.${r.op}`; out.actor = { role: r.role, run: r.holder_run };
out.text = r.op === 'claim' ? `${r.role} claimed by ${r.holder_run} (${r.harness}${r.address ? `, ${r.address}` : ''})` : `${r.role} ${r.op === 'release' ? 'released' : 'revoked'} by ${r.by}${r.reason ? `: ${r.reason}` : ''}`;
}
out.group = out.groupAs || group(out.kind);
return out;
}
function lines(tr, current = null) {
const ctx = { current, asked: new Set(of(tr, 'events').filter(e => e.kind === 'task.created').map(e => e.body.request)), messages: Object.fromEntries(of(tr, 'messages').map(m => [m.id, m])) };
return tr.map(r => line(r, ctx));
}
function trail(kind, ref) {
if (kind === 'task') {
const row = Q.tasks().find(r => r.task_ref === ref);
return row ? lines(Q.trail(ref), row.seq) : null;
}
if (!decision(ref)) return null;
// A decision's trail with its earlier versions: one module read per version, merged in the module's order.
const rows = new Map();
for (const id of [ref, ...chain(ref).map(c => c.id)]) for (const r of Q.trail(id)) rows.set(`${r.table}:${r.seq}`, r);
return lines([...rows.values()].sort((a, b) => a.at.localeCompare(b.at) || a.table.localeCompare(b.table) || a.seq - b.seq));
}
return { NOW, lookups: L, inbox, decision: id => decision(id), chain, tasks, task: ref => { const row = Q.tasks().find(r => r.task_ref === ref); return row ? task(row) : null; }, agents, trail };
}
// ---------------------------------------------------------------- renderers
let state = 'normal', R = reads(FULL), lastRead = FULL.NOW, firstRender = true;
const enc = encodeURIComponent;
function ago(iso) {
const s = Math.max(0, Math.round((R.NOW - Date.parse(iso)) / 1000)), m = Math.floor(s / 60), h = Math.floor(m / 60);
return s < 60 ? `${s} s ago` : m < 60 ? `${m} min ago` : h < 24 ? `${h} h ${m % 60} min ago` : `${Math.floor(h / 24)} d ago`;
}
const when = iso => iso ? `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(ago(iso))}</time>` : 'never';
const clock = iso => `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(iso.slice(11, 16))}</time>`;
const short = id => esc(id.slice(0, 8));
const badge = (text, kind = 'muted') => `<span class="badge badge-${kind}">${esc(text)}</span>`;
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(ref.split('/')[1])}</a>` : '';
// Rows without an actor role come from the human (CLI, outside any run) or from the broker itself.
const actor = (a, by) => `<span class="source">${a ? `${esc(a.role)} · ${esc(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}</span>`;
const PRIORITY = ['unset', 'low', 'medium', 'high', 'urgent', 'do now'];
const head = (title, sub) => `<div class="page-head"><div><h1 tabindex="-1">${title}</h1>${sub ? `<p class="small muted" id="status" role="status">${sub}</p>` : ''}</div></div>`;
const readNote = () => `Read from the broker ${when(lastRead)}.`;
// Anything shown from S1Q1.missing carries this label: no Q1 verb returns it yet.
const gap = what => `<span class="feat">${esc(what)}: not in the Q1 module</span>`;
// Which choice authorizes which action on which target, as the broker recorded it at decision.raise.
// Never inferred from the recommendation or an option's position.
const approves = a => a ? `Choosing “${txt(a.approvalChoice)}” approves ${txt(a.action)}${a.target ? ` on ${txt(a.target)}` : ''}.` : 'No approval context was recorded for this decision, so no option is marked as approving.';
function sections() {
const route = location.hash.slice(1).split('?')[0] || '/';
const ib = R.inbox(), urgent = ib.human.filter(d => d.blocking).length;
const items = [['/', 'Control board', ''], ['/inbox', 'Inbox', `<span class="count" aria-label="${ib.human.length} open for you">${ib.human.length}</span>${urgent ? ` ${badge(`${urgent} blocking`, 'attn')}` : ''}`], ['/tasks', 'Tasks', ''], ['/agents', 'Agents', '']];
$('sections').innerHTML = items.map(([h, l, extra]) => {
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && route.startsWith('/trail/decision'));
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}>${l}${extra ? ` ${extra}` : ''}</a></li>`;
}).join('');
}
// Inbox -----------------------------------------------------------------
function decisionCard(d) {
const first = d.question.split('\n')[0];
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first)}</a></div>
<p class="s1-badges">${d.blocking ? badge('blocking', 'attn') : ''}${badge(`${d.class} · ${d.action}`)}${d.dm ? badge('DM sent', 'ok') : ''}${d.seen.length ? badge(`seen ${ago(d.seen.at(-1).at)}`) : ''}</p>
<p class="small s1-auth">${approves(d.authorization)}</p>
<p class="small muted">Raised by ${esc(d.raisedBy.role)} ${when(d.at)}${d.routeTo !== 'human' ? ` · for ${esc(d.routeTo)}` : ''}${d.taskRef ? ` · task ${taskLink(d.taskRef)}` : ''}${d.requirementRef ? ` · ${esc(d.requirementRef)}` : ''}</p></li>`;
}
function inboxView(q) {
const ib = R.inbox(), view = ['roles', 'recent'].includes(q.get('view')) ? q.get('view') : 'human';
const tabs = [['human', 'For you', ib.human.length], ['roles', 'Routed to roles', ib.roles.length], ['recent', 'Recent', ib.recent.length]];
const nav = `<nav aria-label="Inbox views" class="chips">${tabs.map(([k, l, n]) => `<a class="chip" href="#/inbox${k === 'human' ? '' : `?view=${k}`}"${view === k ? ' aria-current="true"' : ''}>${l} <span class="count">${n}</span></a>`).join('')}</nav>`;
let body;
if (view === 'human') body = ib.human.length ? `<ul class="waiting s1-decs">${ib.human.map(decisionCard).join('')}</ul>` : '<p class="state">Nothing is waiting on you. New decisions also arrive as a DM when they block work.</p>';
else if (view === 'roles') {
const by = {}; for (const d of ib.roles) (by[d.routeTo] ||= []).push(d);
body = ib.roles.length ? Object.entries(by).map(([role, ds]) => `<section aria-labelledby="h-r-${esc(role)}"><h2 id="h-r-${esc(role)}">For ${esc(role)} <span class="count">${ds.length}</span></h2><ul class="waiting s1-decs">${ds.map(decisionCard).join('')}</ul></section>`).join('')
: '<p class="state">No open decisions between roles.</p>';
body = `<p class="small muted">Cross-role decisions go to the arbiter role. They're shown for context; the arbiter resolves them, not you. ${gap('Decisions routed to roles')}</p>${body}`;
} else body = `<p class="small muted">${gap('Closed decisions')}</p>` + (ib.recent.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Recently closed decisions, scroll for all columns"><table class="s1-table"><thead><tr><th scope="col">Decision</th><th scope="col">Outcome</th><th scope="col">By</th><th scope="col">Closed</th><th scope="col">Open for</th></tr></thead><tbody>${ib.recent.map(d => `<tr><td><a href="#/inbox/${enc(d.id)}">${txt(d.question.split('\n')[0])}</a></td><td>${d.closed.op === 'resolved' ? `chose “${txt(d.closed.choice)}”` : esc(d.closed.op)}</td><td>${esc(d.closed.by)}${d.closed.via ? `<span class="source">via ${esc(d.closed.via)}</span>` : ''}</td><td>${when(d.closed.at)}</td><td>${esc(Math.round((Date.parse(d.closed.at) - Date.parse(d.at)) / 60000))} min</td></tr>`).join('')}</tbody></table></div>`
: '<p class="state">No decisions closed yet.</p>');
return `${head('Inbox', `${readNote()} Decisions are resolved in a terminal with <code>mosaic decide</code>; Console only shows them.`)}${nav}${body}`;
}
function decisionView(id) {
const d = R.decision(id);
if (!d) return notFound(`No decision with id ${esc(id)}.`);
const status = d.closed ? (d.closed.op === 'resolved' ? `Resolved: ${esc(d.closed.by)} chose “${txt(d.closed.choice)}” via ${esc(d.closed.via)} ${when(d.closed.at)}.` : `${esc(d.closed.op[0].toUpperCase() + d.closed.op.slice(1))} by ${esc(d.closed.by)} ${when(d.closed.at)}${d.closed.note ? `: ${txt(d.closed.note)}` : ''}.`) : `Open since ${when(d.at)}.`;
const options = `<ol class="s1-opts">${d.options.map(o => {
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = d.closed?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
return `<li class="s1-opt"><p><b class="s1-key">${txt(o.key)}</b> ${txt(o.text)} ${approving ? badge(`approves ${d.authorization.action}`, 'attn') : ''}${o.key === d.recommendation ? badge('recommended', 'ok') : ''}${chosen ? badge('chosen', 'ok') : ''}</p>${d.closed ? '' : `<div class="s1-cmd"><code>${txt(cmd)}</code><button type="button" class="btn" data-copy="${esc(cmd)}" aria-label="Copy the command for option ${esc(o.key)}">Copy</button></div>`}</li>`;
}).join('')}</ol>`;
const chain = R.chain(d.id);
const history = [...d.seen.map(s => `<li>Seen by ${esc(s.by)} via ${esc(s.via)} ${when(s.at)}</li>`), ...(d.closed ? [`<li>${esc(d.closed.op)} by ${esc(d.closed.by)}${d.closed.via ? ` via ${esc(d.closed.via)}` : ''} ${when(d.closed.at)}</li>`] : [])];
return `${head(`Decision ${short(d.id)}`, status)}
<section aria-labelledby="h-q" class="panel"><h2 id="h-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
<dl class="kv"><dt>Class</dt><dd>${esc(d.class)} · ${esc(d.action)}${d.blocking ? ` ${badge('blocking', 'attn')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${esc(d.routeTo)}</dd><dt>Raised by</dt><dd>${esc(d.raisedBy.role)} <span class="source">${esc(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${esc(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${esc(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${esc(d.id)}</code></dd></dl></section>
<section aria-labelledby="h-o"><h2 id="h-o">Options</h2>${d.closed ? '' : `<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.</p>`}${options}<p class="small muted" id="copy-status" role="status"></p></section>
${chain.length ? `<section aria-labelledby="h-c"><h2 id="h-c">Earlier versions</h2><ul class="s1-plain">${chain.map(c => `<li><a href="#/inbox/${enc(c.id)}">${short(c.id)}</a> ${badge(c.closed ? c.closed.op : 'open')} ${txt(c.question.split('\n')[0])}${c.closed?.note ? `<span class="source">${txt(c.closed.note)}</span>` : ''}</li>`).join('')}</ul></section>` : ''}
<section aria-labelledby="h-h"><h2 id="h-h">History</h2>${history.length ? `<ul class="s1-plain">${history.join('')}</ul>` : '<p class="muted">Not seen yet.</p>'}<p><a href="#/trail/decision/${enc(d.id)}">Full trail for this decision</a></p></section>`;
}
// Tasks -----------------------------------------------------------------
function markers(t) {
const out = [];
if (t.external) out.push(t.external.kind === 'foreign' ? badge('not created by the stack', 'attn') : badge(`changed in Vikunja${t.external.fields.length ? `: ${t.external.fields.join(', ')}` : ''}`, 'attn'));
if (t.conflict) out.push(`<a href="#/inbox/${enc(t.conflict.decision)}" class="s1-marker">${badge('conflict', 'danger')}</a>`);
if (t.missing) out.push(badge(`missing: ${t.missing.reason}`, 'danger'));
return out.join(' ');
}
function taskRow(t) {
return `<tr><td class="s1-title"><a href="#/tasks/${enc(t.ref)}">#${esc(t.index)} ${txt(t.title ?? 'untitled')}</a></td><td>${t.assignee.map(esc).join(', ') || '<span class="muted">none</span>'}</td><td>${esc(t.requirement || '') || '<span class="muted">none</span>'}</td><td>${esc(t.due || '') || '<span class="muted">none</span>'}</td><td>${esc(PRIORITY[t.priority] || t.priority)}</td><td>${esc(t.percent)}%</td><td>${when(t.changedAt)}</td><td>${markers(t) || '<span class="muted">none</span>'}</td></tr>`;
}
const taskTable = (rows, label) => `<div class="table-wrap" tabindex="0" role="region" aria-label="${esc(label)}, scroll for all columns"><table class="s1-table s1-tasks"><thead><tr>${['Task', 'Assignee', 'Requirement', 'Due', 'Priority', 'Done', 'Changed', 'Markers'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(taskRow).join('')}</tbody></table></div>`;
function freshness(f) {
if (!f) return 'Vikunja last read: unknown.';
const last = [f.board, f.cursor].sort().at(-1);
return `Vikunja project ${esc(f.project)} last read ${when(last)} (board read ${clock(f.board)}, cursor read ${clock(f.cursor)})${f.error ? `; last poll failed: ${txt(f.error)}` : ''}. ${f.proposed ? '<span class="feat">from the S3 status read, not built yet</span>' : ''}`;
}
function tasksView() {
const ts = R.tasks(), any = ts.groups.some(g => g.rows.length) || ts.missing.length;
if (!any) return `${head('Tasks', freshness(ts.freshness))}<p class="state">No tasks in this business yet. Tasks appear after the PM creates them or the poller reads them from Vikunja.</p>`;
const groups = ts.groups.map(g => {
const id = `h-b-${g.bucket}`;
if (g.bucket === 'done') return `<details class="s1-done"><summary><h2 id="${id}">done <span class="count">${g.rows.length}</span></h2></summary>${g.rows.length ? taskTable(g.rows.slice(0, 20), 'Done tasks') : '<p class="muted">None.</p>'}</details>`;
return `<section aria-labelledby="${id}"><h2 id="${id}">${esc(g.bucket)} <span class="count">${g.rows.length}</span></h2>${g.rows.length ? taskTable(g.rows, `${g.bucket} tasks`) : '<p class="muted">None.</p>'}</section>`;
}).join('');
const missing = ts.missing.length ? `<section aria-labelledby="h-missing"><h2 id="h-missing">No longer readable <span class="count">${ts.missing.length}</span></h2><ul class="s1-plain">${ts.missing.map(t => `<li><a href="#/tasks/${enc(t.ref)}">#${esc(t.index)} ${txt(t.title ?? 'untitled')}</a> ${badge(`missing: ${t.missing.reason}`, 'danger')} <span class="source">since ${clock(t.missing.at)}${t.missing.project ? `, now in project ${esc(t.missing.project)}` : ''}</span></li>`).join('')}</ul></section>` : '';
return `${head('Tasks', freshness(ts.freshness))}<p class="small muted">Business ${esc(R.lookups.business)}. Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}</p>${groups}${missing}`;
}
function taskView(ref) {
const t = R.task(ref);
if (!t) return notFound(`No task ${esc(ref)} in this business.`);
return `${head(`#${esc(t.index)} ${txt(t.title ?? 'untitled')}`, `${t.missing ? `Missing from Vikunja (${esc(t.missing.reason)}) since ${when(t.missing.at)}.` : `In ${esc(t.bucket)}, last changed ${when(t.changedAt)}.`} ${markers(t)}`)}
<p><a class="btn" href="${esc(t.url)}" rel="noreferrer" target="_blank">Open in Vikunja</a></p>
<section class="panel" aria-labelledby="h-t"><h2 id="h-t">Task</h2><dl class="kv"><dt>Ref</dt><dd><code>${esc(t.ref)}</code></dd><dt>Assignee</dt><dd>${t.assignee.map(esc).join(', ') || 'none'}</dd><dt>Requirement</dt><dd>${esc(t.requirement || 'none')}</dd><dt>Due</dt><dd>${esc(t.due || 'none')}</dd><dt>Priority</dt><dd>${esc(PRIORITY[t.priority] || t.priority)}</dd><dt>Done</dt><dd>${esc(t.percent)}%</dd>${t.review.length ? `<dt>Review</dt><dd>${t.review.map(r => `${r.kind === 'review.verdict' ? `verdict ${esc(r.verdict)}` : 'requested'} (queue row ${esc(r.row)}, round ${esc(r.round)}) ${when(r.at)}`).join('<br>')}</dd>` : ''}${t.conflict ? `<dt>Conflict</dt><dd>${esc(t.conflict.field)}: queue says ${esc(t.conflict.queue)}, Vikunja says ${esc(t.conflict.tracker)}. <a href="#/inbox/${enc(t.conflict.decision)}">Decision for the PM</a></dd>` : ''}</dl>
${t.description ? `<h3>Description</h3><p class="s1-q">${txt(t.description)}</p>` : ''}
<details class="s1-snaps"><summary>Snapshots <span class="count">${t.snapshots.length}</span></summary><ol class="s1-plain">${t.snapshots.map(s => `<li>${clock(s.at)} ${esc(s.bucket)} <span class="source">${s.source === 'self' ? `written by ${esc(s.role)}` : `${esc(s.via)} read sent ${esc(s.readAt.slice(11, 19))}`}${s.stale ? '; stale read, not shown' : ''}</span></li>`).join('')}</ol></details></section>
<section aria-labelledby="h-trail"><h2 id="h-trail">Trail</h2>${trailList(R.trail('task', ref), 'all')}<p><a href="#/trail/task/${enc(ref)}">Trail with filters</a></p></section>`;
}
// Agents ----------------------------------------------------------------
function agentsView() {
const a = R.agents();
const limits = a.limits.map(l => `${l.family[0].toUpperCase() + l.family.slice(1)} ${l.live} of ${l.max}`).join(', ');
const launch = `${a.launch.state === 'revoked' ? `${badge('launching revoked', 'danger')} since ${when(a.launch.at)}` : `${badge('launching allowed', 'ok')}${a.launch.at ? ` <span class="source">restored from the CLI ${when(a.launch.at)}</span>` : ''}`} ${gap('Launch state')}`;
const holder = h => !h ? '<span class="muted">not held</span>'
: `${esc(h.run)}${h.advisory ? ` ${badge('advisory', 'muted')}` : ''}<span class="source">${esc(h.harness)}${h.address ? ` · ${esc(h.address)}` : ''} · held since ${clock(h.at)}</span>`;
const session = s => s ? `${esc(s.run)}<span class="source">${esc(s.family)} · ${esc(s.harness)} · started ${when(s.at)}</span>` : '<span class="muted">none</span>';
const creds = cs => cs.length ? cs.map(c => `${esc(c.service)}: ${c.kind === 'expiring' ? badge(`expires ${c.expires}`, 'attn') : c.kind === 'expired' ? badge('expired', 'danger') : badge(`changed ${c.at.slice(0, 10)}`)}`).join('<br>') : '<span class="muted">no notices</span>';
const rows = a.instances.map(i => `<tr><th scope="row">${esc(i.instance)}${i.arbiterFor ? `<span class="source">arbiter, ${esc(i.arbiterFor)}</span>` : ''}</th><td>${esc(i.harness)}<span class="source">${esc(i.model)}</span></td><td>${holder(i.holder)}</td><td>${session(i.session)}</td><td>${creds(i.credentials)}</td></tr>`).join('');
return `${head('Agents', `${readNote()} Live sessions: ${esc(limits)}.`)}
<p class="s1-launch">${launch}</p>
<section aria-labelledby="h-inst"><h2 id="h-inst">Role instances <span class="count">${a.instances.length}</span></h2><p class="small muted">Holders come from the read module; a role with no current claim shows as not held. A claim from a T3 seat is advisory: the seat keeps its own session and the broker can't stop it. ${esc(a.launchRule.by)} may launch ${a.launchRule.instances.map(esc).join(' and ')}. ${gap('Instances, live sessions and credential notices')}</p>
<div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Instance', 'Harness', 'Holder', 'Live session', 'Credentials'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>
<p class="small muted">Credential notices show the service and dates only. Console never receives a token or its path.</p></section>
<section aria-labelledby="h-ended"><h2 id="h-ended">Ended sessions <span class="count">${a.ended.length}</span></h2><p class="small muted">${gap('Ended sessions')}</p>${a.ended.length ? `<ul class="s1-plain">${a.ended.map(e => `<li>${esc(e.role)} · ${esc(e.run)} ended ${when(e.at)} (${txt(e.reason)}, exit ${esc(e.exit)})</li>`).join('')}</ul>` : '<p class="muted">None today.</p>'}</section>
<p class="small muted">Board seats stay on the <a href="#/">control board</a>. This view is about roles.</p>`;
}
// Trail -----------------------------------------------------------------
const GROUPS = [['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
function trailList(rows, filter) {
const shown = filter === 'all' ? rows : rows.filter(r => r.group === filter);
if (!shown.length) return '<p class="muted">Nothing of this kind in the trail.</p>';
return `<ol class="s1-trail">${shown.map(r => `<li class="s1-trow s1-g-${esc(r.group)}"><span class="s1-twhen">${clock(r.at)}</span><span class="s1-tkind">${esc(r.kind)}</span><div class="s1-ttext"><p>${txt(r.text)}${r.decision && !r.kind.startsWith('decision.') && !r.kind.startsWith('delivery.') ? ` <a href="#/inbox/${enc(r.decision)}">decision ${short(r.decision)}</a>` : ''}</p>${actor(r.actor, r.by)}${r.note ? `<span class="source">${esc(r.note)}</span>` : ''}</div></li>`).join('')}</ol>`;
}
function trailView(kind, ref, q) {
const rows = R.trail(kind, ref);
if (!rows) return notFound(`No ${esc(kind)} ${esc(ref)}.`);
const filter = GROUPS.some(([g]) => g === q.get('kind')) ? q.get('kind') : 'all';
const base = `#/trail/${kind}/${enc(ref)}`;
const title = kind === 'task' ? `Trail for task #${esc(ref.split('/')[1])}` : `Trail for decision ${short(ref)}`;
const back = kind === 'task' ? `<a href="#/tasks/${enc(ref)}">Back to the task</a>` : `<a href="#/inbox/${enc(ref)}">Back to the decision</a>`;
return `${head(title, `${readNote()} Oldest first. ${back}`)}
<nav aria-label="Filter the trail" class="chips">${GROUPS.map(([g, l]) => `<a class="chip" href="${base}${g === 'all' ? '' : `?kind=${g}`}"${filter === g ? ' aria-current="true"' : ''}>${l} <span class="count">${g === 'all' ? rows.length : rows.filter(r => r.group === g).length}</span></a>`).join('')}</nav>
${trailList(rows, filter)}`;
}
// Board, not found, states -----------------------------------------------
const boardView = () => `${head('Control board', 'Unchanged in slice 1. The live Console board is not repeated in this mockup.')}<p>The new sections are in the left column: <a href="#/inbox">Inbox</a>, <a href="#/tasks">Tasks</a> and <a href="#/agents">Agents</a>.</p>`;
function notFound(why) { return `${head('Not found')}<p class="state">${why || 'No page at this address.'} <a href="#/inbox">Go to the inbox</a></p>`; }
const loadingView = () => `${head('Reading…')}<p class="state" aria-busy="true"><span class="spinner" aria-hidden="true"></span>Reading from the broker.</p>`;
const refusedView = () => `${head('Broker refused the read')}<div class="state state-error" role="alert"><p><b>The broker refused the read: the bus schema digest doesn't match.</b> Console shows nothing rather than a guess, and it doesn't retry around a refusal.</p><p>Check the broker's log for the reason. Nothing was written.</p><div class="actions"><button type="button" class="btn" data-retry>Read again</button></div></div>`;
function render() {
const raw = location.hash.slice(1) || '/', [path, query = ''] = raw.split('?'), q = new URLSearchParams(query);
const parts = path.split('/').filter(Boolean).map(decodeURIComponent);
R = reads(state === 'empty' ? EMPTY : FULL);
sections();
let html;
if (state === 'loading') html = loadingView();
else if (state === 'refused') html = refusedView();
else {
html = !parts.length ? boardView()
: parts[0] === 'inbox' && parts.length === 1 ? inboxView(q)
: parts[0] === 'inbox' && parts.length === 2 ? decisionView(parts[1])
: parts[0] === 'tasks' && parts.length === 1 ? tasksView()
: parts[0] === 'tasks' && parts.length === 2 ? taskView(parts[1])
: parts[0] === 'agents' && parts.length === 1 ? agentsView()
: parts[0] === 'trail' && parts.length === 3 && ['task', 'decision'].includes(parts[1]) ? trailView(parts[1], parts[2], q)
: notFound();
if (state === 'error') html = `<div class="state state-error s1-stale" role="alert"><p><b>The last read failed:</b> the broker didn't answer within 5 seconds. This is the data from the read before, ${when(lastRead)}. Nothing was changed.</p><div class="actions"><button type="button" class="btn" data-retry>Read again</button></div></div>${html}`;
}
$('main').innerHTML = html;
$('foot-read').textContent = state === 'refused' ? 'Broker refused the read' : `Fixture read at ${new Date(R.NOW).toISOString().slice(11, 16)} UTC`;
const h1 = document.querySelector('#main h1');
document.title = `${h1 ? h1.textContent : 'Console'} · Mosaic Console`;
if (!firstRender && h1) { h1.focus(); announce(h1.textContent); }
firstRender = false;
}
// Appearance, as the live Console does it.
let palette = 'harbor', mode = 'light';
function theme() {
const p = Brand.palettes.find(x => x.id === palette) || Brand.palettes[0];
palette = p.id; if (!['light', 'dim', 'dark'].includes(mode)) mode = 'light';
for (const [name, value] of Object.entries(Brand.tokens(p, mode))) document.documentElement.style.setProperty('--' + name, value);
document.documentElement.dataset.palette = palette; document.documentElement.dataset.mode = mode;
$('palette').value = palette; $('mode').value = mode;
}
$('palette').innerHTML = Brand.palettes.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
const qs = new URLSearchParams(location.search);
palette = qs.get('palette') || palette; mode = qs.get('mode') || mode; state = qs.get('state') || state;
$('demo').value = state;
theme();
$('palette').onchange = e => { palette = e.target.value; theme(); };
$('mode').onchange = e => { mode = e.target.value; theme(); };
$('demo').onchange = e => { state = e.target.value; render(); };
$('refresh').onclick = () => { if (state !== 'refused') state = state === 'loading' ? 'normal' : state; render(); announce('Read again from the fixture.'); };
// Copy puts the command on the clipboard and does nothing else. If the clipboard is
// unavailable, the command text is selected so the person can copy it themselves.
document.addEventListener('click', async e => {
// The skip link moves focus only. Its #main would otherwise reach the hash router.
if (e.target.closest('.skip')) { e.preventDefault(); $('main').focus(); return; }
const b = e.target.closest('[data-copy]');
if (b) {
const text = b.dataset.copy, out = $('copy-status');
try { await navigator.clipboard.writeText(text); out.textContent = `Copied: ${text}`; }
catch { const r = document.createRange(); r.selectNodeContents(b.previousElementSibling); const s = getSelection(); s.removeAllRanges(); s.addRange(r); out.textContent = 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.'; }
return;
}
if (e.target.closest('[data-retry]')) { state = 'normal'; $('demo').value = state; render(); }
});
window.addEventListener('hashchange', () => { if (!location.hash || location.hash.startsWith('#/')) render(); });
window.S1App = { render, reads: () => R, setState: s => { state = s; $('demo').value = s; render(); } };
render();
})();