Skip to main content
UX Design Engineering
Theme
Text size
100%
Typeface

Chapter 10

Debugging, explaining, and working with AI

How to find out why an interface misbehaves using errors, the console, breakpoints, and DevTools panels, how to use AI as a tutor and reviewer, and a bug lab of eight broken interfaces to diagnose and explain.

A teammate reports that the Remove button on a file list “only works sometimes.” There is no error in the console, the code looks fine, and when you try it yourself it works. The interface is still broken for that teammate, and you need a way to find out why that is more reliable than rereading the code and hoping something stands out.

This chapter treats debugging as a skill you can practice. You will learn to read errors, use the console and breakpoints deliberately, inspect the DOM and network, and cut a problem down to a small reproduction. You will also learn to use AI tools in a way that builds your understanding: asking for explanations, and reviewing generated code before it reaches a user. The goal is to be able to say why something broke and how you know the fix is right.

How debugging works

Debugging is a loop. You observe the symptom precisely, form a guess about the cause, run a small test that would prove the guess wrong, and change one thing at a time. A guess you can test is called a hypothesis. “Something is wrong with the click handler” is not testable. “The click handler runs, but id is undefined when I click the icon” is, because one console.log will confirm or rule it out.

The four questions from the introduction give you a map of where interface bugs live:

  1. State. The interface remembers the wrong thing, or remembers the same thing in two places that disagree.
  2. Events. The listener never runs, runs twice, runs on the wrong element, or runs at the wrong time.
  3. Updates. The state is right but the DOM was never updated, or was updated from stale data.
  4. Usability and accessibility. Everything “works” with a mouse, but the keyboard, a screen reader, or a slow network exposes a gap.

When you are stuck, ask which of the four the symptom points to. A list that shows the wrong items after a click is usually an update problem. A button that does nothing is usually an event problem. Naming the category tells you which tool to reach for first.

Explaining the problem out loud helps too. Describing the code line by line to someone else, or to a rubber duck on your desk, forces you to say what each line does rather than what you meant it to do. The gap between those two is often the bug.

Reading error messages

When a script throws an error, the console shows a message, the file and line, and a stack trace. Read all three before changing anything.

Uncaught TypeError: Cannot read properties of null (reading 'addEventListener')
    at app.js:4:12

Read it from the inside out. “Reading ‘addEventListener’” means the code tried to use .addEventListener on something. “Of null” means that something was null. Line 4 of app.js is where it happened. In interface code, null before a method call almost always means querySelector found nothing, so the next step is to compare the selector with the HTML. A typo such as [data-save-buton] or a hook that lives on a different page produces exactly this error.

That message is Chrome’s wording. Firefox says something like can't access property "addEventListener", button is null, and Safari says something like null is not an object (evaluating 'button.addEventListener'). Each one names the property and the value that was null.

Two other errors come up constantly:

  • ReferenceError: x is not defined means the name doesn’t exist in that scope. Check spelling, check that it was imported, and check that it wasn’t declared inside a different function.
  • TypeError: x is not a function means the name exists but holds something else. A common cause is calling an array method such as map on a NodeList, which doesn’t have it, or a typo in a method name such as querySelectorall.

Click the file and line link in the console to jump to the exact spot in the Sources panel (the Debugger panel in Firefox).

The console

console.log is the tool everyone starts with, and it is fine to use it, as long as each log answers a specific question. A few other methods answer questions console.log handles badly:

// app.js
console.log('handler ran', event.target); // Did this run, and on what element?
console.table(state.tasks);               // What does this array of objects contain right now?
console.count('render');                  // How many times has this line run?
console.trace('who called render?');      // Which function call led here?
console.assert(state.tasks.length < 100, 'Unexpected task count', state.tasks);

console.count is the quickest way to catch a listener that was attached twice or a render that runs more often than you expected. console.table is much easier to scan than a logged array when you are checking state.

When you log an object, most browsers show it as a live reference that you expand later, so the expanded contents may reflect the object’s current values rather than its values when the line ran. If timing matters, log a copy with console.log(structuredClone(state)) or a single property instead of the whole object.

Delete your logs when the bug is fixed. A console full of old “here 2” messages hides the next real error.

Breakpoints

