diff --git a/internal/web/static/app.css b/internal/web/static/app.css
new file mode 100644
index 0000000..3bbbf95
--- /dev/null
+++ b/internal/web/static/app.css
@@ -0,0 +1,33 @@
+ :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; }
diff --git a/internal/web/static/app.js b/internal/web/static/app.js
new file mode 100644
index 0000000..e128960
--- /dev/null
+++ b/internal/web/static/app.js
@@ -0,0 +1,179 @@
+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}`;
+}
+
+function evidenceBlock(ev) {
+ if (!ev) return '';
+ const health = ev.available
+ ? `tracking`
+ : `evidence unavailable: ${ev.reason || 'unknown'}`;
+ const now = ev.current && (ev.current.class || ev.current.title)
+ ? `${ev.current.class || '?'} · ${ev.current.title || ''}` : '—';
+ const rows = (ev.buckets || []).map(b =>
+ `
${(b.class || '?')} · ${b.title || ''}${fmt(b.seconds)}`).join('');
+ return `
+
Now: ${now} ${health}
+
+
Context switches: ${ev.switch_count || 0}
+
`;
+}
+
+function updateActiveDrift(drift) {
+ const el = document.getElementById('drift');
+ if (!el) return;
+ const status = drift && drift.status;
+ if (status === 'drifting') {
+ el.innerHTML = `
+
⚠ Possible drift
+
${drift.reason || ''}
+
+
+
+
+
`;
+ document.getElementById('refocus').onclick = () => post('/refocus');
+ document.getElementById('ontask').onclick = () => post('/ontask');
+ document.getElementById('enddrift').onclick = () => post('/complete');
+ return;
+ }
+ const nudge = (drift && drift.nudge) || '';
+ if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges
+ if (nudge && nudge !== dismissedNudge) {
+ el.innerHTML = `
+
Heads up
+
${nudge}
+
+
+
`;
+ document.getElementById('dismissnudge').onclick = () => {
+ dismissedNudge = nudge;
+ const e = document.getElementById('drift');
+ if (e) e.innerHTML = '';
+ };
+ return;
+ }
+ if (status === 'pending') {
+ el.innerHTML = `checking focus…
`;
+ return;
+ }
+ el.innerHTML = '';
+}
+
+function updatePlanningCoach(coach) {
+ const statusEl = document.getElementById('coachStatus');
+ if (!statusEl) return;
+ if (!coach || coach.status === 'idle') { statusEl.textContent = ''; return; }
+ if (coach.status === 'pending') { statusEl.textContent = 'Sharpening…'; return; }
+ 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;
+ const na = document.getElementById('na'), sc = document.getElementById('sc'),
+ mins = document.getElementById('mins'), start = document.getElementById('start');
+ if (na && !na.value.trim()) na.value = coach.proposal.next_action || '';
+ if (sc && !sc.value.trim()) sc.value = coach.proposal.success_condition || '';
+ if (mins && coach.proposal.timebox_secs) mins.value = Math.round(coach.proposal.timebox_secs / 60);
+ if (start) start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0);
+ const apps = document.getElementById('apps');
+ if (apps && !apps.value.trim() && Array.isArray(coach.proposal.allowed_window_classes)) {
+ apps.value = coach.proposal.allowed_window_classes.join(', ');
+ }
+ }
+}
+
+function render(state) {
+ const rs = state.runtime_state;
+ if (rs === 'planning' && renderedState === 'planning') {
+ updatePlanningCoach(state.coach); // partial update only
+ return;
+ }
+ if (rs === 'active' && renderedState === 'active') {
+ updateActiveDrift(state.drift);
+ return;
+ }
+ if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
+ renderedState = rs;
+ if (rs === 'locked') {
+ view.innerHTML = `Locked
+ No active commitment.
+ `;
+ document.getElementById('plan').onclick = () => post('/planning');
+ } else if (rs === 'planning') {
+ view.innerHTML = `Planning
+
+
+
+
+
+
+
+
+
+ `;
+ 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); };
+ [na, sc, mins].forEach(el => el.oninput = check);
+ start.onclick = () => post('/commitment', {
+ next_action: na.value.trim(),
+ success_condition: sc.value.trim(),
+ timebox_secs: Math.round(+mins.value * 60),
+ allowed_window_classes: (document.getElementById('apps').value || '')
+ .split(',').map(s => s.trim()).filter(Boolean),
+ });
+ document.getElementById('sharpen').onclick = () => {
+ const intent = document.getElementById('intent').value.trim();
+ if (intent) post('/coach', { intent });
+ };
+ updatePlanningCoach(state.coach);
+ } else if (rs === 'active') {
+ const c = state.commitment || {};
+ view.innerHTML = `Active
+
+ --:--
+ ${c.next_action || ''}
+ Done when: ${c.success_condition || ''}
+ ${evidenceBlock(state.evidence)}
+ `;
+ 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);
+ } else if (rs === 'review') {
+ const c = state.commitment || {};
+ view.innerHTML = `Review
+ Session ended
+ ${c.next_action || ''}
+ ${evidenceBlock(state.evidence)}
+ `;
+ document.getElementById('end').onclick = () => post('/end');
+ } else {
+ view.textContent = rs;
+ }
+}
+
+const es = new EventSource('/events');
+es.onmessage = (e) => render(JSON.parse(e.data));
+es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };
diff --git a/internal/web/static/index.html b/internal/web/static/index.html
index ae7e944..41fb16c 100644
--- a/internal/web/static/index.html
+++ b/internal/web/static/index.html
@@ -4,227 +4,13 @@
AntiDrift
-
+
-
+
AntiDrift
connecting…
-
+