Lesson 4 of 6
Implement, inspect the diff, and troubleshoot locally
Implement the agreed FEATURE.md, map every git diff hunk to a criterion, verify by hand, and fix the keyboard-focus defect that re-render causes.
Commit the starter, then build from the plan
Put the project under Git so you can see and undo every change:
cd ~/projects/task-tracker
git init
printf 'node_modules/\n.dev.vars*\n.env*\n' > .gitignore
git add -A
git commit -m "Starter task tracker before the done feature"
Press Tab until OpenCode is in Build mode (not Plan). Paste:
The plan is approved. Implement it now.
Change only public/index.html, public/styles.css and public/app.js.
Follow FEATURE.md exactly: AC1–AC6.
Keep localStorage key task-tracker.tasks.v1; treat missing done as false.
No dependency, framework, network call, or API key.
After edits, run git diff and list which criterion each change serves.
Do not claim a criterion passes until you show how to check it by hand.
Every change should serve a criterion. If the diff contains something you cannot map to AC1–AC6, ask for it to be reverted. /undo reverses the last agent change.
Read git diff like a reviewer
The diff is the truth about what happened:
git status
git diff --stat
git diff
- lines were removed, + lines were added. Stop if you see: a new fetch(...) or third-party script; a token in localStorage; a changed storage key (existing tasks will look lost); JSON.parse without try/catch; a new npm dependency. The old text node in render() must be removed when the checkbox appears — if both remain, the task text shows twice. You catch that in the diff, not in the agent's summary.
Working feature code you can type if the agent drifts
public/index.html stays as in Lesson 2. The checkbox is created in JavaScript. Replace public/app.js with this file if you need a known-good implementation. load() keeps the key and treats missing done as false. After toggle, focus returns to the same checkbox.
const STORAGE_KEY = 'task-tracker.tasks.v1';
const form = document.querySelector('#add-form');
const input = document.querySelector('#new-task');
const list = document.querySelector('#task-list');
const count = document.querySelector('#count');
let tasks = load();
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return [];
return parsed
.filter((task) => task && typeof task.text === 'string')
.map((task) => ({ id: task.id, text: task.text, done: task.done === true }));
} catch (error) {
console.warn('Could not read saved tasks:', error);
return [];
}
}
function save() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}
function render() {
list.replaceChildren(
...tasks.map((task) => {
const item = document.createElement('li');
item.classList.toggle('done', task.done);
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.checked = task.done;
toggle.dataset.id = task.id;
toggle.setAttribute('aria-label', `Mark ${task.text} as done`);
toggle.addEventListener('change', () => {
tasks = tasks.map((entry) =>
entry.id === task.id ? { ...entry, done: toggle.checked } : entry
);
save();
render();
list.querySelector(`input[data-id="${task.id}"]`)?.focus();
});
const remove = document.createElement('button');
remove.type = 'button';
remove.textContent = 'Delete';
remove.setAttribute('aria-label', `Delete task: ${task.text}`);
remove.addEventListener('click', () => {
tasks = tasks.filter((entry) => entry.id !== task.id);
save();
render();
});
const text = document.createElement('span');
text.className = 'task-text';
text.textContent = task.text;
const left = document.createElement('span');
left.className = 'task-main';
left.append(toggle, text);
if (task.done) {
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = 'Done';
left.append(badge);
}
item.append(left, remove);
return item;
})
);
const open = tasks.filter((task) => !task.done).length;
count.textContent =
tasks.length === 0 ? 'No tasks yet.' : `${open} open of ${tasks.length}`;
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text) return;
tasks = [...tasks, { id: crypto.randomUUID(), text, done: false }];
save();
render();
form.reset();
input.focus();
});
render();
Append this to public/styles.css (keep Lesson 2 rules). Strikethrough is a second cue; the Done badge is visible text:
li { min-height: 44px; }
.task-main {
display: flex;
gap: 0.6rem;
align-items: center;
min-width: 0;
}
.task-text { overflow-wrap: anywhere; }
.task-main input[type='checkbox'] {
width: 1.25rem;
height: 1.25rem;
flex: none;
}
li.done .task-text {
text-decoration: line-through;
color: var(--muted);
}
.badge {
flex: none;
padding: 0.05rem 0.45rem;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 0.75rem;
color: var(--muted);
}
The defect you will hit: lost keyboard focus
render() rebuilds the list. When a keyboard user presses Space, the old checkbox is destroyed and focus falls back to <body>, so the next Tab starts over. That fails AC6 while looking fine with a mouse.
The fix is already in the change handler above:
render();
list.querySelector(`input[data-id="${task.id}"]`)?.focus();
Verify: Tab to a checkbox, press Space, confirm the focus ring stays on that task, then Space again. This is a minimal fix for a small app. A larger app would keep the node or use event delegation. Say that honestly rather than calling this ideal architecture.
If a criterion fails, send a small report: which AC, steps, expected, actual, and relevant function. Do not restart the whole feature.
Verify each criterion by hand on localhost
Serve public/ as in Lesson 2. Work the list; record a result per row. AC1: inspect a checkbox accessible name. AC2: tick — strikethrough and Done. AC3: three tasks, one done → 2 open of 3; still correct after delete. AC4: reload keeps states. AC5: untick once; row count unchanged. AC6: Tab, Space, visible ring, focus stays. Console: zero errors.
git add -A
git commit -m "Add done state and open count"
A passing mouse demo that logs an exception is not finished. Do not skip AC6 because the pointer looked fine.