A breakpoint pauses the script on a line so you can inspect every variable at that moment, then step forward one line at a time. Logging tells you what happened, while a breakpoint lets you look around while it is happening.

To set one, open the Sources panel, find your file, and click a line number. Then trigger the interaction. When the script pauses, hover over variables to see their values, look at the Scope section for everything in reach, and use “Step over” to run the next line. You can also write debugger; in your code, which pauses there whenever DevTools is open.

Two other kinds of breakpoints are especially useful for interface work:

  • DOM change breakpoints. In the Elements panel, right-click an element and choose to break on attribute modifications or subtree modifications. The next time any script changes that element, the debugger pauses on the line that did it. This answers “what keeps adding hidden to this panel?” in seconds.
  • Event listener breakpoints. In Chrome’s Sources panel, you can pause on every click or keydown handler, which helps when you don’t know which file handles an event.

The Elements and Network panels

The Elements panel shows the live DOM, which is the page after your JavaScript has changed it. Your HTML file shows only where the page started. When you click a button, watch the panel: attributes that change briefly highlight. If aria-expanded flips but hidden never changes, the bug is in the update step.

Select an element and open the Event Listeners tab to see every listener attached to it and its ancestors, with a link to the code. Two click entries pointing at the same function means the listener was attached twice. In the Chrome, Edge, and Safari consoles, getEventListeners($0) lists the listeners on the currently selected element. In Firefox, use the “event” badge in the Inspector instead.

The Network tab shows every request, its HTTP status, and its response body. For async bugs it answers three questions quickly: whether the request was sent at all, what status came back, and what the server actually returned. Remember from chapter 7 that fetch does not reject on a 404 or 500, so a request can appear in red here while your code happily treats the error page as data. Use the throttling menu to slow the network down and see your loading states the way people on a poor connection see them.

The smallest version: a minimal reproduction

A minimal reproduction is the smallest page that still shows the bug. Building one is often how you find the bug, because each piece you remove either makes the bug disappear, which tells you that piece is involved, or leaves it in place, which tells you it isn’t.

Start from the real page. Remove styling, unrelated components, and data until the bug either stops or you can’t remove anything else. Here is the file-list bug from the start of the chapter, cut down to one item:

<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Repro: Remove only works sometimes</title>
</head>
<body>
  <ul data-file-list>
    <li>
      Invoice.pdf
      <button type="button" data-remove="invoice">
        <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
          <path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="2" />
        </svg>
        Remove
      </button>
    </li>
  </ul>
  <p data-status role="status"></p>
  <script type="module" src="app.js"></script>
</body>
</html>
// app.js
const list = document.querySelector('[data-file-list]');
const status = document.querySelector('[data-status]');

list.addEventListener('click', (event) => {
  const id = event.target.dataset.remove;
  if (!id) return;
  status.textContent = `Removed ${id}.`;
});

A reproduction this small is also what you share when you ask for help. A reviewer, a forum, or an AI tool can reason about twenty lines far better than about your whole project.

Debugging challenge: the Remove button that works only sometimes

Open the reproduction and try it. Clicking the word “Remove” shows the status message. Clicking the X icon does nothing. Tabbing to the button and pressing Enter works every time. There is no error.

Start with a hypothesis about events: maybe the listener doesn’t run when you click the icon. Add console.log('click', event.target); as the first line of the handler and click the icon. The log appears, so the listener runs, and that hypothesis is ruled out.

Look at what was logged. Clicking the word logs <button data-remove="invoice">. Clicking the icon logs <path>. That is the cause. event.target is the innermost element the click landed on, and the icon’s <path> has no data-remove attribute, so id is undefined and the guard clause returns quietly. Pressing Enter works because a keyboard activation targets the button itself.

The fix is to ask for the nearest ancestor that has the hook, using closest:

// app.js
list.addEventListener('click', (event) => {
  const button = event.target.closest('[data-remove]');
  if (!button) return;
  status.textContent = `Removed ${button.dataset.remove}.`;
});

Test the fix the same way you found the bug: click the icon, click the word, and use the keyboard. Then remove the console.log.

Accessibility check: bugs the console can’t see

Some of the most serious interface bugs produce no error at all. A button with no accessible name, a panel whose aria-expanded never updates, or focus that disappears after a render all look fine in the console and fine to a mouse user.

