Files
antidrift/docs/superpowers/plans/2026-05-31-m4-look-good.md
T
2026-05-31 21:03:34 -04:00

24 KiB
Raw Blame History

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 <main>. 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:

  • <main id="app" data-state="...">data-statelocked | 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 (<ul>/<li>), .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 <style>/<script> into app.css/app.js, link them, and add Go routes so they are served from the embedded FS. No visual or logic change yet.

Files:

  • Create: internal/web/static/app.css

  • Create: internal/web/static/app.js

  • Modify: internal/web/static/index.html (replace whole file)

  • Modify: internal/web/web.go:39-56 (add two asset routes)

  • Test: internal/web/web_test.go (add one test func)

  • Step 1: Write the failing test for asset serving

Add to internal/web/web_test.go:

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)
		}
	}
}
  • Step 2: Run the test to verify it fails

Run: go test ./internal/web/ -run TestServesStaticAssets -v Expected: FAIL — routes return 404 (and the embedded files don't exist yet).

  • Step 3: Create app.css by moving the inline styles verbatim

Cut the entire current contents of the <style>…</style> block in internal/web/static/index.html (everything between the tags, currently lines 841) 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 <script>…</script> block in internal/web/static/index.html (everything between the tags, currently lines 49227) 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:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AntiDrift</title>
<link rel="stylesheet" href="/app.css">
</head>
<body>
<main id="app">
  <h1>AntiDrift</h1>
  <div class="card" id="view">connecting…</div>
</main>
<script src="/app.js"></script>
</body>
</html>

Note: the script must reference view and (from Task 2 on) app; both ids exist here. The script tag is at the end of <body>, 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 4346):

	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:

	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
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 <noreply@anthropic.com>"

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:

: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:

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
    ? `<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="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>`;
}

// 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 = `<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.nudge || '';
  if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges
  if (nudge && nudge !== dismissedNudge) {
    el.className = 'band statusband col';
    el.innerHTML = `<div><span class="pill">Heads up</span></div>
      <div class="nudge-msg">${nudge}</div>
      <div class="band-actions">
        <button id="dismissnudge" type="button" class="btn btn-ghost">Dismiss</button>
      </div>`;
    document.getElementById('dismissnudge').onclick = () => {
      dismissedNudge = nudge;
      setStateAttr(state);
      updateActiveDrift(state);
    };
    return;
  }

  if (drift.status === 'pending') {
    el.className = 'band statusband';
    el.innerHTML = `<span class="pill">Active</span><span class="status-meta">checking focus…</span>`;
    return;
  }

  el.className = 'band statusband';
  el.innerHTML = `<span class="pill">Active</span>
    <span class="status-meta">on task · ${switches} switches</span>`;
}

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 = `<div class="band statusband"><span class="pill">Locked</span></div>
      <div class="band">
        <p class="meta">No active commitment.</p>
        <button id="plan" class="btn btn-primary">Start planning</button>
      </div>`;
    document.getElementById('plan').onclick = () => post('/planning');

  } else if (rs === 'planning') {
    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" 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" 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); };
    [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 = `<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>
      </div>
      ${evidenceBlock(state.evidence)}
      <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);

  } else if (rs === 'review') {
    const c = state.commitment || {};
    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)}
      <div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
    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:

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

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 <noreply@anthropic.com>"

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:

// 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 =>
    `<div class="summary-row"><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></div>`).join('');
  const switches = `<div class="summary-row"><span>context switches</span><span>${ev.switch_count || 0}</span></div>`;
  return `<div class="band summary">${switches}${rows}</div>`;
}
  • 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:

    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)}
      <div class="band"><button id="end" class="btn btn-primary">End</button></div>`;

TO:

    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>
        <p class="meta">done when: ${c.success_condition || ''}</p>
      </div>
      ${reviewSummary(state.evidence)}
      <div class="band"><button id="end" class="btn btn-primary">End</button></div>`;

(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
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 <noreply@anthropic.com>"

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.