From 93e779348ccde530565a0594ed3765d989ad5640 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 21:03:34 -0400 Subject: [PATCH] M4: add look-good implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-05-31-m4-look-good.md | 625 ++++++++++++++++++ 1 file changed, 625 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-31-m4-look-good.md diff --git a/docs/superpowers/plans/2026-05-31-m4-look-good.md b/docs/superpowers/plans/2026-05-31-m4-look-good.md new file mode 100644 index 0000000..ce75cb8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-m4-look-good.md @@ -0,0 +1,625 @@ +# M4 — "Look good" Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restyle the AntiDrift web UI into a cockpit-style, state-aware stacked HUD, split CSS/JS out of the inline HTML, and polish the review screen — with zero behavior changes. + +**Architecture:** The UI is a single embedded static bundle served by `internal/web`. Today everything lives inline in `static/index.html`. We split it into `index.html` + `app.css` + `app.js`, add Go routes to serve the two new assets from the existing `embed.FS`, then rework the client markup into reusable "band" rows whose accent color is driven by a `data-state` attribute on `
`. All SSE wiring, element ids, and POST endpoints are preserved, so the existing Go endpoint tests keep passing. + +**Tech Stack:** Go 1.26, Gin, `embed.FS`, server-sent events; plain HTML/CSS/vanilla JS (no framework, no build step). Tests: stdlib `testing` + `net/http/httptest`. + +--- + +## Reference: shared CSS class vocabulary + +These class/attribute names are used by BOTH `app.css` and `app.js`. Keep them identical across tasks: + +- `
` — `data-state` ∈ `locked | planning | active | nudge | drift | review`. Drives `--accent`. +- `.band` — a horizontal HUD row (top divider + padding). `.band:first-child` has no top border. +- `.statusband` — the top band; shows the state pill + status meta, takes a thicker accent top border. +- `.pill` — uppercase state label, colored by `--accent`. +- `.status-meta` — dim text beside the pill (e.g. "on task · 7 switches"). +- `.timer` — large tabular-nums countdown. +- `.action` — the next-action line. `.meta` — dim secondary text (e.g. "done when: …"). +- `.evidence` (band) with children `.now`, `.buckets` (`
    `/`
  • `), `.switches`; health spans `.health-ok` / `.health-bad`. +- `.summary` (review band) with `.summary-row` children. +- Buttons: `.btn`, `.btn-primary`, `.btn-ghost`. +- The `data-state` value is derived by `stateKey(state)` (defined in Task 2), which accounts for the client-side `dismissedNudge`. + +Preserved element ids (do not rename): `#app`, `#intent`, `#sharpen`, `#coachStatus`, `#na`, `#sc`, `#mins`, `#apps`, `#start`, `#statusband`, `#t`, `#done`, `#end`, `#plan`, `#refocus`, `#ontask`, `#enddrift`, `#dismissnudge`. + +Preserved endpoints: `GET /events`; `POST /planning /coach /commitment /complete /end /refocus /ontask`. + +--- + +## Task 1: Split assets out of the inline HTML and serve them + +Mechanical, behavior-preserving: move the inline `` block in `internal/web/static/index.html` (everything between the tags, currently lines 8–41) and paste it unchanged into the new file `internal/web/static/app.css`. Do not edit the rules in this task — this is a pure move. (They are replaced wholesale in Task 2.) + +- [ ] **Step 4: Create `app.js` by moving the inline script verbatim** + +Cut the entire current contents of the `` block in `internal/web/static/index.html` (everything between the tags, currently lines 49–227) and paste it unchanged into the new file `internal/web/static/app.js`. + +- [ ] **Step 5: Replace `index.html` with the markup shell** + +Replace the whole file `internal/web/static/index.html` with: + +```html + + + + + +AntiDrift + + + +
    +

    AntiDrift

    +
    connecting…
    +
    + + + +``` + +Note: the script must reference `view` and (from Task 2 on) `app`; both ids exist here. The script tag is at the end of ``, so the DOM is parsed before it runs (matches the current inline-at-end-of-body behavior). + +- [ ] **Step 6: Add asset routes in `web.go`** + +In `internal/web/web.go`, the `Router()` method currently has (around lines 43–46): + +```go + sub, _ := fs.Sub(staticFS, "static") + r.GET("/", func(c *gin.Context) { + c.FileFromFS("/", http.FS(sub)) + }) +``` + +Add the two asset routes immediately after the `r.GET("/", …)` block: + +```go + 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)) + }) +``` + +- [ ] **Step 7: Run the asset test and the full web suite** + +Run: `go test ./internal/web/ -v` +Expected: PASS — `TestServesStaticAssets` passes and all pre-existing web tests still pass (they assert on endpoints/JSON, not markup). + +- [ ] **Step 8: Verify vet and the whole module** + +Run: `go vet ./... && go test -race ./...` +Expected: clean, all green. + +- [ ] **Step 9: Commit** + +```bash +git add internal/web/static/index.html internal/web/static/app.css internal/web/static/app.js internal/web/web.go internal/web/web_test.go +git commit -m "M4: split web UI assets into app.css and app.js + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 2: Cockpit visual system + locked/planning/active/drift/nudge + +Replace `app.css` with the new design system and rewrite `app.js`'s rendering into stacked bands with a `data-state`-driven accent. Review stays a simple functional band here; Task 3 turns it into the recap. No Go changes — the existing endpoint tests are the regression guard. + +**Files:** +- Modify: `internal/web/static/app.css` (replace whole file) +- Modify: `internal/web/static/app.js` (replace whole file) +- Test: existing `internal/web/web_test.go` (regression only — no new test) + +- [ ] **Step 1: Replace `app.css` with the cockpit design system** + +Replace the whole file `internal/web/static/app.css` with: + +```css +: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; } +``` + +- [ ] **Step 2: Replace `app.js` with the band-based renderer** + +Replace the whole file `internal/web/static/app.js` with: + +```js +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 ''; + 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}
    +
    `; +} + +// 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; + 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 = `
    Drift
    +
    ${drift.reason || 'This looks off task.'}
    +
    + + + +
    `; + document.getElementById('refocus').onclick = () => post('/refocus'); + document.getElementById('ontask').onclick = () => post('/ontask'); + document.getElementById('enddrift').onclick = () => post('/complete'); + return; + } + + const nudge = drift.nudge || ''; + if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges + if (nudge && nudge !== dismissedNudge) { + el.className = 'band statusband col'; + el.innerHTML = `
    Heads up
    +
    ${nudge}
    +
    + +
    `; + document.getElementById('dismissnudge').onclick = () => { + dismissedNudge = nudge; + setStateAttr(state); + updateActiveDrift(state); + }; + return; + } + + if (drift.status === 'pending') { + el.className = 'band statusband'; + el.innerHTML = `Activechecking focus…`; + return; + } + + el.className = 'band statusband'; + el.innerHTML = `Active + on task · ${switches} switches`; +} + +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.'; + 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) { + setStateAttr(state); + const rs = state.runtime_state; + if (rs === 'planning' && renderedState === 'planning') { + updatePlanningCoach(state.coach); + return; + } + if (rs === 'active' && renderedState === 'active') { + updateActiveDrift(state); + 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 = `
    +
    --:--
    +
    +
    ${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); + + } 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?'; }; +``` + +- [ ] **Step 3: Run the web suite (regression guard)** + +Run: `go test ./internal/web/ -v` +Expected: PASS — all endpoint/asset tests still green (markup changed, contracts did not). + +- [ ] **Step 4: Manual visual check** + +Run the daemon and click through states: + +```bash +go run ./cmd/antidriftd +``` + +Verify in the browser (http://localhost:7777): +- Locked: gray accent, "Start planning". +- Planning: blue accent, all fields, Sharpen + Start. +- Active on-task: green accent, "Active · on task · N switches", big timer, evidence band. +- Drift (force by setting allowed apps then switching to an off-list window): red accent, reason + three buttons. +- Nudge (semantic nudge within an allowed app): amber accent, message + Dismiss; after Dismiss the accent returns to green. + +- [ ] **Step 5: Commit** + +```bash +git add internal/web/static/app.css internal/web/static/app.js +git commit -m "M4: cockpit HUD with state-driven accent + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 3: Polish the review screen into a session recap + +Turn the bare review state into a presentational summary built from data the state already carries (`commitment` + `evidence`). No new backend data. + +**Files:** +- Modify: `internal/web/static/app.js` (add `reviewSummary` helper; swap the review branch's markup) +- Modify: `internal/web/static/app.css` (`.summary` styles already added in Task 2 — verify, no change expected) +- Test: existing `internal/web/web_test.go` (regression only) + +- [ ] **Step 1: Add the `reviewSummary` helper to `app.js`** + +Insert this function immediately after `evidenceBlock` in `internal/web/static/app.js`: + +```js +// 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 => + `
    ${(b.class || '?')} · ${b.title || ''}${fmt(b.seconds)}
    `).join(''); + const switches = `
    context switches${ev.switch_count || 0}
    `; + return `
    ${switches}${rows}
    `; +} +``` + +- [ ] **Step 2: Swap the review branch markup to use the recap** + +In `internal/web/static/app.js`, find the review branch (the `else if (rs === 'review')` block) and replace its `view.innerHTML = …` assignment. Change FROM: + +```js + view.innerHTML = `
    Review
    +
    +
    Session ended
    +

    ${c.next_action || ''}

    +
    + ${evidenceBlock(state.evidence)} +
    `; +``` + +TO: + +```js + view.innerHTML = `
    Review
    +
    +
    Session ended
    +

    ${c.next_action || ''}

    +

    done when: ${c.success_condition || ''}

    +
    + ${reviewSummary(state.evidence)} +
    `; +``` + +(The verbose live `evidenceBlock` is replaced by the calmer `reviewSummary` recap; the "now / tracking" line belongs to the active HUD, not the post-session summary.) + +- [ ] **Step 3: Run the web suite (regression guard)** + +Run: `go test ./internal/web/ -v` +Expected: PASS. + +- [ ] **Step 4: Manual visual check of review** + +Start a short commitment (1 minute) with allowed apps, switch between a couple of windows, let it expire (or click Complete), and confirm the review screen shows: violet accent, "Session ended", the action + success condition, then a summary band listing context switches and the per-window time buckets, then End. + +- [ ] **Step 5: Final module verification** + +Run: `go vet ./... && go test -race ./...` +Expected: clean, all green. + +- [ ] **Step 6: Commit** + +```bash +git add internal/web/static/app.js internal/web/static/app.css +git commit -m "M4: presentational review recap + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## After all tasks + +Dispatch a final code reviewer over the three commits, then use **superpowers:finishing-a-development-branch** to close out M4. + +Done when: the UI renders the cockpit HUD across all six `data-state` values, CSS/JS are served as separate embedded assets, the review screen shows a recap, and `go vet ./... && go test -race ./...` is clean.