Compare commits
7 Commits
19654c2b57
...
6b2a96620c
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b2a96620c | |||
| 51cb09c520 | |||
| f5da12362a | |||
| 76816ae188 | |||
| 93e779348c | |||
| c807a72623 | |||
| 097678e839 |
@@ -13,6 +13,7 @@ import (
|
|||||||
"antidrift/internal/ai"
|
"antidrift/internal/ai"
|
||||||
"antidrift/internal/evidence"
|
"antidrift/internal/evidence"
|
||||||
"antidrift/internal/session"
|
"antidrift/internal/session"
|
||||||
|
"antidrift/internal/statusfile"
|
||||||
"antidrift/internal/store"
|
"antidrift/internal/store"
|
||||||
"antidrift/internal/web"
|
"antidrift/internal/web"
|
||||||
)
|
)
|
||||||
@@ -45,6 +46,15 @@ func main() {
|
|||||||
log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name())
|
log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirror runtime status to ~/.antidrift_status for a window-manager bar.
|
||||||
|
// A misconfigured home dir degrades to no status file, not a failed start.
|
||||||
|
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
||||||
|
log.Printf("status file disabled: %v", err)
|
||||||
|
} else {
|
||||||
|
go statusfile.NewWriter(statusPath, ctrl.State).Run(context.Background())
|
||||||
|
log.Printf("status: writing %s", statusPath)
|
||||||
|
}
|
||||||
|
|
||||||
src := evidence.NewSource()
|
src := evidence.NewSource()
|
||||||
go src.Watch(context.Background(), ctrl.RecordWindow)
|
go src.Watch(context.Background(), ctrl.RecordWindow)
|
||||||
|
|
||||||
|
|||||||
@@ -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 `<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-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` (`<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`:
|
||||||
|
|
||||||
|
```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 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 `<script>…</script>` 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
|
||||||
|
<!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 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 <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:
|
||||||
|
|
||||||
|
```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
|
||||||
|
? `<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} ${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:
|
||||||
|
|
||||||
|
```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 <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`:
|
||||||
|
|
||||||
|
```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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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**
|
||||||
|
|
||||||
|
```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 <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.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# M4 — "Look good" Design
|
||||||
|
|
||||||
|
**Goal:** A real design pass on the web UI: a cockpit-style, state-aware HUD that
|
||||||
|
reads at a glance, with CSS/JS split out of the inline HTML for maintainability,
|
||||||
|
and a polished review recap. No behavior changes.
|
||||||
|
|
||||||
|
**Status:** Design approved 2026-05-31. Supersedes the utilitarian inline UI
|
||||||
|
shipped through M3.5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Direction & Visual System
|
||||||
|
|
||||||
|
The UI is an **instrument panel you glance at** — a cockpit, not a document.
|
||||||
|
Dark, near-black cool-neutral base. State is carried by a **single state-driven
|
||||||
|
accent**: a CSS custom property `--accent` switched by a `data-state` attribute
|
||||||
|
on the `<main>` element. The accent colors the status band's top border and the
|
||||||
|
state pill, so the frame itself communicates where you are without reading text.
|
||||||
|
|
||||||
|
### State → accent mapping
|
||||||
|
|
||||||
|
| State | `data-state` | Accent |
|
||||||
|
|--------------------|--------------|--------------------|
|
||||||
|
| locked | `locked` | dim gray |
|
||||||
|
| planning | `planning` | blue |
|
||||||
|
| active · on-task | `active` | calm green / cyan |
|
||||||
|
| active · nudge | `nudge` | amber |
|
||||||
|
| active · drifting | `drift` | red |
|
||||||
|
| review | `review` | violet-neutral |
|
||||||
|
|
||||||
|
`data-state` is derived in the client render from `runtime_state` plus, when
|
||||||
|
active, the drift sub-status (`drifting` → `drift`; a present `nudge` → `nudge`;
|
||||||
|
otherwise `active`). This mirrors the precedence already used by the status-file
|
||||||
|
renderer (drift outranks nudge).
|
||||||
|
|
||||||
|
### Design tokens (CSS custom properties)
|
||||||
|
|
||||||
|
- Surfaces / text: `--bg`, `--panel`, `--line`, `--ink`, `--ink-dim`
|
||||||
|
- State accent: `--accent` (the only variable that changes with `data-state`)
|
||||||
|
- Fixed semantic colors: `--ok`, `--warn`, `--danger`
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
- Timer: heavy weight, `font-variant-numeric: tabular-nums`.
|
||||||
|
- Evidence times: `ui-monospace` so the bucket columns align.
|
||||||
|
- Band headers / pills: small, uppercase, letter-spaced (keeps the existing pill
|
||||||
|
idiom from the current UI).
|
||||||
|
- Prose: `system-ui`.
|
||||||
|
|
||||||
|
## 2. Layout — Stacked HUD Bands
|
||||||
|
|
||||||
|
Every state composes the same **band primitive**: a row with a top divider and
|
||||||
|
consistent horizontal/vertical padding. Stacking bands produces the layered HUD
|
||||||
|
look. The active session follows the approved sketch:
|
||||||
|
|
||||||
|
```
|
||||||
|
ACTIVE · on task · 7 switches ← status band (accent border-top + pill)
|
||||||
|
24:18 write the spec section ← timer band
|
||||||
|
done when: draft saved ← task band
|
||||||
|
now code·spec ● | code 18:02 … ← evidence band
|
||||||
|
[ Complete ] ← action band
|
||||||
|
```
|
||||||
|
|
||||||
|
Drift and nudge are **not** a separate floating box. When the session drifts or
|
||||||
|
is nudged, the **status band itself** changes copy and `data-state` flips, so the
|
||||||
|
whole frame goes amber/red. The same controls render inside that band:
|
||||||
|
|
||||||
|
- Drift: `Back to task` (`/refocus`), `This is on task` (`/ontask`),
|
||||||
|
`End session` (`/complete`).
|
||||||
|
- Nudge: `Dismiss` (client-only, current behavior).
|
||||||
|
- Pending: a quiet "checking focus…" line.
|
||||||
|
|
||||||
|
## 3. Per-State Treatment
|
||||||
|
|
||||||
|
- **Locked:** one dim band, large `Start planning` button (`/planning`).
|
||||||
|
- **Planning:** an intent + `Sharpen` band, then field bands — Next action,
|
||||||
|
Success condition, Minutes, Allowed apps — with the blue accent. All existing
|
||||||
|
input ids (`#intent`, `#na`, `#sc`, `#mins`, `#apps`, `#start`,
|
||||||
|
`#coachStatus`) and the coach pre-fill behavior are untouched.
|
||||||
|
- **Active:** the HUD described in §2.
|
||||||
|
- **Review (polished, presentational only):** summary bands built from data the
|
||||||
|
state already carries — `next_action`, `success_condition`, the context-switch
|
||||||
|
count, and the per-window bucket recap (reusing the existing `evidence`
|
||||||
|
fields). **No new backend data** is introduced; richer session reflection is
|
||||||
|
M7's job. The `End` button (`/end`) remains.
|
||||||
|
|
||||||
|
## 4. Structure
|
||||||
|
|
||||||
|
Split the single inline file into three files under `internal/web/static/`:
|
||||||
|
|
||||||
|
- `index.html` — markup shell only (`<head>` links the stylesheet and script).
|
||||||
|
- `app.css` — the full visual system (tokens, bands, per-state rules).
|
||||||
|
- `app.js` — the render logic, **moved verbatim**: same `render()` function,
|
||||||
|
same partial-update paths (`updateActiveDrift`, `updatePlanningCoach`), same
|
||||||
|
element ids, same `EventSource('/events')` and POST endpoints. The only
|
||||||
|
additions are the band markup in the template strings and setting
|
||||||
|
`main.dataset.state` per render.
|
||||||
|
|
||||||
|
`web.go` currently serves only `/` via `c.FileFromFS`. Add routes so the two new
|
||||||
|
assets are served from the embedded `staticFS`:
|
||||||
|
|
||||||
|
- `GET /app.css` → `static/app.css`
|
||||||
|
- `GET /app.js` → `static/app.js`
|
||||||
|
|
||||||
|
No new Go dependencies, no JavaScript build step, no framework. The embedded
|
||||||
|
static directory and the conciseness/token-efficiency ethos of the Go rewrite
|
||||||
|
are preserved.
|
||||||
|
|
||||||
|
## 5. Behavior & Data Flow — Unchanged
|
||||||
|
|
||||||
|
Same SSE stream, same partial-update `render()` logic, same element ids, same
|
||||||
|
POST endpoints, same server-authoritative expiry timer. The redesign is markup +
|
||||||
|
CSS + asset routing only. This is precisely what keeps the existing
|
||||||
|
`web_test.go` (endpoint and state-JSON assertions, markup-agnostic) green.
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
- **Existing `web_test.go` stays green.** It asserts on endpoint status codes and
|
||||||
|
the state JSON, not on HTML markup, so the visual rework does not touch it.
|
||||||
|
- **New Go test:** `GET /app.css` and `GET /app.js` each return `200` with the
|
||||||
|
correct `Content-Type` (`text/css`, `text/javascript` / `application/javascript`).
|
||||||
|
Asserted against the router via `httptest`, stdlib `testing` only.
|
||||||
|
- **Manual visual checklist** across the six `data-state` values: locked,
|
||||||
|
planning, active (on-task), active (nudge), active (drift), review. There is no
|
||||||
|
JavaScript test harness in this Go project; the rendering is presentational and
|
||||||
|
verified by eye, consistent with the existing approach.
|
||||||
|
|
||||||
|
## 7. Out of Scope
|
||||||
|
|
||||||
|
- Micro-interactions / motion (timer easing, accent transitions, panel slide-in)
|
||||||
|
— explicitly excluded for M4; can be a later pass.
|
||||||
|
- Any new backend data or fields on the state payload.
|
||||||
|
- M7 reflection content (real session summary, time-on-task analytics). The M4
|
||||||
|
review screen is presentational recap of already-available data only.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Package statusfile mirrors the controller's runtime status into a single-line
|
||||||
|
// file (~/.antidrift_status) so an external consumer — a window-manager status
|
||||||
|
// bar — can display it with a plain file read.
|
||||||
|
package statusfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"antidrift/internal/domain"
|
||||||
|
"antidrift/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// statusFileName is the file written under the user's home directory.
|
||||||
|
const statusFileName = ".antidrift_status"
|
||||||
|
|
||||||
|
// interval is how often the writer re-renders. Minute granularity is enough for
|
||||||
|
// a status bar, so a coarse tick keeps the write rate (and disk churn) low.
|
||||||
|
const interval = time.Minute
|
||||||
|
|
||||||
|
// DefaultPath resolves ~/.antidrift_status.
|
||||||
|
func DefaultPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, statusFileName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render produces the single status line for the given state. It is empty for
|
||||||
|
// Locked/idle so a bar module can hide itself. Drift status strings ("drifting")
|
||||||
|
// match the JSON contract the web UI already consumes.
|
||||||
|
func Render(st session.State, now time.Time) string {
|
||||||
|
switch st.RuntimeState {
|
||||||
|
case domain.RuntimeActive:
|
||||||
|
timer := remaining(st, now)
|
||||||
|
switch {
|
||||||
|
case st.Drift != nil && st.Drift.Status == "drifting":
|
||||||
|
return "⚠ DRIFT " + timer
|
||||||
|
case st.Drift != nil && st.Drift.Nudge != "":
|
||||||
|
return "● " + timer + " ·?"
|
||||||
|
default:
|
||||||
|
return "● " + timer
|
||||||
|
}
|
||||||
|
case domain.RuntimePlanning:
|
||||||
|
return "◔ planning"
|
||||||
|
case domain.RuntimeReview:
|
||||||
|
return "✓ review"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remaining formats the minutes left until the commitment deadline, rounded up
|
||||||
|
// so a partial minute still reads as a minute and the count hits 0m only at the
|
||||||
|
// end. It returns 0m when no deadline is known.
|
||||||
|
func remaining(st session.State, now time.Time) string {
|
||||||
|
if st.Commitment == nil || st.Commitment.DeadlineUnixSecs == 0 {
|
||||||
|
return "0m"
|
||||||
|
}
|
||||||
|
left := time.Unix(st.Commitment.DeadlineUnixSecs, 0).Sub(now)
|
||||||
|
if left < 0 {
|
||||||
|
left = 0
|
||||||
|
}
|
||||||
|
mins := int((left + time.Minute - 1) / time.Minute) // ceil
|
||||||
|
return fmt.Sprintf("%dm", mins)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writer periodically renders the controller state and writes it to a file,
|
||||||
|
// rewriting only when the line changes.
|
||||||
|
type Writer struct {
|
||||||
|
path string
|
||||||
|
state func() session.State
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
last string
|
||||||
|
wrote bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWriter builds a Writer for path, reading state via the given accessor.
|
||||||
|
func NewWriter(path string, state func() session.State) *Writer {
|
||||||
|
return &Writer{path: path, state: state, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run writes the status file immediately, then on every tick when the rendered
|
||||||
|
// line has changed. It removes the file on ctx cancellation so a stale status
|
||||||
|
// does not linger after shutdown.
|
||||||
|
func (w *Writer) Run(ctx context.Context) {
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
w.write()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = os.Remove(w.path)
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
w.write()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Writer) write() {
|
||||||
|
line := Render(w.state(), w.now())
|
||||||
|
if w.wrote && line == w.last {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(w.path, []byte(line), 0o644); err != nil {
|
||||||
|
log.Printf("statusfile: write %s: %v", w.path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.last = line
|
||||||
|
w.wrote = true
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package statusfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"antidrift/internal/domain"
|
||||||
|
"antidrift/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func activeState(deadline int64, driftStatus, nudge string) session.State {
|
||||||
|
st := session.State{
|
||||||
|
RuntimeState: domain.RuntimeActive,
|
||||||
|
Commitment: &session.CommitmentView{DeadlineUnixSecs: deadline},
|
||||||
|
}
|
||||||
|
if driftStatus != "" || nudge != "" {
|
||||||
|
st.Drift = &session.DriftView{Status: driftStatus, Nudge: nudge}
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender(t *testing.T) {
|
||||||
|
now := time.Unix(1_000_000, 0)
|
||||||
|
in24m := now.Add(24 * time.Minute).Unix()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
st session.State
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"locked", session.State{RuntimeState: domain.RuntimeLocked}, ""},
|
||||||
|
{"empty runtime", session.State{}, ""},
|
||||||
|
{"planning", session.State{RuntimeState: domain.RuntimePlanning}, "◔ planning"},
|
||||||
|
{"review", session.State{RuntimeState: domain.RuntimeReview}, "✓ review"},
|
||||||
|
{"active ontask", activeState(in24m, "ontask", ""), "● 24m"},
|
||||||
|
{"active no drift view", activeState(in24m, "", ""), "● 24m"},
|
||||||
|
{"active drifting", activeState(in24m, "drifting", ""), "⚠ DRIFT 24m"},
|
||||||
|
{"active nudge", activeState(in24m, "ontask", "wandered off"), "● 24m ·?"},
|
||||||
|
{"drift outranks nudge", activeState(in24m, "drifting", "wandered off"), "⚠ DRIFT 24m"},
|
||||||
|
{"active no deadline", session.State{RuntimeState: domain.RuntimeActive}, "● 0m"},
|
||||||
|
{"active past deadline", activeState(now.Add(-5 * time.Minute).Unix(), "ontask", ""), "● 0m"},
|
||||||
|
{"active partial minute rounds up", activeState(now.Add(10*time.Second).Unix(), "ontask", ""), "● 1m"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := Render(tc.st, now); got != tc.want {
|
||||||
|
t.Errorf("Render() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriterWritesAndDedups(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
|
now := time.Unix(1_000_000, 0)
|
||||||
|
state := session.State{RuntimeState: domain.RuntimePlanning}
|
||||||
|
w := NewWriter(path, func() session.State { return state })
|
||||||
|
w.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
w.write()
|
||||||
|
if got := readFile(t, path); got != "◔ planning" {
|
||||||
|
t.Fatalf("first write = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same state: file untouched. Detect by clearing it and confirming the
|
||||||
|
// deduped write does not recreate content.
|
||||||
|
if err := os.WriteFile(path, []byte("sentinel"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.write()
|
||||||
|
if got := readFile(t, path); got != "sentinel" {
|
||||||
|
t.Errorf("deduped write should not rewrite, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed state: rewrites.
|
||||||
|
state = session.State{RuntimeState: domain.RuntimeLocked}
|
||||||
|
w.write()
|
||||||
|
if got := readFile(t, path); got != "" {
|
||||||
|
t.Errorf("changed write = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriterRemovesFileOnCancel(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
|
w := NewWriter(path, func() session.State {
|
||||||
|
return session.State{RuntimeState: domain.RuntimePlanning}
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { w.Run(ctx); close(done) }()
|
||||||
|
|
||||||
|
// Wait for the immediate write.
|
||||||
|
waitFor(t, func() bool { _, err := os.Stat(path); return err == nil })
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("file should be removed on cancel, stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFile(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitFor(t *testing.T, cond func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if cond() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("condition not met within timeout")
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
: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 */
|
||||||
|
.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; }
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
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} ${health}</div>
|
||||||
|
<ul class="buckets">${rows}</ul>
|
||||||
|
<div class="switches">context switches: ${ev.switch_count || 0}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
<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>`;
|
||||||
|
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 charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>AntiDrift</title>
|
<title>AntiDrift</title>
|
||||||
<style>
|
<link rel="stylesheet" href="/app.css">
|
||||||
: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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main id="app">
|
||||||
<h1>AntiDrift</h1>
|
<h1>AntiDrift</h1>
|
||||||
<div class="card" id="view">connecting…</div>
|
<div class="card" id="view">connecting…</div>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script src="/app.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ func (s *Server) Router() *gin.Engine {
|
|||||||
r.GET("/", func(c *gin.Context) {
|
r.GET("/", func(c *gin.Context) {
|
||||||
c.FileFromFS("/", http.FS(sub))
|
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.GET("/events", s.handleEvents)
|
||||||
r.POST("/planning", s.handlePlanning)
|
r.POST("/planning", s.handlePlanning)
|
||||||
r.POST("/coach", s.handleCoach)
|
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) {
|
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
|
||||||
s := newTestServer(t)
|
s := newTestServer(t)
|
||||||
r := s.Router()
|
r := s.Router()
|
||||||
|
|||||||
Reference in New Issue
Block a user