const app = document.getElementById('app');
const view = document.getElementById('view');
let countdownTimer = null;
let renderedState = null; // which runtime_state the DOM currently shows
let dismissedNudge = null; // text of the nudge the user has dismissed
function post(path, body) {
return fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
}
function fmt(secs) {
secs = Math.max(0, Math.floor(secs));
const m = String(Math.floor(secs / 60)).padStart(2, '0');
const s = String(secs % 60).padStart(2, '0');
return `${m}:${s}`;
}
// stateKey maps server state to the data-state accent bucket. Drift outranks
// a nudge, and a nudge the user already dismissed reverts to plain active.
function stateKey(state) {
const rs = state.runtime_state;
if (rs !== 'active') return rs || 'locked';
const d = state.drift || {};
if (d.status === 'drifting') return 'drift';
if (d.nudge && d.nudge !== dismissedNudge) return 'nudge';
return 'active';
}
function setStateAttr(state) {
app.dataset.state = stateKey(state);
}
function evidenceBlock(ev) {
if (!ev) return `
`;
}
// updateActiveEvidence repaints the live evidence band (current window, per-window
// buckets, switch count) on every active tick. The band carries no event listeners,
// so swapping outerHTML wholesale is safe and keeps a single render source.
function updateActiveEvidence(state) {
const el = document.getElementById('evidence');
if (el) el.outerHTML = evidenceBlock(state.evidence);
}
// reviewSummary renders a presentational recap from already-available state:
// the commitment plus the per-window evidence buckets. No new backend data.
function reviewSummary(ev) {
if (!ev) return '';
const rows = (ev.buckets || []).map(b =>
`
`;
el.querySelectorAll('.task-chip').forEach(btn => {
btn.onclick = () => {
const intent = document.getElementById('intent');
if (intent) { intent.value = list[+btn.dataset.i].title; intent.focus(); }
};
});
}
// updatePlanningKnowledge renders the standing-profile grounding indicator and
// a small "change" affordance to repoint the file. idle/nil renders nothing;
// pending/ready/absent/error each show one quiet line. The profile text never
// reaches the browser — only status, path, and size.
function updatePlanningKnowledge(k) {
const el = document.getElementById('knowBand');
if (!el) return;
if (!k || k.status === 'idle') { el.innerHTML = ''; return; }
const base = (k.path || '').split('/').pop() || k.path || '';
let line;
if (k.status === 'pending') line = 'loading profile…';
else if (k.status === 'ready') line = `grounded by ${base} · ${k.chars} chars`;
else if (k.status === 'absent') line = `no profile (${k.path || 'unset'})`;
else line = 'profile unreadable';
el.innerHTML =
`${line} `;
document.getElementById('knowChange').onclick = openSettings;
}
// reflectionBlock renders the reviewer's recap on the Review screen. idle/nil or
// an empty recap renders nothing; pending shows a quiet line; ready shows the
// one-line recap.
function reflectionBlock(refl) {
if (!refl) return '';
if (refl.status === 'pending') return `
reflecting…
`;
if (refl.status === 'ready' && refl.recap) {
return `
${refl.recap}
`;
}
return '';
}
// updatePlanningReflection renders last session's carry-forward takeaway as a
// quiet one-liner on the planning screen. Nothing renders without one.
function updatePlanningReflection(refl) {
const el = document.getElementById('reflectBand');
if (!el) return;
if (!refl || !refl.carry_forward) { el.innerHTML = ''; return; }
el.innerHTML = `Last time: ${refl.carry_forward}`;
}
function render(state) {
setStateAttr(state);
const rs = state.runtime_state;
if (rs === 'planning' && renderedState === 'planning') {
updatePlanningCoach(state.coach);
updatePlanningTasks(state.tasks);
updatePlanningKnowledge(state.knowledge);
updatePlanningReflection(state.reflection);
updatePlanningWindowHint(state);
return;
}
if (rs === 'active' && renderedState === 'active') {
updateActiveDrift(state);
updateActiveEvidence(state);
return;
}
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
renderedState = rs;
if (rs === 'locked') {
view.innerHTML = `