Make the reproduction above icon-only by deleting the word “Remove” from the button. It still works with a mouse. Now select the button in the Elements panel and open the Accessibility pane. The computed name is empty, because the <svg> is aria-hidden and there is no text left. A screen reader announces it as “button” with nothing else, so someone who can’t see the icon has no idea what it does, and with several files in the list they can’t tell the buttons apart.

The fix is to give the button a name that includes the file:

<button type="button" data-remove="invoice" aria-label="Remove Invoice.pdf">
  <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
    <path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="2" />
  </svg>
</button>

Check the Accessibility pane again and confirm the name is “Remove Invoice.pdf”. Then do the checks the console will never do for you: tab through the page and confirm focus is visible and lands where you expect, operate every control with Enter, Space, and Escape where they apply, and listen to it with a screen reader. Chapter 9 covers these checks in detail. Make them part of every bug fix, because a fix that restores mouse behavior can still leave keyboard users stuck.

Working with AI

AI tools can explain code, suggest causes for a bug, and generate working interface code in seconds. They can also produce code that looks right, runs, and fails keyboard users, uses APIs that don’t exist, or quietly duplicates state. The way to get value from them is to use them to build your understanding and to treat everything they generate as code from a confident new teammate whose work you have to review.

Asking for explanations

Ask for explanations first and answers second. A fix you don’t understand is a fix you can’t defend in review or repair when it breaks again. Prompts like these keep you in charge of the reasoning:

Here is a 20-line reproduction of a bug. Don't fix it yet. Explain what
event.target refers to when I click the <svg> inside the button, and
why that would make dataset.remove undefined.
I think this list doesn't update because renderTasks reads a stale
reference to the tasks array. Here is the code. Is my hypothesis right?
If not, what would you check next, and how would that check prove it?
Explain this function line by line in plain English, as if to a designer
who knows HTML and CSS. Then tell me which line you would expect to break
first if the server returned a 500.

Give the tool your minimal reproduction, the exact symptom, what you expected, and what you have already ruled out. Ask it to name the part of the browser behavior involved so you can check that behavior yourself on MDN. If it mentions a method or attribute you don’t recognize, look it up before you use it, because generated code sometimes calls APIs that don’t exist.

Reviewing generated UI code

Before you use generated interface code, check it against this list:

  • Semantics. Real <button>, <a href>, <label>, <form>, <fieldset>, and <dialog> elements where they fit, and no <div> with a click handler.
  • Keyboard. Every control is reachable with Tab and works with Enter or Space. Escape closes anything that opens. Focus is visible and goes somewhere sensible after content is removed or re-rendered.
  • ARIA. Attributes such as aria-expanded, aria-selected, and aria-invalid are updated when the state changes, not only set once. No ARIA role replaces a native element that would do the job.
  • Text and safety. User-provided text goes in with textContent. Any innerHTML is questioned.
  • State. Each fact is stored once. Values such as counts and “is anything selected” are derived from state instead of being stored next to it.
  • Events. Listeners are attached once, outside any render function, and delegation uses closest instead of trusting event.target.
  • Async. fetch responses are checked with response.ok, loading and error states exist, and stale responses can’t overwrite newer ones.
  • Fit. The code follows your project’s conventions and uses existing components instead of re-creating them.
  • Explanation. You can explain every line. If you can’t, ask until you can or rewrite the part you don’t understand.

Refactoring generated code

Suppose you ask for a “copy code” button and get this:

document.querySelectorAll('.copy-btn').forEach(function (btn) {
  btn.onclick = function () {
    var code = btn.parentElement.querySelector('code').innerText;
    navigator.clipboard.writeText(code);
    btn.innerHTML = '<span>Copied!</span>';
  };
});

It works in a quick test, but the review checklist finds several problems. It uses a styling class as a hook, onclick replaces any other handler, the clipboard promise is never awaited, so a failure is silent, the label changes to “Copied!” forever, and a screen reader user may never hear that anything happened. Here is the refactor:

<!-- index.html (excerpt) -->
<div class="example" data-example>
  <pre><code>const state = { open: false };</code></pre>
  <button type="button" data-copy>Copy code</button>
