M4: cockpit HUD with state-driven accent

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 21:10:03 -04:00
parent 76816ae188
commit f5da12362a
2 changed files with 204 additions and 88 deletions
+109 -33
View File
@@ -1,33 +1,109 @@
:root { color-scheme: dark; }
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; background: #11131a; color: #e6e8ee; }
main { max-width: 640px; margin: 8vh auto; padding: 0 24px; }
h1 { font-size: 14px; letter-spacing: .2em; text-transform: uppercase; color: #7a8095; }
.card { background: #1a1d27; border: 1px solid #272b38; border-radius: 14px; padding: 28px; }
label { display: block; font-size: 13px; color: #9aa0b4; margin: 16px 0 6px; }
input { width: 100%; box-sizing: border-box; padding: 10px 12px; background: #11131a;
border: 1px solid #2d3242; border-radius: 8px; color: #e6e8ee; font: inherit; }
button { margin-top: 22px; padding: 11px 18px; border: 0; border-radius: 8px;
background: #4c6ef5; color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
button:disabled { background: #2d3242; color: #6a7186; cursor: not-allowed; }
.timer { font-size: 56px; font-weight: 700; font-variant-numeric: tabular-nums; margin: 8px 0 4px; }
.action { font-size: 22px; font-weight: 600; }
.meta { color: #9aa0b4; }
.pill { display: inline-block; font-size: 12px; letter-spacing: .15em; text-transform: uppercase;
color: #7a8095; border: 1px solid #272b38; border-radius: 999px; padding: 3px 10px; }
.ev { margin-top: 18px; border-top: 1px solid #272b38; padding-top: 14px; }
.ev .now { font-size: 14px; color: #cdd2e0; }
.ev .health-ok { color: #5fd08a; }
.ev .health-bad { color: #e0b15f; }
.buckets { list-style: none; padding: 0; margin: 10px 0 0; font-size: 13px; color: #9aa0b4; }
.buckets li { display: flex; justify-content: space-between; padding: 2px 0; font-variant-numeric: tabular-nums; }
.switches { font-size: 13px; color: #7a8095; margin-top: 8px; }
.drift { background: #2a1d22; border: 1px solid #5a2d39; border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
.drift-title { font-weight: 700; color: #f0a3b1; }
.drift-reason { color: #e6c0c8; margin: 4px 0 10px; }
.drift-actions button { margin: 0 8px 0 0; padding: 8px 14px; }
.drift-actions button.secondary { background: #2d3242; color: #cdd2e0; }
.drift-hint { font-size: 12px; color: #7a8095; margin-bottom: 10px; }
.nudge { background: #1d2630; border: 1px solid #2f4255; border-radius: 10px; padding: 12px 14px; margin-bottom: 16px; }
.nudge-title { font-weight: 600; color: #8fb6d9; }
.nudge-msg { color: #c2d2e0; margin: 4px 0 8px; }
.nudge-actions button { padding: 6px 12px; background: #2d3242; color: #cdd2e0; }
:root {
color-scheme: dark;
--bg: #0d0f14;
--panel: #161922;
--line: #232733;
--ink: #e6e8ee;
--ink-dim: #8b91a6;
--ok: #34d399;
--warn: #f0b429;
--danger: #f06070;
--accent: #6b7280; /* default / locked */
}
/* The single state-driven knob: only --accent changes per state. */
[data-state="planning"] { --accent: #4c6ef5; }
[data-state="active"] { --accent: #34d399; }
[data-state="nudge"] { --accent: #f0b429; }
[data-state="drift"] { --accent: #f06070; }
[data-state="review"] { --accent: #a78bfa; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 15px/1.5 system-ui, sans-serif;
background: var(--bg);
color: var(--ink);
}
main { max-width: 560px; margin: 7vh auto; padding: 0 20px; }
h1 {
font-size: 12px; letter-spacing: .28em; text-transform: uppercase;
color: var(--ink-dim); margin: 0 0 14px 6px;
}
/* The HUD card: a stack of bands. */
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
overflow: hidden;
}
.band { border-top: 1px solid var(--line); padding: 16px 20px; }
.band:first-child { border-top: 0; }
.statusband {
display: flex; align-items: baseline; gap: 12px;
border-top: 3px solid var(--accent);
background: color-mix(in srgb, var(--accent) 8%, var(--panel));
}
.pill {
font-size: 11px; letter-spacing: .18em; text-transform: uppercase;
font-weight: 700; color: var(--accent);
}
.status-meta { font-size: 13px; color: var(--ink-dim); }
.timer {
font-size: 52px; font-weight: 700; line-height: 1;
font-variant-numeric: tabular-nums; color: var(--ink);
}
.action { font-size: 19px; font-weight: 600; }
.meta { color: var(--ink-dim); margin: 4px 0 0; }
label { display: block; font-size: 12px; color: var(--ink-dim); margin: 14px 0 5px; }
label:first-child { margin-top: 0; }
input {
width: 100%; padding: 10px 12px;
background: var(--bg); border: 1px solid var(--line);
border-radius: 8px; color: var(--ink); font: inherit;
}
input:focus { outline: 0; border-color: var(--accent); }
.btn {
margin-top: 16px; padding: 10px 16px; border: 0; border-radius: 8px;
font: inherit; font-weight: 600; cursor: pointer;
}
.btn-primary { background: var(--accent); color: #0d0f14; }
.btn-ghost { background: var(--line); color: var(--ink); }
.btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; }
/* Drift/nudge actions live in the status band; lay them out below the text. */
.band-actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
.band-actions .btn { margin-top: 0; padding: 7px 12px; }
.drift-reason, .nudge-msg { color: var(--ink); margin: 4px 0 0; font-weight: 400; }
.statusband.col { flex-direction: column; align-items: stretch; gap: 6px; }
/* Evidence band */
.evidence .now { font-size: 13px; color: var(--ink); }
.health-ok { color: var(--ok); }
.health-bad { color: var(--warn); }
.buckets { list-style: none; padding: 0; margin: 10px 0 0; font-size: 13px; color: var(--ink-dim); }
.buckets li {
display: flex; justify-content: space-between; padding: 2px 0;
font-family: ui-monospace, monospace; font-variant-numeric: tabular-nums;
}
.switches { font-size: 12px; color: var(--ink-dim); margin-top: 8px; }
/* Review summary band (built out in Task 3) */
.summary { font-size: 14px; }
.summary-row {
display: flex; justify-content: space-between; padding: 3px 0;
font-variant-numeric: tabular-nums; color: var(--ink-dim);
}
.summary-row span:last-child { color: var(--ink); font-family: ui-monospace, monospace; }
+84 -44
View File
@@ -1,3 +1,4 @@
const app = document.getElementById('app');
const view = document.getElementById('view');
let countdownTimer = null;
let renderedState = null; // which runtime_state the DOM currently shows
@@ -18,6 +19,21 @@ function fmt(secs) {
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 '';
const health = ev.available
@@ -27,52 +43,63 @@ function evidenceBlock(ev) {
? `${ev.current.class || '?'} · ${ev.current.title || ''}` : '—';
const rows = (ev.buckets || []).map(b =>
`<li><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></li>`).join('');
return `<div class="ev">
<div class="now">Now: ${now} &nbsp; ${health}</div>
return `<div class="band evidence">
<div class="now">now ${now} &nbsp; ${health}</div>
<ul class="buckets">${rows}</ul>
<div class="switches">Context switches: ${ev.switch_count || 0}</div>
<div class="switches">context switches: ${ev.switch_count || 0}</div>
</div>`;
}
function updateActiveDrift(drift) {
const el = document.getElementById('drift');
// updateActiveDrift owns the active status band: it renders on-task, nudge,
// drift, or pending content into #statusband and rebinds the buttons.
function updateActiveDrift(state) {
const el = document.getElementById('statusband');
if (!el) return;
const status = drift && drift.status;
if (status === 'drifting') {
el.innerHTML = `<div class="drift">
<div class="drift-title">⚠ Possible drift</div>
<div class="drift-reason">${drift.reason || ''}</div>
<div class="drift-actions">
<button id="refocus" type="button">Back to task</button>
<button id="ontask" type="button" class="secondary">This is on task</button>
<button id="enddrift" type="button" class="secondary">End session</button>
</div></div>`;
setStateAttr(state);
const drift = state.drift || {};
const switches = (state.evidence && state.evidence.switch_count) || 0;
if (drift.status === 'drifting') {
el.className = 'band statusband col';
el.innerHTML = `<div><span class="pill">Drift</span></div>
<div class="drift-reason">${drift.reason || 'This looks off task.'}</div>
<div class="band-actions">
<button id="refocus" type="button" class="btn btn-primary">Back to task</button>
<button id="ontask" type="button" class="btn btn-ghost">This is on task</button>
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
</div>`;
document.getElementById('refocus').onclick = () => post('/refocus');
document.getElementById('ontask').onclick = () => post('/ontask');
document.getElementById('enddrift').onclick = () => post('/complete');
return;
}
const nudge = (drift && drift.nudge) || '';
const nudge = drift.nudge || '';
if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges
if (nudge && nudge !== dismissedNudge) {
el.innerHTML = `<div class="nudge">
<div class="nudge-title">Heads up</div>
el.className = 'band statusband col';
el.innerHTML = `<div><span class="pill">Heads up</span></div>
<div class="nudge-msg">${nudge}</div>
<div class="nudge-actions">
<button id="dismissnudge" type="button">Dismiss</button>
</div></div>`;
<div class="band-actions">
<button id="dismissnudge" type="button" class="btn btn-ghost">Dismiss</button>
</div>`;
document.getElementById('dismissnudge').onclick = () => {
dismissedNudge = nudge;
const e = document.getElementById('drift');
if (e) e.innerHTML = '';
setStateAttr(state);
updateActiveDrift(state);
};
return;
}
if (status === 'pending') {
el.innerHTML = `<div class="drift-hint">checking focus…</div>`;
if (drift.status === 'pending') {
el.className = 'band statusband';
el.innerHTML = `<span class="pill">Active</span><span class="status-meta">checking focus…</span>`;
return;
}
el.innerHTML = '';
el.className = 'band statusband';
el.innerHTML = `<span class="pill">Active</span>
<span class="status-meta">on task · ${switches} switches</span>`;
}
function updatePlanningCoach(coach) {
@@ -83,8 +110,6 @@ function updatePlanningCoach(coach) {
if (coach.status === 'error') { statusEl.textContent = coach.error || 'coach unavailable'; return; }
if (coach.status === 'ready' && coach.proposal) {
statusEl.textContent = 'AI suggestion applied — edit freely.';
// Pre-fill once per ready proposal so later manual edits are not clobbered
// by subsequent SSE ticks (idempotent via the dataset stamp below).
const stamp = JSON.stringify(coach.proposal);
if (statusEl.dataset.applied === stamp) return;
statusEl.dataset.applied = stamp;
@@ -102,34 +127,43 @@ function updatePlanningCoach(coach) {
}
function render(state) {
setStateAttr(state);
const rs = state.runtime_state;
if (rs === 'planning' && renderedState === 'planning') {
updatePlanningCoach(state.coach); // partial update only
updatePlanningCoach(state.coach);
return;
}
if (rs === 'active' && renderedState === 'active') {
updateActiveDrift(state.drift);
updateActiveDrift(state);
return;
}
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
renderedState = rs;
if (rs === 'locked') {
view.innerHTML = `<span class="pill">Locked</span>
view.innerHTML = `<div class="band statusband"><span class="pill">Locked</span></div>
<div class="band">
<p class="meta">No active commitment.</p>
<button id="plan">Start planning</button>`;
<button id="plan" class="btn btn-primary">Start planning</button>
</div>`;
document.getElementById('plan').onclick = () => post('/planning');
} else if (rs === 'planning') {
view.innerHTML = `<span class="pill">Planning</span>
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div>
<div class="band">
<label>Rough intent</label>
<input id="intent" placeholder="e.g. work on the quarterly report">
<button id="sharpen" type="button">Sharpen</button>
<button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
<div id="coachStatus" class="meta"></div>
</div>
<div class="band">
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
<label>Allowed apps (comma-separated window classes)</label>
<input id="apps" placeholder="e.g. code, firefox">
<button id="start" disabled>Start commitment</button>`;
<button id="start" class="btn btn-primary" disabled>Start commitment</button>
</div>`;
const na = document.getElementById('na'), sc = document.getElementById('sc'),
mins = document.getElementById('mins'), start = document.getElementById('start');
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
@@ -146,29 +180,35 @@ function render(state) {
if (intent) post('/coach', { intent });
};
updatePlanningCoach(state.coach);
} else if (rs === 'active') {
const c = state.commitment || {};
view.innerHTML = `<span class="pill">Active</span>
<div id="drift"></div>
<div class="timer" id="t">--:--</div>
view.innerHTML = `<div class="band statusband" id="statusband"></div>
<div class="band"><div class="timer" id="t">--:--</div></div>
<div class="band">
<div class="action">${c.next_action || ''}</div>
<p class="meta">Done when: ${c.success_condition || ''}</p>
<p class="meta">done when: ${c.success_condition || ''}</p>
</div>
${evidenceBlock(state.evidence)}
<button id="done">Complete</button>`;
<div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`;
document.getElementById('done').onclick = () => post('/complete');
const t = document.getElementById('t');
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
tick();
countdownTimer = setInterval(tick, 250);
updateActiveDrift(state.drift);
updateActiveDrift(state);
} else if (rs === 'review') {
const c = state.commitment || {};
view.innerHTML = `<span class="pill">Review</span>
<p class="action">Session ended</p>
view.innerHTML = `<div class="band statusband"><span class="pill">Review</span></div>
<div class="band">
<div class="action">Session ended</div>
<p class="meta">${c.next_action || ''}</p>
</div>
${evidenceBlock(state.evidence)}
<button id="end">End</button>`;
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
document.getElementById('end').onclick = () => post('/end');
} else {
view.textContent = rs;
}