Skip to content
← Back

Lesson 2 of 6

Open the starter app and run it locally

Create a three-file Task Tracker, serve it over HTTP, and prove that tasks persist only in this browser's localStorage.

See the app as files, not a screenshot

Download the Lesson 2 starter ZIP. Unzip it into a new ~/projects/task-tracker folder, or type the exact files below. The ZIP is the baseline before the done feature; it contains no keys and performs no deployment.

A web app is a folder the browser downloads. This module's tracker is three static files under public/ so a later deploy uploads the app and nothing else:

task-tracker/
  public/
    index.html    heading, labelled form, status line, list
    styles.css    readable layout, visible focus, 44px targets
    app.js        add, delete, save, load

There is no login, no database, and no AI call inside the app. Tasks live in localStorage under task-tracker.tasks.v1, keyed by origin (scheme + host + port). Clearing site data deletes them. Another browser, a private window, or another device starts empty. That is the product, not a bug — never describe it as a secure cloud store.

Module 2 will keep this behaviour and move the same tracker onto Vite + TypeScript. These exact files are not carried forward byte-for-byte.

Create the folder and type the HTML

Do not open file://. localStorage and crypto.randomUUID() need a served page (a secure context). Create the project, then either paste the files yourself or start OpenCode in this folder and give it the same requirements. If the agent adds a framework, stop it and use the files below.

mkdir -p ~/projects/task-tracker/public
cd ~/projects/task-tracker

Save this as public/index.html. The empty favicon link stops a console 404:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Task Tracker</title>
    <link rel="icon" href="data:," />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <main>
      <h1>Task Tracker</h1>
      <form id="add-form">
        <label for="new-task">New task</label>
        <input id="new-task" name="task" autocomplete="off" required />
        <button type="submit">Add task</button>
      </form>
      <p id="count" role="status" aria-live="polite"></p>
      <ul id="task-list"></ul>
    </main>
    <script src="app.js"></script>
  </body>
</html>

Add behaviour: load, save, render

Save this as public/app.js. load() reads JSON with try/catch. save() writes the same key. render() rebuilds the list. Submit ignores empty text, then clears and refocuses the input. Ids come from crypto.randomUUID().

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) : [];
    return Array.isArray(parsed) ? parsed : [];
  } 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');
      const label = document.createElement('span');
      label.textContent = task.text;

      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();
      });

      item.append(label, remove);
      return item;
    })
  );

  count.textContent = tasks.length === 0 ? 'No tasks yet.' : `${tasks.length} task(s).`;
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  const text = input.value.trim();
  if (!text) return;
  tasks = [...tasks, { id: crypto.randomUUID(), text }];
  save();
  render();
  form.reset();
  input.focus();
});

render();

Confirm on disk:

ls -la public
grep -n localStorage public/app.js

You should see save() as the writer and load() as the reader.

Add styles, serve over HTTP, use the app

Save this as public/styles.css:

:root {
  color-scheme: light;
  --ink: #1a1a1a;
  --muted: #555;
  --line: #d8d4c8;
  --paper: #faf9f6;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  padding: 1.5rem 1rem 3rem;
  background: var(--paper);
  color: var(--ink);
  font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}

main { max-width: 40rem; margin: 0 auto; }
h1 { font-size: 1.6rem; }

form {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: end;
  margin-block: 1.5rem;
}

label { flex-basis: 100%; font-weight: 600; }

input {
  flex: 1 1 12rem;
  min-height: 44px;
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--line);
  border-radius: 0.5rem;
  background: #fff;
  color: inherit;
  font: inherit;
}

button {
  min-height: 44px;
  padding: 0.5rem 1rem;
  border: 1px solid var(--line);
  border-radius: 0.5rem;
  background: #fff;
  color: inherit;
  font: inherit;
  cursor: pointer;
}

:focus-visible {
  outline: 3px solid #8a5a2b;
  outline-offset: 2px;
}

#count { color: var(--muted); }
ul { list-style: none; margin: 0; padding: 0; }

li {
  display: flex;
  gap: 0.75rem;
  align-items: center;
  justify-content: space-between;
  padding: 0.6rem 0;
  border-bottom: 1px solid var(--line);
}

Serve the folder that contains index.html:

cd ~/projects/task-tracker/public
python3 -m http.server 8000
# or: npx --yes serve .

Open the printed URL (http://localhost:8000 for Python). Add buy milk and renew library book. The status line should read 2 task(s). Reload: both remain. Delete one: the count drops. If port 8000 is busy, use python3 -m http.server 8080. Hard-refresh if you see a stale page.

Inspect storage, then prove the limit

Open DevTools (F12) → Application (Chrome/Edge) or Storage (Firefox) → Local Storage → http://localhost:8000. Find task-tracker.tasks.v1. The value is your array of { id, text }.

Open the same URL in a private window. The list is empty: that profile has its own store. Nothing was sent to a server.

Keyboard pass: Tab from the address bar to the input, Add task, and each Delete. Focus must be visible (:focus-visible outline).

Write one sentence: My tasks are stored in this browser's localStorage for this origin and are visible only to this profile.

Bridge to Module 2: keep this behaviour. Module 2 replaces public/index.html, styles.css, and app.js with Vite + TypeScript sources, npm run dev / npm run build, and a Cloudflare assets.directory that points at the build output — not at public/.

Sources

How did this lesson go? Give feedback →