</div>
<p class="visually-hidden" data-copy-status role="status"></p>
// copy.js
const status = document.querySelector('[data-copy-status]');

document.addEventListener('click', async (event) => {
  const button = event.target.closest('[data-copy]');
  if (!button) return;

  const code = button.closest('[data-example]').querySelector('code').textContent;
  try {
    await navigator.clipboard.writeText(code);
    status.textContent = 'Code copied.';
  } catch {
    status.textContent = 'Could not copy. Select the code and copy it manually.';
  }
  setTimeout(() => {
    status.textContent = '';
  }, 3000);
});

The status region exists in the page before anything is written to it, so screen readers announce the message, and clearing it afterward means a second copy is announced too. The clipboard API needs a secure context, which includes localhost, so this runs on your local server.

This works because…

The Remove button fix works because closest starts at the element the click landed on and walks up the tree until it finds an element matching [data-remove]. Whether the click lands on the <path>, the <svg>, the text, or the button itself, the search ends at the same button, and its data-remove value tells the handler which file to remove. Before the fix, the handler trusted event.target, which is only the innermost element, so the result depended on exactly where the pointer was. Logging event.target ruled out a missing listener, and the fix was tested by clicking each part of the button and by using the keyboard.

Project: bug lab

The bug lab is eight small broken interfaces. Your job is to fix each one and to write up how you found it. The write-up is the main deliverable.

Set up a folder per bug, bug-lab/01-accordion/ through bug-lab/08-list/, each with an index.html that contains the markup the snippet needs and an app.js with the snippet. Here is the starter for the race condition bug, which needs the least markup:

<!-- bug-lab/05-race/index.html -->
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bug 5: search results from the wrong query</title>
</head>
<body>
  <label for="search">Search components</label>
  <input id="search" type="search" data-search>
  <ul data-results></ul>
  <script type="module" src="app.js"></script>
</body>
</html>

For each bug, reproduce the symptom before changing any code, form a hypothesis, test it with a tool from this chapter, fix it, and test the fix with a mouse and a keyboard. Diagnoses for all eight are at the end of the chapter. Try each bug before reading its diagnosis.

Bug 1: the accordion opens the wrong panel

// bug-lab/01-accordion/app.js
document.querySelectorAll('[data-accordion-button]').forEach((button) => {
  button.addEventListener('click', () => {
    const panel = document.querySelector('[data-accordion-panel]');
    const open = button.getAttribute('aria-expanded') === 'true';
    button.setAttribute('aria-expanded', String(!open));
    panel.hidden = open;
  });
});

Symptom: every button opens and closes the first panel. The second and third buttons’ aria-expanded values change, but their panels never appear.

Bug 2: the form validates too early

// bug-lab/02-validation/app.js
const email = document.querySelector('[data-email]');
const error = document.querySelector('[data-email-error]');

email.addEventListener('input', () => {
  const invalid = !email.validity.valid;
  error.textContent = invalid ? 'Enter an email address like name@example.com.' : '';
  email.setAttribute('aria-invalid', String(invalid));
});

Symptom: the moment someone types “j”, the field turns red and says the email is invalid.

Bug 3: the menu won’t open

// bug-lab/03-menu/app.js
const button = document.querySelector('[data-menu-button]');
const menu = document.querySelector('[data-menu]');

button.addEventListener('click', () => {
  menu.hidden = !menu.hidden;
  button.setAttribute('aria-expanded', String(!menu.hidden));
});

document.addEventListener('click', () => {
  menu.hidden = true;
  button.setAttribute('aria-expanded', 'false');
});

Symptom: clicking the account button does nothing visible. The code is meant to close the menu when someone clicks elsewhere on the page.

Bug 4: the favorites count only goes up

// bug-lab/04-favorites/app.js (excerpt)
const state = {
  resources: [
    { id: 'contrast', title: 'Color contrast guide', favorite: false },
    { id: 'focus', title: 'Focus order checklist', favorite: false },
  ],
  favoriteCount: 0,
};

function toggleFavorite(id) {
  const resource = state.resources.find((item) => item.id === id);
  resource.favorite = !resource.favorite;
  if (resource.favorite) state.favoriteCount += 1;
  render();
}

