From 76816ae18835ca9a3610a30a3961d4359fd5232c Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 21:05:34 -0400 Subject: [PATCH] M4: split web UI assets into app.css and app.js Co-Authored-By: Claude Opus 4.8 --- internal/web/static/app.css | 33 +++++ internal/web/static/app.js | 179 +++++++++++++++++++++++++++ internal/web/static/index.html | 220 +-------------------------------- internal/web/web.go | 6 + internal/web/web_test.go | 27 ++++ 5 files changed, 248 insertions(+), 217 deletions(-) create mode 100644 internal/web/static/app.css create mode 100644 internal/web/static/app.js 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}
    +
      ${rows}
    +
    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…
    - + diff --git a/internal/web/web.go b/internal/web/web.go index 70af19f..f887b23 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -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) diff --git a/internal/web/web_test.go b/internal/web/web_test.go index a39add3..488babb 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -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()