Skip to content
← Back

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.

Sources

How did this lesson go? Give feedback →