Symptom: favoriting a resource increases the “Favorites (1)” count, but unfavoriting it leaves the count where it was.

Bug 5: search shows results for the wrong query

// bug-lab/05-race/app.js
const input = document.querySelector('[data-search]');
const results = document.querySelector('[data-results]');

function fakeSearch(query) {
  const delay = Math.random() * 1000;
  return new Promise((resolve) => {
    setTimeout(() => resolve([`${query}: result 1`, `${query}: result 2`]), delay);
  });
}

input.addEventListener('input', async () => {
  const items = await fakeSearch(input.value);
  results.replaceChildren(...items.map((text) => {
    const li = document.createElement('li');
    li.textContent = text;
    return li;
  }));
});

Symptom: type “button” quickly. Sometimes the list ends up showing results for “butt” or “bu” even though the field says “button”.

Bug 6: focus disappears after checking a task

// bug-lab/06-focus/app.js (excerpt)
list.addEventListener('change', (event) => {
  const checkbox = event.target.closest('[data-task-toggle]');
  if (!checkbox) return;
  const id = checkbox.closest('[data-task-id]').dataset.taskId;
  const task = state.tasks.find((item) => item.id === id);
  task.completed = checkbox.checked;
  renderTasks(); // rebuilds the list with replaceChildren
});

Symptom: with a keyboard, press Space to check a task. Focus disappears: no checkbox shows a focus ring, document.activeElement is <body>, and a screen reader goes silent or announces something unrelated.

Bug 7: the delete button nobody can reach

<!-- bug-lab/07-button/index.html (excerpt) -->
<div class="icon-button" data-delete-draft>
  <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
    <path d="M3 4h10M6 4V2h4v2M5 4l1 10h4l1-10" fill="none" stroke="currentColor" />
  </svg>
  Delete draft
</div>
<p data-status role="status"></p>
// bug-lab/07-button/app.js
const status = document.querySelector('[data-status]');

document.querySelector('[data-delete-draft]').addEventListener('click', () => {
  status.textContent = 'Draft deleted.';
});

Symptom: it works with a mouse. With a keyboard you can’t reach it, and a screen reader reads “Delete draft” as plain text.

Bug 8: Clear completed doesn’t clear anything

// bug-lab/08-list/app.js (excerpt)
const state = {
  tasks: [
    { id: crypto.randomUUID(), label: 'Draft copy', completed: true },
    { id: crypto.randomUUID(), label: 'Review contrast', completed: false },
  ],
  filter: 'all',
};
const { tasks } = state;

function createTaskItem(task) {
  const li = document.createElement('li');
  li.textContent = task.completed ? `${task.label} (done)` : task.label;
  return li;
}

function renderTasks() {
  list.replaceChildren(...tasks.map(createTaskItem));
  count.textContent = `Tasks: ${state.tasks.length}`;
}

clearButton.addEventListener('click', () => {
  state.tasks = state.tasks.filter((task) => !task.completed);
  renderTasks();
});

Symptom: after clicking Clear completed, the count drops from 2 to 1, but “Draft copy (done)” is still in the list.

The write-up template

Copy this into a NOTES.md file in each bug’s folder and fill it in after the fix:

Bug: <name>

What was broken:
  The symptom, described the way a user would report it.

Why it was broken:
  The cause, in one or two plain sentences. Name the state, event,
  or update that was wrong.

How I found it:
  The hypotheses I tried, in order, and the tool that confirmed or
  ruled out each one (log, breakpoint, Elements panel, Accessibility
  pane, Network tab).

What I changed:
  The lines I changed and why that change addresses the cause rather
  than only the symptom.

How I tested it:
  Mouse, keyboard, screen reader, and any edge cases (empty input,
  fast typing, slow network).

What I'll watch for next time:
  The pattern behind this bug, so I recognize it in other code.

Done means

  • All eight bugs are fixed, and each fix addresses the cause.
  • Each bug’s symptom was reproduced before the fix and confirmed gone after it.
  • Every fixed interface works with a keyboard alone, and controls have accurate names and states in the Accessibility pane.
  • No console.log calls remain in the fixed code.
  • Each folder has a completed write-up, and someone who hasn’t seen the code could follow it.

Bug lab diagnoses

Bug 1 diagnosis

