M4: split web UI assets into app.css and app.js
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||
@@ -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
|
||||
? `<span class="health-ok">tracking</span>`
|
||||
: `<span class="health-bad">evidence unavailable: ${ev.reason || 'unknown'}</span>`;
|
||||
const now = ev.current && (ev.current.class || ev.current.title)
|
||||
? `${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} ${health}</div>
|
||||
<ul class="buckets">${rows}</ul>
|
||||
<div class="switches">Context switches: ${ev.switch_count || 0}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function updateActiveDrift(drift) {
|
||||
const el = document.getElementById('drift');
|
||||
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>`;
|
||||
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 = `<div class="nudge">
|
||||
<div class="nudge-title">Heads up</div>
|
||||
<div class="nudge-msg">${nudge}</div>
|
||||
<div class="nudge-actions">
|
||||
<button id="dismissnudge" type="button">Dismiss</button>
|
||||
</div></div>`;
|
||||
document.getElementById('dismissnudge').onclick = () => {
|
||||
dismissedNudge = nudge;
|
||||
const e = document.getElementById('drift');
|
||||
if (e) e.innerHTML = '';
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (status === 'pending') {
|
||||
el.innerHTML = `<div class="drift-hint">checking focus…</div>`;
|
||||
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 = `<span class="pill">Locked</span>
|
||||
<p class="meta">No active commitment.</p>
|
||||
<button id="plan">Start planning</button>`;
|
||||
document.getElementById('plan').onclick = () => post('/planning');
|
||||
} else if (rs === 'planning') {
|
||||
view.innerHTML = `<span class="pill">Planning</span>
|
||||
<label>Rough intent</label>
|
||||
<input id="intent" placeholder="e.g. work on the quarterly report">
|
||||
<button id="sharpen" type="button">Sharpen</button>
|
||||
<div id="coachStatus" class="meta"></div>
|
||||
<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>`;
|
||||
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 = `<span class="pill">Active</span>
|
||||
<div id="drift"></div>
|
||||
<div class="timer" id="t">--:--</div>
|
||||
<div class="action">${c.next_action || ''}</div>
|
||||
<p class="meta">Done when: ${c.success_condition || ''}</p>
|
||||
${evidenceBlock(state.evidence)}
|
||||
<button id="done">Complete</button>`;
|
||||
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 = `<span class="pill">Review</span>
|
||||
<p class="action">Session ended</p>
|
||||
<p class="meta">${c.next_action || ''}</p>
|
||||
${evidenceBlock(state.evidence)}
|
||||
<button id="end">End</button>`;
|
||||
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?'; };
|
||||
@@ -4,227 +4,13 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AntiDrift</title>
|
||||
<style>
|
||||
: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; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<main id="app">
|
||||
<h1>AntiDrift</h1>
|
||||
<div class="card" id="view">connecting…</div>
|
||||
</main>
|
||||
<script>
|
||||
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
|
||||
? `<span class="health-ok">tracking</span>`
|
||||
: `<span class="health-bad">evidence unavailable: ${ev.reason || 'unknown'}</span>`;
|
||||
const now = ev.current && (ev.current.class || ev.current.title)
|
||||
? `${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} ${health}</div>
|
||||
<ul class="buckets">${rows}</ul>
|
||||
<div class="switches">Context switches: ${ev.switch_count || 0}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function updateActiveDrift(drift) {
|
||||
const el = document.getElementById('drift');
|
||||
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>`;
|
||||
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 = `<div class="nudge">
|
||||
<div class="nudge-title">Heads up</div>
|
||||
<div class="nudge-msg">${nudge}</div>
|
||||
<div class="nudge-actions">
|
||||
<button id="dismissnudge" type="button">Dismiss</button>
|
||||
</div></div>`;
|
||||
document.getElementById('dismissnudge').onclick = () => {
|
||||
dismissedNudge = nudge;
|
||||
const e = document.getElementById('drift');
|
||||
if (e) e.innerHTML = '';
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (status === 'pending') {
|
||||
el.innerHTML = `<div class="drift-hint">checking focus…</div>`;
|
||||
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 = `<span class="pill">Locked</span>
|
||||
<p class="meta">No active commitment.</p>
|
||||
<button id="plan">Start planning</button>`;
|
||||
document.getElementById('plan').onclick = () => post('/planning');
|
||||
} else if (rs === 'planning') {
|
||||
view.innerHTML = `<span class="pill">Planning</span>
|
||||
<label>Rough intent</label>
|
||||
<input id="intent" placeholder="e.g. work on the quarterly report">
|
||||
<button id="sharpen" type="button">Sharpen</button>
|
||||
<div id="coachStatus" class="meta"></div>
|
||||
<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>`;
|
||||
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 = `<span class="pill">Active</span>
|
||||
<div id="drift"></div>
|
||||
<div class="timer" id="t">--:--</div>
|
||||
<div class="action">${c.next_action || ''}</div>
|
||||
<p class="meta">Done when: ${c.success_condition || ''}</p>
|
||||
${evidenceBlock(state.evidence)}
|
||||
<button id="done">Complete</button>`;
|
||||
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 = `<span class="pill">Review</span>
|
||||
<p class="action">Session ended</p>
|
||||
<p class="meta">${c.next_action || ''}</p>
|
||||
${evidenceBlock(state.evidence)}
|
||||
<button id="end">End</button>`;
|
||||
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?'; };
|
||||
</script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -44,6 +44,12 @@ func (s *Server) Router() *gin.Engine {
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.FileFromFS("/", http.FS(sub))
|
||||
})
|
||||
r.GET("/app.css", func(c *gin.Context) {
|
||||
c.FileFromFS("/app.css", http.FS(sub))
|
||||
})
|
||||
r.GET("/app.js", func(c *gin.Context) {
|
||||
c.FileFromFS("/app.js", http.FS(sub))
|
||||
})
|
||||
r.GET("/events", s.handleEvents)
|
||||
r.POST("/planning", s.handlePlanning)
|
||||
r.POST("/coach", s.handleCoach)
|
||||
|
||||
@@ -203,6 +203,33 @@ func TestActiveStatePayloadCarriesNudge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServesStaticAssets(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
r := s.Router()
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
ctSubstr string
|
||||
}{
|
||||
{"/app.css", "css"},
|
||||
{"/app.js", "javascript"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("GET %s code = %d, want 200", tc.path, w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, tc.ctSubstr) {
|
||||
t.Errorf("GET %s Content-Type = %q, want substring %q", tc.path, ct, tc.ctSubstr)
|
||||
}
|
||||
if w.Body.Len() == 0 {
|
||||
t.Errorf("GET %s body is empty", tc.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
r := s.Router()
|
||||
|
||||
Reference in New Issue
Block a user