diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 3bbbf95..12c5da6 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -1,33 +1,109 @@ - :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; } +: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; } diff --git a/internal/web/static/app.js b/internal/web/static/app.js index e128960..6b37ece 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -1,3 +1,4 @@ +const app = document.getElementById('app'); const view = document.getElementById('view'); let countdownTimer = null; let renderedState = null; // which runtime_state the DOM currently shows @@ -18,6 +19,21 @@ function fmt(secs) { 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 @@ -27,52 +43,63 @@ function evidenceBlock(ev) { ? `${ev.current.class || '?'} · ${ev.current.title || ''}` : '—'; const rows = (ev.buckets || []).map(b => `
  • ${(b.class || '?')} · ${b.title || ''}${fmt(b.seconds)}
  • `).join(''); - return `
    -
    Now: ${now}   ${health}
    + return `
    +
    now ${now}   ${health}
    -
    Context switches: ${ev.switch_count || 0}
    +
    context switches: ${ev.switch_count || 0}
    `; } -function updateActiveDrift(drift) { - const el = document.getElementById('drift'); +// 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; - const status = drift && drift.status; - if (status === 'drifting') { - el.innerHTML = `
    -
    ⚠ Possible drift
    -
    ${drift.reason || ''}
    -
    - - - -
    `; + setStateAttr(state); + const drift = state.drift || {}; + const switches = (state.evidence && state.evidence.switch_count) || 0; + + if (drift.status === 'drifting') { + el.className = 'band statusband col'; + el.innerHTML = `
    Drift
    +
    ${drift.reason || 'This looks off task.'}
    +
    + + + +
    `; document.getElementById('refocus').onclick = () => post('/refocus'); document.getElementById('ontask').onclick = () => post('/ontask'); document.getElementById('enddrift').onclick = () => post('/complete'); return; } - const nudge = (drift && drift.nudge) || ''; + + const nudge = drift.nudge || ''; if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges if (nudge && nudge !== dismissedNudge) { - el.innerHTML = `
    -
    Heads up
    + el.className = 'band statusband col'; + el.innerHTML = `
    Heads up
    ${nudge}
    -
    - -
    `; +
    + +
    `; document.getElementById('dismissnudge').onclick = () => { dismissedNudge = nudge; - const e = document.getElementById('drift'); - if (e) e.innerHTML = ''; + setStateAttr(state); + updateActiveDrift(state); }; return; } - if (status === 'pending') { - el.innerHTML = `
    checking focus…
    `; + + if (drift.status === 'pending') { + el.className = 'band statusband'; + el.innerHTML = `Activechecking focus…`; return; } - el.innerHTML = ''; + + el.className = 'band statusband'; + el.innerHTML = `Active + on task · ${switches} switches`; } function updatePlanningCoach(coach) { @@ -83,8 +110,6 @@ function updatePlanningCoach(coach) { 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; @@ -102,34 +127,43 @@ function updatePlanningCoach(coach) { } function render(state) { + setStateAttr(state); const rs = state.runtime_state; if (rs === 'planning' && renderedState === 'planning') { - updatePlanningCoach(state.coach); // partial update only + updatePlanningCoach(state.coach); return; } if (rs === 'active' && renderedState === 'active') { - updateActiveDrift(state.drift); + updateActiveDrift(state); return; } if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } renderedState = rs; + if (rs === 'locked') { - view.innerHTML = `Locked -

    No active commitment.

    - `; + view.innerHTML = `
    Locked
    +
    +

    No active commitment.

    + +
    `; document.getElementById('plan').onclick = () => post('/planning'); + } else if (rs === 'planning') { - view.innerHTML = `Planning - - - -
    - - - - - - `; + view.innerHTML = `
    Planning
    +
    + + + +
    +
    +
    + + + + + + +
    `; const na = document.getElementById('na'), sc = document.getElementById('sc'), mins = document.getElementById('mins'), start = document.getElementById('start'); const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); }; @@ -146,29 +180,35 @@ function render(state) { if (intent) post('/coach', { intent }); }; updatePlanningCoach(state.coach); + } else if (rs === 'active') { const c = state.commitment || {}; - view.innerHTML = `Active -
    -
    --:--
    -
    ${c.next_action || ''}
    -

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

    + view.innerHTML = `
    +
    --:--
    +
    +
    ${c.next_action || ''}
    +

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

    +
    ${evidenceBlock(state.evidence)} - `; +
    `; document.getElementById('done').onclick = () => post('/complete'); const t = document.getElementById('t'); const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); }; tick(); countdownTimer = setInterval(tick, 250); - updateActiveDrift(state.drift); + updateActiveDrift(state); + } else if (rs === 'review') { const c = state.commitment || {}; - view.innerHTML = `Review -

    Session ended

    -

    ${c.next_action || ''}

    + view.innerHTML = `
    Review
    +
    +
    Session ended
    +

    ${c.next_action || ''}

    +
    ${evidenceBlock(state.evidence)} - `; +
    `; document.getElementById('end').onclick = () => post('/end'); + } else { view.textContent = rs; }