document.querySelector always returns the first match in the whole document, so every button controls the first panel. Find the panel that belongs to the clicked button instead: button.closest('[data-accordion-item]').querySelector('[data-accordion-panel]'), or document.getElementById(button.getAttribute('aria-controls')) if the buttons reference their panels by id. Watch for any query inside a loop or handler that searches document when it should search near the element.

Bug 2 diagnosis

The listener validates on input, which fires on every keystroke, so it judges an email before anyone could have finished typing it. Validate on blur the first time, then re-validate on input only once an error is showing, so it clears as soon as the value is fixed. Validate everything again on submit, as chapter 6 describes.

const field = { hasError: false };

function validate() {
  field.hasError = !email.validity.valid;
  error.textContent = field.hasError ? 'Enter an email address like name@example.com.' : '';
  if (field.hasError) email.setAttribute('aria-invalid', 'true');
  else email.removeAttribute('aria-invalid');
}

email.addEventListener('blur', validate);
email.addEventListener('input', () => {
  if (field.hasError) validate();
});

Bug 3 diagnosis

The click on the button bubbles up to document. The button’s listener opens the menu, then the document’s listener closes it in the same click, so it never appears. A console.count in each listener shows both running on one click. Make the document listener ignore clicks inside the menu’s wrapper, and add Escape while you are there:

function closeMenu() {
  menu.hidden = true;
  button.setAttribute('aria-expanded', 'false');
}

document.addEventListener('click', (event) => {
  if (event.target.closest('[data-menu-wrapper]')) return;
  closeMenu();
});

document.addEventListener('keydown', (event) => {
  if (event.key === 'Escape' && !menu.hidden) {
    closeMenu();
    button.focus();
  }
});

Chapter 8’s popover attribute gives you click-outside and Escape closing for free if a light-dismiss panel is what you need.

Bug 4 diagnosis

The count is stored separately from the facts it describes, and only one direction of the toggle updates it. Adding a matching decrement would fix today’s symptom, but any future code that changes favorite would have to remember to update the count as well. Remove favoriteCount and derive the value when you need it:

function getFavoriteCount() {
  return state.resources.filter((item) => item.favorite).length;
}

Bug 5 diagnosis

Each keystroke starts a search, and the searches finish in random order. Whichever response arrives last wins, even if it belongs to an older query. Give each request a number and ignore any response that isn’t the latest:

let latestRequest = 0;

input.addEventListener('input', async () => {
  latestRequest += 1;
  const requestId = latestRequest;
  const items = await fakeSearch(input.value);
  if (requestId !== latestRequest) return;
  results.replaceChildren(...items.map((text) => {
    const li = document.createElement('li');
    li.textContent = text;
    return li;
  }));
});

With a real fetch, cancel the older request with AbortController as in chapter 7, and debounce the input so fewer requests start.

Bug 6 diagnosis

replaceChildren removes the checkbox that had focus and inserts a new one. When a focused element leaves the DOM, focus falls back to the <body>, so nothing visibly has focus and a screen reader user loses their place. Running document.activeElement in the console after the change shows <body>. After rendering, move focus to the new checkbox for the same task:

renderTasks();
list.querySelector(`[data-task-id="${CSS.escape(id)}"] [data-task-toggle]`).focus();

A longer-term fix is to update only the item that changed instead of rebuilding the list.

Bug 7 diagnosis

A <div> isn’t focusable, has no button role, and doesn’t respond to Enter or Space. You could add role="button", tabindex="0", and a keydown handler, but a real button gives you all of that for free. Change the markup to <button type="button" class="icon-button" data-delete-draft> and keep the same JavaScript. Confirm in the Accessibility pane that the role is “button” and the name is “Delete draft”.

Bug 8 diagnosis

const { tasks } = state copies a reference to the original array into tasks. filter returns a new array, and assigning it to state.tasks doesn’t change what tasks points to, so renderTasks keeps drawing the old list while the count reads the new one. A console.log(tasks === state.tasks) inside renderTasks prints false after the click. Remove the destructured variable and read state.tasks in the render function, so state lives in exactly one place.

Chapter 11 takes the patterns you have been fixing and turns them into small, reusable components with clear inputs, outputs, and accessibility contracts.

Found an error? Report it.