# JavaScript DOM 100 — Interface Lab

Created by Laurence Svekis for DiscoveryVIP.

Each lesson uses a fresh authored HTML fixture. JavaScript runs after that markup exists. Run to interact manually; Check to perform the authored actions on a fresh page. Tests are targeted learning checks, not a full accessibility or security audit.

## 1. Your first living page

HTML describes the initial document. The browser builds a tree of objects from it. JavaScript can change those objects after the page loads, so the visible page need not match its original source. In this studio the HTML fixture is created before your code runs. Select the heading, then assign a new textContent value; the change is visible immediately. This is a DOM update, not a change to the HTML file on disk.

**Mission:** Change #welcome to exactly Hello, DOM!

### Starting HTML
```html
<h1 id="welcome">Hello, explorer</h1>
```

### Worked JavaScript
```javascript
document.querySelector('#welcome').textContent = 'Hello, DOM!';
```

**Hint:** querySelector returns an element; textContent controls its text.

**Knowledge question:** What did your script change?

**Explanation:** The live document in the browser

**Try next:** Change the greeting again, run it and compare the DOM inspector with the original HTML.

## 2. Elements and text nodes

A DOM tree contains more than elements. Text inside an element is represented by a text node. children lists element children; childNodes includes text and comments too. Whitespace between tags may therefore change a childNodes count. Use the collection that matches your intent rather than assuming every node is an element. This fixture deliberately includes a text node followed by a strong element.

**Mission:** Show the nodeType of the first child of #line in #result.

### Starting HTML
```html
<p id="line">Hello <strong>world</strong></p><output id="result"></output>
```

### Worked JavaScript
```javascript
document.querySelector('#result').textContent = document.querySelector('#line').childNodes[0].nodeType;
```

**Hint:** A text node has nodeType 3.

**Knowledge question:** Which collection includes text nodes?

**Explanation:** childNodes

**Try next:** Inspect children.length and childNodes.length with console.log.

## 3. Select by an identifier

An id identifies a particular element in a document and should be unique. getElementById accepts the identifier without a # prefix; querySelector accepts a CSS selector such as #status. Both can return null when nothing matches. Store the element in a descriptive variable so later changes are easy to understand. Avoid relying on automatic window properties created from ids.

**Mission:** Use getElementById to change #status to Ready.

### Starting HTML
```html
<p id="status">Waiting</p>
```

### Worked JavaScript
```javascript
const status = document.getElementById('status');
status.textContent = 'Ready';
```

**Hint:** getElementById("status") selects this paragraph.

**Knowledge question:** What argument does getElementById expect?

**Explanation:** The id without a # prefix

**Try next:** Try an absent id and log the result without accessing textContent on it.

## 4. Wait for the document

A script cannot select markup that the browser has not parsed yet. On a normal site, defer on an external script lets parsing finish before execution; a script at the end of body also follows earlier markup. DOMContentLoaded announces parsing completion. If your script may load later, check readyState before adding a listener, because an event that already happened will not replay. Here your code always runs after the fixture exists.

**Mission:** Initialize #status to Initialized whether DOMContentLoaded has fired or not.

### Starting HTML
```html
<p id="status">Not initialized</p>
```

### Worked JavaScript
```javascript
function init() { document.querySelector('#status').textContent = 'Initialized'; }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
```

**Hint:** Use a readyState branch and an init function.

**Knowledge question:** Why check readyState in late-loading code?

**Explanation:** DOMContentLoaded may already have happened

**Try next:** Explain why a listener alone could miss initialization on a real site.

## 5. Handle a missing match

querySelector returns null when there is no match. Reading a property on null throws, stopping the current synchronous execution. Guard optional UI before using it and provide a meaningful fallback. Do not hide every error in an empty catch block: a missing optional element differs from a broken required selector. This lesson contains no #optional element, so the fallback must run.

**Mission:** Show No optional panel when #optional is absent.

### Starting HTML
```html
<output id="result"></output>
```

### Worked JavaScript
```javascript
const optional = document.querySelector('#optional');
document.querySelector('#result').textContent = optional ? optional.textContent : 'No optional panel';
```

**Hint:** Check the selection before reading its properties.

**Knowledge question:** What does querySelector return for no match?

**Explanation:** null

**Try next:** Add a guard around a second optional feature and log a helpful message.

## 6. Find elements with CSS selectors

querySelector uses the same selector language as CSS. A descendant selector scopes a search to matching descendants, while a class selector matches a reusable class. querySelector returns only the first match. Prefer selectors that describe stable structure or explicit hooks; styling changes should not silently break important behavior. Here two cards exist, but only the featured card should change.

**Mission:** Change only .card.featured to Featured pick.

### Starting HTML
```html
<article class="card">Basic</article><article class="card featured">Special</article>
```

### Worked JavaScript
```javascript
document.querySelector('.card.featured').textContent = 'Featured pick';
```

**Hint:** Combine class selectors without a space to require both.

**Knowledge question:** What does .card.featured mean?

**Explanation:** One element with both classes

**Try next:** Compare .card.featured with .card .featured in the inspector.

## 7. Update a collection

querySelectorAll returns a static NodeList of matches. It is not one element, so setting textContent on the collection does not update its members. Iterate over each element with forEach or a loop. Keep the selection scoped to the part of the UI you intend to change. This avoids changing similar elements in another feature.

**Mission:** Add the ready class to all three .item elements.

### Starting HTML
```html
<ul><li class="item">One</li><li class="item">Two</li><li class="item">Three</li></ul>
```

### Worked JavaScript
```javascript
document.querySelectorAll('.item').forEach(item => item.classList.add('ready'));
```

**Hint:** Use querySelectorAll(...).forEach(...).

**Knowledge question:** How do you update every match?

**Explanation:** Iterate over the returned elements

**Try next:** Use the callback index to number each item.

## 8. Scope a search to a component

An element also supports querySelector. Searching inside a known container makes reusable components safer: the same class can appear elsewhere without being changed. Start with the container, then find a descendant. Scope is especially useful for cards, forms and repeated UI regions. Remember that the container itself is not a descendant match.

**Mission:** Set only the right panel’s .count to 5.

### Starting HTML
```html
<section id="left"><span class="count">0</span></section><section id="right"><span class="count">0</span></section>
```

### Worked JavaScript
```javascript
const panel = document.querySelector('#right');
panel.querySelector('.count').textContent = '5';
```

**Hint:** Call querySelector on #right.

**Knowledge question:** Why query within a container?

**Explanation:** It limits changes to that component

**Try next:** Create a helper accepting a panel element and a number.

## 9. Move through the tree

parentElement, children and nextElementSibling let you navigate relationships already present in the document. Element-oriented properties skip whitespace text nodes. Traversal is useful when a control acts on its own card, but overreliance on a fixed number of parents becomes brittle when markup changes. Use closest for a named ancestor when that expresses your intent better.

**Mission:** Change the next element sibling of #first to Next.

### Starting HTML
```html
<ul id="list"><li id="first">A</li><li>B</li></ul>
```

### Worked JavaScript
```javascript
document.querySelector('#first').nextElementSibling.textContent = 'Next';
```

**Hint:** Get #first, then its nextElementSibling.

**Knowledge question:** Which property skips whitespace text siblings?

**Explanation:** nextElementSibling

**Try next:** Log the parent element’s tagName and number of children.

## 10. Understand static selections

querySelectorAll gives a snapshot of matching elements at selection time. Adding another matching element later does not expand that NodeList. getElementsByClassName is a live collection and changes as the DOM changes. A live collection can surprise you if you remove classes during iteration. When you need a stable batch, a static selection or Array.from copy is often clearer.

**Mission:** Capture .item matches, append a second .item, and display old/new counts as 1/2.

### Starting HTML
```html
<div id="items"><span class="item">A</span></div><output id="result"></output>
```

### Worked JavaScript
```javascript
const before = document.querySelectorAll('.item');
const item = document.createElement('span'); item.className = 'item'; item.textContent = 'B';
document.querySelector('#items').append(item);
document.querySelector('#result').textContent = before.length + '/' + document.querySelectorAll('.item').length;
```

**Hint:** Compare a saved NodeList length with a fresh selection.

**Knowledge question:** Does a querySelectorAll result automatically gain later matches?

**Explanation:** No, it is static

**Try next:** Repeat mentally with getElementsByClassName and predict the counts.

## 11. Render text safely

textContent treats a value as text, even if it contains angle brackets. innerHTML parses a string as markup, so untrusted input can create elements or dangerous behavior. Prefer textContent for names, comments and search results. It also replaces existing descendants, so use a dedicated text element if a component contains controls you need to preserve.

**Mission:** Show the literal text <strong>Hello</strong> without creating a strong element.

### Starting HTML
```html
<p id="message"></p>
```

### Worked JavaScript
```javascript
document.querySelector('#message').textContent = '<strong>Hello</strong>';
```

**Hint:** Assign the exact string to textContent.

**Knowledge question:** Which API renders an untrusted name as text?

**Explanation:** textContent

**Try next:** Try a string that looks like an image tag; it should remain text.

## 12. Read and write attributes

Attributes hold values in markup. setAttribute writes a string value, and getAttribute reads one or returns null. Some DOM properties reflect attributes, but not all behave identically. Links, images and accessibility state often need explicit attribute updates. When building real links from external data, validate allowed destinations as well as safely rendering the label.

**Mission:** Change #guide to Start learning and its href attribute to #learn.

### Starting HTML
```html
<a id="guide" href="#old">Old guide</a>
```

### Worked JavaScript
```javascript
const link = document.querySelector('#guide');
link.setAttribute('href', '#learn');
link.textContent = 'Start learning';
```

**Hint:** Use setAttribute for href and textContent for the label.

**Knowledge question:** What does getAttribute return for an absent attribute?

**Explanation:** null

**Try next:** Explain why a label and destination must both be checked.

## 13. Keep styles in classes

classList provides add, remove, contains and toggle without replacing unrelated classes. This keeps behavior in JavaScript and presentation in CSS. Setting className to a new string replaces the full class list, which may remove styling or hooks you wanted to keep. Use toggle with its second argument when a Boolean state should determine the class rather than invert it blindly.

**Mission:** Remove muted and add highlight while keeping card.

### Starting HTML
```html
<article id="card" class="card muted">Status card</article>
```

### Worked JavaScript
```javascript
const card = document.querySelector('#card');
card.classList.remove('muted');
card.classList.add('highlight');
```

**Hint:** Call remove and add on the same classList.

**Knowledge question:** Which operation preserves unrelated classes?

**Explanation:** classList.add

**Try next:** Use toggle("highlight", false) and predict the result.

## 14. Use data attributes

Custom data-* attributes store small pieces of element metadata. dataset exposes them as strings with camel-cased names: data-product-id becomes dataset.productId. Convert numbers deliberately before arithmetic. These values are visible to visitors and editable through developer tools, so never treat them as secrets or authorization evidence.

**Mission:** Read data-price and data-quantity and show their product in #total.

### Starting HTML
```html
<article id="product" data-price="12" data-quantity="3">Notebook</article><output id="total"></output>
```

### Worked JavaScript
```javascript
const product = document.querySelector('#product');
document.querySelector('#total').textContent = Number(product.dataset.price) * Number(product.dataset.quantity);
```

**Hint:** Use Number to make the conversion explicit.

**Knowledge question:** What type is a dataset value?

**Explanation:** String

**Try next:** Try addition instead of multiplication and observe why conversion matters.

## 15. Properties describe current control state

A checkbox’s checked property represents its current state. Its checked attribute describes the default state in markup; it is not a reliable way to read later user interaction. Likewise input.value represents the current text. Boolean properties such as disabled should be assigned true or false. Writing disabled="false" still creates a disabled attribute.

**Mission:** Check #agree and enable #continue using properties.

### Starting HTML
```html
<label><input id="agree" type="checkbox"> Agree</label><button id="continue" disabled>Continue</button>
```

### Worked JavaScript
```javascript
document.querySelector('#agree').checked = true;
document.querySelector('#continue').disabled = false;
```

**Hint:** Set checked = true and disabled = false.

**Knowledge question:** Does disabled="false" enable a button?

**Explanation:** No, the Boolean attribute is still present

**Try next:** Compare getAttribute("checked") with the checked property.

## 16. Create and append an element

createElement makes a detached element. Set its text and attributes, then insert it with append. Creating an element alone does not make it visible. append accepts nodes and strings, while appendChild expects a node. This approach makes data flow explicit and lets you avoid constructing HTML strings for ordinary user-facing text.

**Mission:** Create one li containing Learn DOM and append it to #list.

### Starting HTML
```html
<ul id="list"></ul>
```

### Worked JavaScript
```javascript
const item = document.createElement('li');
item.textContent = 'Learn DOM';
document.querySelector('#list').append(item);
```

**Hint:** Create, set text, then append.

**Knowledge question:** When does a new element become part of the visible document?

**Explanation:** When inserted into the document

**Try next:** Add a class before inserting the element.

## 17. Insert at the right position

prepend inserts content before existing children. before and after insert siblings beside a node. These methods express placement directly and can move existing nodes too. Choose the operation that matches the desired relationship instead of rebuilding a whole container. Replacing content unnecessarily can discard focus or event handlers on child elements.

**Mission:** Add Plan as the first li without removing Build or Test.

### Starting HTML
```html
<ol id="steps"><li>Build</li><li>Test</li></ol>
```

### Worked JavaScript
```javascript
const item = document.createElement('li'); item.textContent = 'Plan';
document.querySelector('#steps').prepend(item);
```

**Hint:** Create a new li, then call prepend on the list.

**Knowledge question:** Which method inserts before existing children?

**Explanation:** prepend

**Try next:** Insert Review after Test using after.

## 18. Remove a targeted node

remove detaches the selected element from its parent. Removing a card is different from clearing all of its container’s content. Select the exact item, then remove it. If a feature may already have removed the element, guard a missing match. In an application with data state, update the data too or the next render may bring the item back.

**Mission:** Remove #remove while preserving #keep.

### Starting HTML
```html
<ul><li id="keep">Keep</li><li id="remove">Remove me</li></ul>
```

### Worked JavaScript
```javascript
document.querySelector('#remove').remove();
```

**Hint:** Call remove on the selected element, not its parent.

**Knowledge question:** What does element.remove() remove?

**Explanation:** That element from its parent

**Try next:** Try optional chaining on a second removal and explain the difference.

## 19. Clone a reusable template

A template element holds inert markup in its content DocumentFragment. cloneNode(true) makes a deep copy, including descendants. Fill the copied fields and append it to your visible list. Cloning does not copy listeners added with addEventListener; wire events on the copy or use delegation. Avoid duplicating ids across repeated cards.

**Mission:** Clone the template, set its heading to DOM explorer and paragraph to Ready to build, then append it.

### Starting HTML
```html
<template id="cardTemplate"><article class="card"><h2></h2><p></p></article></template><div id="cards"></div>
```

### Worked JavaScript
```javascript
const copy = document.querySelector('#cardTemplate').content.cloneNode(true);
copy.querySelector('h2').textContent = 'DOM explorer';
copy.querySelector('p').textContent = 'Ready to build';
document.querySelector('#cards').append(copy);
```

**Hint:** Select template.content before cloning.

**Knowledge question:** What does cloneNode(true) copy?

**Explanation:** The node and its descendants

**Try next:** Make a second distinct card without adding duplicate ids.

## 20. Render a batch with a fragment

A DocumentFragment groups detached nodes. Appending it moves its children into the destination. It is useful for constructing a batch clearly, but is not a guarantee of a measurable speedup in every situation. replaceChildren replaces old children in one explicit operation. Consider focus and event listeners when rebuilding a container; sometimes a targeted update is better.

**Mission:** Replace the placeholder with Plan, Build and Test list items.

### Starting HTML
```html
<ul id="list"><li>Old placeholder</li></ul>
```

### Worked JavaScript
```javascript
const fragment = document.createDocumentFragment();
['Plan', 'Build', 'Test'].forEach(text => { const li = document.createElement('li'); li.textContent = text; fragment.append(li); });
document.querySelector('#list').replaceChildren(fragment);
```

**Hint:** Build a fragment then pass it to replaceChildren.

**Knowledge question:** What happens when a fragment is appended?

**Explanation:** Its children move into the destination

**Try next:** Explain when replacing all children might be disruptive.

## 21. Respond to a click

addEventListener connects an event type with a callback. Pass the callback function rather than the result of calling it. The function runs later when the event occurs. A native button already supports keyboard activation, so it is a better starting point than making a clickable div. Keep visible feedback close to the action and meaningful to the user.

**Mission:** On #button click, set #message to Hello!

### Starting HTML
```html
<button id="button">Say hello</button><p id="message">Waiting</p>
```

### Worked JavaScript
```javascript
document.querySelector('#button').addEventListener('click', () => { document.querySelector('#message').textContent = 'Hello!'; });
```

**Hint:** Use an arrow function as the second argument.

**Knowledge question:** What should be passed to addEventListener?

**Explanation:** A function to run later

**Try next:** Click with the keyboard as well as the pointer.

## 22. Build a stateful counter

A UI often displays a value stored in JavaScript. Keep that value in one variable, update it after an action and render it into the DOM. Reading a string from textContent and doing arithmetic can cause coercion mistakes. Separating state from presentation makes reset, limits and testing clearer. This counter must handle multiple clicks, not only one.

**Mission:** Increment #count on every #increment click.

### Starting HTML
```html
<button id="increment">Add one</button><output id="count">0</output>
```

### Worked JavaScript
```javascript
let count = 0;
document.querySelector('#increment').addEventListener('click', () => { count += 1; document.querySelector('#count').textContent = count; });
```

**Hint:** Declare count outside the listener so it survives between clicks.

**Knowledge question:** Why keep a numeric state variable?

**Explanation:** It separates the value from its text presentation

**Try next:** Add a reset control in an exported copy.

## 23. Distinguish target and currentTarget

event.target identifies where an event began; currentTarget is the element whose listener is currently running. Clicking a nested span inside a button makes the span the target but the button the currentTarget for its listener. This distinction matters when controls contain icons or labels. Avoid assuming that every target is the control you need.

**Mission:** Show target id/currentTarget id in #result when the button receives a click.

### Starting HTML
```html
<button id="button"><span id="label">Click nested label</span></button><output id="result"></output>
```

### Worked JavaScript
```javascript
document.querySelector('#button').addEventListener('click', event => { document.querySelector('#result').textContent = event.target.id + '/' + event.currentTarget.id; });
```

**Hint:** Read both properties from the event parameter.

**Knowledge question:** When clicking a span inside a listening button, currentTarget is…

**Explanation:** The button

**Try next:** Click the button padding and compare the result with clicking its label.

## 24. Let events bubble

Many events bubble from their target through ancestor elements. This enables a container to observe interactions on its children. It also explains why more than one listener may run for a single action. stopPropagation prevents later propagation; preventDefault cancels a default action and is a different operation. Use propagation control deliberately rather than adding it everywhere.

**Mission:** Append inner then outer to #trace using listeners on the button and its container.

### Starting HTML
```html
<div id="outer"><button id="inner">Click</button></div><output id="trace"></output>
```

### Worked JavaScript
```javascript
const trace = document.querySelector('#trace');
document.querySelector('#inner').addEventListener('click', () => trace.textContent += 'inner ');
document.querySelector('#outer').addEventListener('click', () => trace.textContent += 'outer');
```

**Hint:** Attach two ordinary click listeners without stopping propagation.

**Knowledge question:** Does preventDefault stop bubbling?

**Explanation:** No, it cancels a default action

**Try next:** Predict how capture: true on the outer listener changes the order.

## 25. Delegate actions from a list

Delegation attaches one listener to a stable parent. Use target.closest to locate the relevant control even when a nested label receives the click. Verify the match belongs to the intended container, then find the row to change. This also works for controls added after the listener was attached. Here each delete control removes its own row.

**Mission:** Use a listener on #tasks to remove the clicked button’s li, including nested span clicks.

### Starting HTML
```html
<ul id="tasks"><li>Read <button data-remove><span>Delete</span></button></li><li>Build <button data-remove>Delete</button></li></ul>
```

### Worked JavaScript
```javascript
const list = document.querySelector('#tasks');
list.addEventListener('click', event => { const button = event.target.closest('[data-remove]'); if (button && list.contains(button)) button.closest('li').remove(); });
```

**Hint:** closest("[data-remove]") handles clicks on nested labels.

**Knowledge question:** Why is delegation useful for dynamic lists?

**Explanation:** The parent listener can handle later children

**Try next:** Add a non-delete button and ensure the handler ignores it.

## 26. React while someone types

The input event fires as a control’s value changes through user input. Read the current value property inside the handler. change often waits until a value is committed, so it is not the same experience for text fields. Do not rely on the original value attribute to read current text. Here a greeting updates with each input event.

**Mission:** Update #greeting to Hello, followed by the trimmed name, or friend when blank.

### Starting HTML
```html
<label>Name <input id="name"></label><p id="greeting">Hello, friend</p>
```

### Worked JavaScript
```javascript
document.querySelector('#name').addEventListener('input', event => { document.querySelector('#greeting').textContent = 'Hello, ' + (event.target.value.trim() || 'friend'); });
```

**Hint:** Use input and value.trim().

**Knowledge question:** Where is the current input text?

**Explanation:** The value property

**Try next:** Try a name containing angle brackets; keep it literal text.

## 27. Handle form submission

A form submit event represents the form-level action, including keyboard submission. Listen on the form rather than only a button click. preventDefault lets a demo handle submission without navigating. In a real application you still need appropriate server validation and an actual save or send operation. This lesson only updates an on-page status; it sends nothing.

**Mission:** Prevent default form submission and display Saved: followed by the topic.

### Starting HTML
```html
<form id="form"><label>Topic <input id="topic" name="topic"></label><button>Save topic</button></form><p id="status"></p>
```

### Worked JavaScript
```javascript
document.querySelector('#form').addEventListener('submit', event => { event.preventDefault(); document.querySelector('#status').textContent = 'Saved: ' + document.querySelector('#topic').value; });
```

**Hint:** The event parameter provides preventDefault().

**Knowledge question:** Why listen for submit on the form?

**Explanation:** It represents the form action, including keyboard submission

**Try next:** Submit by pressing Enter in the preview.

## 28. Collect named fields with FormData

FormData reads successful named controls from a form. Controls need name attributes; disabled controls and unchecked checkboxes are generally excluded. get returns the first matching value, which may be a string or File. In this text-only example, combine the title and level fields into an output. FormData itself does not send data anywhere.

**Mission:** Read the form with FormData and show DOM / Beginner in #summary.

### Starting HTML
```html
<form id="form"><label>Title <input name="title" value="DOM"></label><label>Level <select name="level"><option>Beginner</option><option>Intermediate</option></select></label></form><output id="summary"></output>
```

### Worked JavaScript
```javascript
const data = new FormData(document.querySelector('#form'));
document.querySelector('#summary').textContent = data.get('title') + ' / ' + data.get('level');
```

**Hint:** Construct FormData with the form element.

**Knowledge question:** Which attribute supplies a FormData field key?

**Explanation:** name

**Try next:** Remove a name attribute in an exported copy and see which field disappears.

## 29. Give specific validation feedback

Validation feedback should explain what to fix and connect to the input. Trimming catches whitespace-only values that look nonempty to a simple truthiness check. Set aria-invalid when invalid and clear it when the problem is fixed. Client-side checks help users but do not protect a server on their own. This exercise handles blank and valid submissions without navigation.

**Mission:** Reject whitespace with Enter a task title and aria-invalid=true; show Saved and false for valid input.

### Starting HTML
```html
<form id="form"><label>Task <input id="task" aria-describedby="error"></label><button>Save</button></form><p id="error" role="status"></p>
```

### Worked JavaScript
```javascript
document.querySelector('#form').addEventListener('submit', event => { event.preventDefault(); const field = document.querySelector('#task'); const invalid = !field.value.trim(); field.setAttribute('aria-invalid', String(invalid)); document.querySelector('#error').textContent = invalid ? 'Enter a task title' : 'Saved'; });
```

**Hint:** Use trim, a Boolean invalid value and one render of the feedback.

**Knowledge question:** Does client-side validation secure a server?

**Explanation:** No, the server must validate independently

**Try next:** Focus the invalid field after submission and try the keyboard flow.

## 30. Synchronize a select control

Controls can drive another part of the page. Read a select’s value in a change listener and render from that value. Keep a predictable mapping rather than depending on option label text that may change. Run the render function once during setup as well as on later changes so the initial UI matches the control.

**Mission:** Keep #card data-theme equal to #theme’s current selected value.

### Starting HTML
```html
<label>Theme <select id="theme"><option value="light">Light</option><option value="night">Night</option></select></label><article id="card" data-theme="light">Preview</article>
```

### Worked JavaScript
```javascript
const select = document.querySelector('#theme');
function render() { document.querySelector('#card').dataset.theme = select.value; }
select.addEventListener('change', render); render();
```

**Hint:** Share one render function between setup and change.

**Knowledge question:** Why render once during setup?

**Explanation:** So initial output matches initial state

**Try next:** Add a descriptive text label reflecting the selected theme.

## 31. Build an accessible disclosure

A disclosure button reveals or hides related content. Use a native button, aria-expanded for its state and aria-controls to identify the panel. The hidden property controls visibility and normally removes the panel from layout and accessibility exposure. Keep the visual and accessibility states synchronized in both directions. A CSS-only color change is not enough to communicate the state.

**Mission:** Toggle #panel.hidden and synchronize aria-expanded on every click.

### Starting HTML
```html
<button id="toggle" aria-expanded="false" aria-controls="panel">Show details</button><section id="panel" hidden>Helpful details</section>
```

### Worked JavaScript
```javascript
const button = document.querySelector('#toggle'); const panel = document.querySelector('#panel');
button.addEventListener('click', () => { panel.hidden = !panel.hidden; button.setAttribute('aria-expanded', String(!panel.hidden)); });
```

**Hint:** Use panel.hidden as the state and invert it.

**Knowledge question:** What should aria-expanded describe?

**Explanation:** Whether the controlled content is expanded

**Try next:** Change the button text to Show details or Hide details as appropriate.

## 32. Move focus with a purpose

Focus determines which element receives keyboard input. After an explicit action opens a search area, moving focus to its input can reduce effort. Unexpected focus moves during typing are disruptive, so tie them to a clear user action. Native controls are focusable without adding tabindex. Test the behavior using a keyboard, not only by inspecting an attribute.

**Mission:** Reveal #panel and focus #search when Open search is clicked.

### Starting HTML
```html
<button id="open">Open search</button><div id="panel" hidden><label>Search <input id="search"></label></div>
```

### Worked JavaScript
```javascript
document.querySelector('#open').addEventListener('click', () => { document.querySelector('#panel').hidden = false; document.querySelector('#search').focus(); });
```

**Hint:** Reveal the field before calling focus().

**Knowledge question:** When is moving focus usually appropriate here?

**Explanation:** After the user explicitly opens search

**Try next:** Add a close action that restores focus to the opener in an exported copy.

## 33. Respond to Escape

KeyboardEvent.key describes the key pressed, such as Escape. Handle only the key your feature needs and preserve ordinary typing. For a dismissible panel, closing on Escape should also restore focus to the control that opened it. This exercise starts with an open panel to isolate dismissal. More complex widgets may require additional keyboard behavior and testing.

**Mission:** Close #panel and focus #open when Escape is pressed; ignore other keys.

### Starting HTML
```html
<button id="open">Open panel</button><section id="panel"><label>Note <input id="note"></label></section>
```

### Worked JavaScript
```javascript
document.addEventListener('keydown', event => { if (event.key === 'Escape') { document.querySelector('#panel').hidden = true; document.querySelector('#open').focus(); } });
```

**Hint:** Check the key before changing the page.

**Knowledge question:** Which property identifies the Escape key?

**Explanation:** event.key

**Try next:** Try Escape while focus is inside the input.

## 34. Announce a status update

A status region can let assistive technology announce changes without moving focus. role=status implies polite live-region behavior. Keep the region present before its text changes and make the message useful and concise. A test can check markup and text changes but cannot prove how every screen reader announces it. Manual assistive-technology testing remains important.

**Mission:** On Save draft, update the existing status region to Draft saved locally without moving focus.

### Starting HTML
```html
<button id="save">Save draft</button><p id="status" role="status"></p>
```

### Worked JavaScript
```javascript
document.querySelector('#save').addEventListener('click', () => { document.querySelector('#status').textContent = 'Draft saved locally'; });
```

**Hint:** Change the existing region’s text; do not call focus on it.

**Knowledge question:** Does an automated markup check prove a screen reader announcement?

**Explanation:** No, manual testing is still needed

**Try next:** Write a more useful message for a real save failure.

## 35. Open and close a native dialog

The dialog element provides built-in modal behavior when opened with showModal. A modal needs an accessible name, a clear close control and sensible focus behavior. close dismisses it. Native support reduces custom code, but you must still test the complete keyboard and assistive-technology experience. This lesson uses a labeled dialog and explicit open and close buttons.

**Mission:** Wire Open help to showModal and Close to close.

### Starting HTML
```html
<button id="open">Open help</button><dialog id="help" aria-labelledby="helpTitle"><h2 id="helpTitle">Help</h2><p>Try one step at a time.</p><button id="close">Close</button></dialog>
```

### Worked JavaScript
```javascript
const dialog = document.querySelector('#help');
document.querySelector('#open').addEventListener('click', () => dialog.showModal());
document.querySelector('#close').addEventListener('click', () => dialog.close());
```

**Hint:** Use the same dialog reference in both listeners.

**Knowledge question:** Which method opens a modal dialog?

**Explanation:** showModal()

**Try next:** Check Escape dismissal and focus return manually.

## 36. Render from an array

Treat an array as data and the DOM as its current presentation. A render function maps each item into an element and replaces the list contents. It should be repeatable: rendering twice must not duplicate rows. Keep user-facing strings as textContent. This small pattern becomes the foundation of searchable lists, task boards and many framework components.

**Mission:** Render Ada, Grace and Linus as list items in that order.

### Starting HTML
```html
<ul id="list"></ul>
```

### Worked JavaScript
```javascript
const names = ['Ada', 'Grace', 'Linus'];
function render() { document.querySelector('#list').replaceChildren(...names.map(name => { const li = document.createElement('li'); li.textContent = name; return li; })); }
render();
```

**Hint:** map can create elements, and replaceChildren accepts the spread array.

**Knowledge question:** Why replace list contents during a full render?

**Explanation:** It avoids accumulating duplicate rows

**Try next:** Call render twice and verify that only three rows remain.

## 37. Filter without losing source data

Filtering should derive a visible subset from the original collection, not permanently delete nonmatches. Normalize both query and item text for a forgiving case-insensitive search. Render an empty state when no results match. Clearing the input should restore all items because the source data is still available. This exercise uses textContent-based list items built from an array.

**Mission:** Filter Ada, Grace and Linus case-insensitively; show #empty for no matches and restore all when cleared.

### Starting HTML
```html
<label>Search names <input id="search"></label><ul id="list"></ul><p id="empty" hidden>No matches</p>
```

### Worked JavaScript
```javascript
const names = ['Ada', 'Grace', 'Linus'];
function render() { const term = document.querySelector('#search').value.trim().toLowerCase(); const visible = names.filter(name => name.toLowerCase().includes(term)); document.querySelector('#list').replaceChildren(...visible.map(name => { const li = document.createElement('li'); li.textContent = name; return li; })); document.querySelector('#empty').hidden = visible.length > 0; }
document.querySelector('#search').addEventListener('input', render); render();
```

**Hint:** Keep names unchanged and compute visible in render.

**Knowledge question:** Should filtering overwrite the source collection?

**Explanation:** No, derive a visible subset

**Try next:** Show a result count next to the search field.

## 38. Delete by stable identity

A visible row’s position is not a reliable identity. Filtering and sorting change positions. Store a stable id on each row and use it when selecting the data or DOM node to remove. Here two rows carry explicit ids. Delegation lets one listener handle their buttons, including a nested span. This avoids deleting whichever row happens to share an index.

**Mission:** Delete the row associated with the clicked delete control, preserving the other id.

### Starting HTML
```html
<ul id="list"><li data-id="a">Read <button data-delete><span>Delete</span></button></li><li data-id="b">Build <button data-delete>Delete</button></li></ul>
```

### Worked JavaScript
```javascript
document.querySelector('#list').addEventListener('click', event => { const button = event.target.closest('[data-delete]'); if (!button) return; const row = button.closest('[data-id]'); const id = row.dataset.id; document.querySelectorAll('#list [data-id]').forEach(item => { if (item.dataset.id === id) item.remove(); }); });
```

**Hint:** Read dataset.id from the closest row.

**Knowledge question:** Why use a stable id rather than a displayed index?

**Explanation:** Filtering and sorting can change positions

**Try next:** Describe how to apply the same id to an array.filter update.

## 39. Derive a selection count

A derived value can be calculated from current state instead of maintained as a second independent counter. Counting checked controls avoids drifting out of sync after unchecking or repeated actions. Delegate change events from the group and render once initially. For larger apps the same principle applies to deriving totals from a data model rather than from the DOM.

**Mission:** Show the current number of checked skill boxes in #count.

### Starting HTML
```html
<fieldset id="choices"><legend>Skills</legend><label><input type="checkbox"> Select</label><label><input type="checkbox"> Create</label><label><input type="checkbox"> Listen</label></fieldset><output id="count">0</output>
```

### Worked JavaScript
```javascript
function render() { document.querySelector('#count').textContent = document.querySelectorAll('#choices input:checked').length; }
document.querySelector('#choices').addEventListener('change', render); render();
```

**Hint:** Use the :checked selector inside render.

**Knowledge question:** Why derive the count from selected state?

**Explanation:** It avoids keeping two values synchronized manually

**Try next:** Disable a submit control when zero options are selected.

## 40. Model an empty list

Empty states are part of the interface, not an afterthought. A list can start empty or become empty after deletion. Update its empty-state message after each mutation and once at initialization. Hide the message when rows exist and reveal it when none remain. A useful real-world empty state explains what action the user can take next.

**Mission:** Reveal #empty after the only row is deleted, keeping it hidden initially.

### Starting HTML
```html
<ul id="list"><li>Draft <button id="delete">Delete</button></li></ul><p id="empty" hidden>No tasks yet. Add your first task.</p>
```

### Worked JavaScript
```javascript
function renderEmpty() { document.querySelector('#empty').hidden = document.querySelector('#list').children.length > 0; }
document.querySelector('#delete').addEventListener('click', event => { event.currentTarget.closest('li').remove(); renderEmpty(); }); renderEmpty();
```

**Hint:** Centralize the visibility calculation in a function.

**Knowledge question:** When should empty state be updated?

**Explanation:** At initialization and after relevant changes

**Try next:** Write an empty-state message for a search result versus a new account.

## 41. Remove a listener cleanly

removeEventListener needs the same function reference used to add the listener, along with the matching event type and capture setting. Two identical-looking anonymous functions are different objects. Keep a named handler or stored function reference when later cleanup is required. This prevents old components from continuing to react after they are disabled or removed from use.

**Mission:** Count Ping clicks until Stop listening removes that listener.

### Starting HTML
```html
<button id="ping">Ping</button><button id="stop">Stop listening</button><output id="count">0</output>
```

### Worked JavaScript
```javascript
let count = 0; const ping = document.querySelector('#ping');
function handle() { document.querySelector('#count').textContent = ++count; }
ping.addEventListener('click', handle);
document.querySelector('#stop').addEventListener('click', () => ping.removeEventListener('click', handle));
```

**Hint:** Define handle once and reuse it.

**Knowledge question:** What must removeEventListener receive?

**Explanation:** The same handler function reference

**Try next:** Explain when an AbortController could simplify a group of listener cleanups.

## 42. Handle an action once

The once option removes a listener after its first invocation. It is useful for a one-time interaction, but it is not a substitute for server-side duplicate protection. Here the listener increments a counter only once even if the button is clicked again. Be clear whether an action should be permanently unavailable or just guarded while processing.

**Mission:** Count only the first click on #claim.

### Starting HTML
```html
<button id="claim">Claim demo badge</button><output id="count">0</output>
```

### Worked JavaScript
```javascript
let count = 0;
document.querySelector('#claim').addEventListener('click', () => { document.querySelector('#count').textContent = ++count; }, { once: true });
```

**Hint:** Pass an options object as the third argument.

**Knowledge question:** What does once: true do?

**Explanation:** Removes the listener after its first invocation

**Try next:** Make the button visibly unavailable after the first click as an extra refinement.

## 43. Observe changes to a list

MutationObserver reports selected DOM mutations after the current synchronous work. Configure it narrowly to avoid observing unrelated activity or creating feedback loops. childList tracks added and removed child nodes. disconnect stops future observation. It is often better to update your own UI directly when you control the mutation; observers help when another component owns it.

**Mission:** Observe direct child changes to #list and update #count.

### Starting HTML
```html
<ul id="list"></ul><output id="count">0</output>
```

### Worked JavaScript
```javascript
const list = document.querySelector('#list');
const observer = new MutationObserver(() => { document.querySelector('#count').textContent = list.children.length; });
observer.observe(list, { childList: true });
```

**Hint:** Observe childList on the list, not on the output you modify.

**Knowledge question:** When are mutation observer callbacks delivered?

**Explanation:** After the current synchronous work

**Try next:** Explain how observing and changing the same subtree could cause a loop.

## 44. Schedule a visual update

requestAnimationFrame asks the browser to run a callback before a future repaint. It is useful for coordinating visual work, but it does not mean exactly 60 frames per second. Animation should use timestamps and respect reduced-motion preferences when motion is introduced. This exercise schedules one class update, avoiding a permanent loop.

**Mission:** Add highlight to #box inside a requestAnimationFrame callback.

### Starting HTML
```html
<div id="box" class="card">Ready for a visual update</div>
```

### Worked JavaScript
```javascript
requestAnimationFrame(() => { document.querySelector('#box').classList.add('highlight'); });
```

**Hint:** Pass a function without immediately calling it.

**Knowledge question:** Does requestAnimationFrame guarantee exactly 60 calls per second?

**Explanation:** No, timing depends on the browser and display

**Try next:** Inspect prefers-reduced-motion before adding motion in a real project.

## 45. Separate data updates from custom events

CustomEvent lets one part of a page announce a domain-specific event with detail data. Dispatch it from an element and set bubbles when an ancestor should listen. Keep the event payload small and documented. An event is a notification, not persistent state; late listeners do not automatically receive old notifications. Here a component emits a score for an ancestor to display.

**Mission:** On Award points, dispatch a bubbling scorechange event with detail.score=10; show it through the board listener.

### Starting HTML
```html
<section id="board"><button id="award">Award points</button><output id="score">0</output></section>
```

### Worked JavaScript
```javascript
document.querySelector('#board').addEventListener('scorechange', event => { document.querySelector('#score').textContent = event.detail.score; });
document.querySelector('#award').addEventListener('click', event => { event.currentTarget.dispatchEvent(new CustomEvent('scorechange', { bubbles: true, detail: { score: 10 } })); });
```

**Hint:** Use new CustomEvent with bubbles and detail.

**Knowledge question:** Where does a CustomEvent carry its custom payload?

**Explanation:** detail

**Try next:** Describe how a second independent listener could react to the same event.

## 46. Project: a helpful FAQ

Combine delegation, hidden state and aria-expanded into a reusable FAQ. Each question controls its own answer. Keep sections independent so opening one does not accidentally change another. Native buttons provide keyboard activation, and explicit controls relationships help explain the interface. Automated checks cover state changes; manually check reading order and keyboard use too.

**Mission:** Make both FAQ buttons independently toggle their matching panel and aria-expanded.

### Starting HTML
```html
<div id="faq"><button data-panel="a" aria-controls="a" aria-expanded="false">What is the DOM?</button><p id="a" hidden>A live document tree.</p><button data-panel="b" aria-controls="b" aria-expanded="false">How do I practice?</button><p id="b" hidden>Build one small interaction.</p></div>
```

### Worked JavaScript
```javascript
document.querySelector('#faq').addEventListener('click', event => { const button = event.target.closest('button[data-panel]'); if (!button) return; const panel = document.getElementById(button.dataset.panel); panel.hidden = !panel.hidden; button.setAttribute('aria-expanded', String(!panel.hidden)); });
```

**Hint:** Use dataset.panel to find the associated answer.

**Knowledge question:** What must each FAQ button update?

**Explanation:** Its own panel and expanded state

**Try next:** Add a third question in an exported copy without changing the listener.

## 47. Project: a live character coach

A character counter combines input events, derived values and useful feedback. This example uses JavaScript string.length, which counts UTF-16 code units rather than user-perceived characters; emoji may count as more than one. State that limitation rather than presenting it as universal character counting. Keep the count accurate as text is added and removed, and clearly indicate when the limit is exceeded.

**Mission:** Show length / 20 and reveal #warning only when the text length is over 20.

### Starting HTML
```html
<label>Short note <textarea id="note"></textarea></label><output id="count">0 / 20</output><p id="warning" hidden>Over the practice limit</p>
```

### Worked JavaScript
```javascript
function render() { const length = document.querySelector('#note').value.length; document.querySelector('#count').textContent = length + ' / 20'; document.querySelector('#warning').hidden = length <= 20; }
document.querySelector('#note').addEventListener('input', render); render();
```

**Hint:** Derive length and visibility inside the same render function.

**Knowledge question:** What does JavaScript string.length count?

**Explanation:** UTF-16 code units

**Try next:** Research Intl.Segmenter for user-perceived character counting after completing this build.

## 48. Project: sortable learning cards

Sorting and rendering should preserve the original data when you need multiple views. Copy an array before sort because sort mutates it. Use localeCompare for ordinary text labels and a numeric comparator for numbers. This project toggles between title and duration ordering. Re-rendering cards is simple here because they contain no focused interactive controls.

**Mission:** Render DOM, Events, Forms when sorted by title; Forms, DOM, Events when sorted by minutes.

### Starting HTML
```html
<label>Sort <select id="sort"><option value="title">Title</option><option value="minutes">Minutes</option></select></label><ul id="cards"></ul>
```

### Worked JavaScript
```javascript
const lessons = [{title:'Events',minutes:15},{title:'DOM',minutes:10},{title:'Forms',minutes:5}];
function render() { const key = document.querySelector('#sort').value; const sorted = [...lessons].sort((a,b) => key === 'title' ? a.title.localeCompare(b.title) : a.minutes-b.minutes); document.querySelector('#cards').replaceChildren(...sorted.map(item => { const li=document.createElement('li'); li.textContent=item.title+' · '+item.minutes+' min'; return li; })); }
document.querySelector('#sort').addEventListener('change',render);render();
```

**Hint:** Use [...lessons].sort with a comparator based on the selection.

**Knowledge question:** Does Array.sort mutate its array?

**Explanation:** Yes; copy first when preserving original order matters

**Try next:** Add a descending sort without changing the source array.

## 49. Project: a small task composer

A task composer joins form handling, validation, safe text rendering and event delegation. Reject blank titles, append a row, clear the input and let the user remove individual tasks. Never interpolate a user title into innerHTML. This small end-to-end workflow is more meaningful than a button that only changes color. It still uses in-memory DOM state; refreshing does not save tasks.

**Mission:** Build an add/delete task flow, reject blank input, render titles as text and clear the input on success.

### Starting HTML
```html
<form id="form"><label>Task <input id="title"></label><button>Add task</button></form><p id="status" role="status"></p><ul id="tasks"></ul>
```

### Worked JavaScript
```javascript
const list=document.querySelector('#tasks');
document.querySelector('#form').addEventListener('submit', event => { event.preventDefault(); const field=document.querySelector('#title'); const title=field.value.trim(); if(!title){document.querySelector('#status').textContent='Enter a task';return;} const li=document.createElement('li'); const label=document.createElement('span');label.textContent=title;const button=document.createElement('button');button.textContent='Delete';li.append(label,button);list.append(li);field.value='';document.querySelector('#status').textContent='Task added'; });
list.addEventListener('click',event=>{const button=event.target.closest('button');if(button)button.closest('li').remove();});
```

**Hint:** Create separate label and button nodes instead of an HTML string.

**Knowledge question:** What is missing before claiming tasks survive a refresh?

**Explanation:** A persistence design and its error handling

**Try next:** Extend an exported copy with a second task and an empty-state message.

## 50. Capstone: a searchable resource shelf

Combine rendering, filtering, delegated actions and status feedback into a resource shelf. Keep the source array stable and store bookmarks in a Set keyed by resource id. Filtering should never erase a bookmark. Render button labels from state and announce the action. This capstone tests interactions across views, a common source of bugs in real interfaces. No data is uploaded or permanently stored by this preview.

**Mission:** Build three searchable resources with independent Save/Saved toggles that survive filtering and update aria-pressed and status.

### Starting HTML
```html
<label>Find a resource <input id="search"></label><ul id="resources"></ul><p id="status" role="status"></p>
```

### Worked JavaScript
```javascript
const resources=[{id:'dom',title:'DOM basics'},{id:'events',title:'Event patterns'},{id:'forms',title:'Form design'}];
const saved=new Set();const list=document.querySelector('#resources');
function render(){const term=document.querySelector('#search').value.toLowerCase().trim();list.replaceChildren(...resources.filter(item=>item.title.toLowerCase().includes(term)).map(item=>{const li=document.createElement('li');const label=document.createElement('span');label.textContent=item.title;const button=document.createElement('button');button.dataset.id=item.id;button.textContent=saved.has(item.id)?'Saved':'Save';button.setAttribute('aria-pressed',String(saved.has(item.id)));li.append(label,button);return li;}));}
document.querySelector('#search').addEventListener('input',render);
list.addEventListener('click',event=>{const button=event.target.closest('button[data-id]');if(!button)return;const id=button.dataset.id;if(saved.has(id))saved.delete(id);else saved.add(id);document.querySelector('#status').textContent=saved.has(id)?'Resource saved':'Resource removed';render();});render();
```

**Hint:** Keep resources and saved outside render; derive each visible row from them.

**Knowledge question:** Why key saved items by id rather than filtered position?

**Explanation:** An item keeps its identity across different views

**Try next:** Add a saved-only view, an empty state and a manual keyboard test plan to your exported capstone.

## 51. Initialize two independent counters

A reusable initializer receives a root element and finds controls inside it. State declared inside that initializer belongs to one instance. Global selectors or a single shared counter can accidentally connect independent widgets. Build a counter twice using the same function, then verify that interacting with one leaves the other unchanged.

**Mission:** Initialize each .counter with independent state starting at zero; its button adds one.

### Starting HTML
```html
<section class="counter"><button>Add</button><output>0</output></section><section class="counter"><button>Add</button><output>0</output></section>
```

### Worked JavaScript
```javascript
function mountCounter(root){let value=0;root.querySelector('button').addEventListener('click',()=>{root.querySelector('output').textContent=String(++value);});}
document.querySelectorAll('.counter').forEach(mountCounter);
```

**Hint:** Accept a root parameter and select beneath that root.

**Knowledge question:** Where should instance-specific counter state live?

**Explanation:** Inside each initializer invocation

**Try next:** Add a third counter without adding another initializer function.

## 52. Prevent duplicate initialization

Calling setup twice can register two separate listeners when each call creates a new callback. A WeakSet can record which roots are mounted without attaching application metadata to every node. Idempotent setup means repeating setup does not multiply the action. It does not mean that the action itself can happen only once.

**Mission:** Call mount twice but ensure one click increases output by exactly one.

### Starting HTML
```html
<button id="add">Add</button><output>0</output>
```

### Worked JavaScript
```javascript
const mounted=new WeakSet();
function mount(root){if(mounted.has(root))return;mounted.add(root);let n=0;root.querySelector('#add').addEventListener('click',()=>root.querySelector('output').textContent=String(++n));}
mount(document.body);mount(document.body);
```

**Hint:** Guard setup with a WeakSet before adding listeners.

**Knowledge question:** What does idempotent setup prevent?

**Explanation:** Duplicate installation of the same behavior

**Try next:** Add cleanup that allows a component to be mounted again.

## 53. Return a component cleanup function

A mounted component may later be removed or replaced. Returning a cleanup function makes ownership explicit: the caller can detach listeners before discarding the component. removeEventListener needs the same function reference used during registration. Merely writing a similar anonymous function will not remove the original listener.

**Mission:** Make Add increment output; Dispose must detach that behavior.

### Starting HTML
```html
<button id="add">Add</button><button id="dispose">Dispose counter</button><output>0</output>
```

### Worked JavaScript
```javascript
function mount(){let n=0;const button=document.querySelector('#add');const onClick=()=>document.querySelector('output').textContent=String(++n);button.addEventListener('click',onClick);return ()=>button.removeEventListener('click',onClick);}
const cleanup=mount();document.querySelector('#dispose').addEventListener('click',cleanup);
```

**Hint:** Keep the named handler in a variable shared with cleanup.

**Knowledge question:** What must removeEventListener receive?

**Explanation:** The original listener function reference

**Try next:** Disable the Add button when the counter is disposed.

## 54. Listen for a component’s custom event

CustomEvent lets a component announce an application-level fact without knowing every consumer. Put the payload in detail and choose whether the event bubbles. A containing section can listen for a bubbling event from a nested button. This separates a user action from the parent’s decision about how to display its result.

**Mission:** Dispatch a bubbling topic-chosen event with detail.topic HTML; the panel renders that value.

### Starting HTML
```html
<section id="panel"><button id="choose">Choose HTML</button></section><output></output>
```

### Worked JavaScript
```javascript
document.querySelector('#choose').addEventListener('click',event=>event.currentTarget.dispatchEvent(new CustomEvent('topic-chosen',{bubbles:true,detail:{topic:'HTML'}})));document.querySelector('#panel').addEventListener('topic-chosen',event=>document.querySelector('output').textContent=event.detail.topic);
```

**Hint:** Use new CustomEvent(name,{detail,bubbles:true}).

**Knowledge question:** Where does a CustomEvent carry application data?

**Explanation:** In its detail property

**Try next:** Add a second topic button that uses the same event contract.

## 55. Query inside a shadow root

A shadow root creates a separate DOM subtree with scoped style behavior. document.querySelector does not automatically search inside it. Attach an open shadow root to #host, create its elements, and query through host.shadowRoot. Shadow DOM is encapsulation, not a security boundary; an open root remains accessible to outside code.

**Mission:** Create a button and counter inside an open shadow root; clicks increment its output.

### Starting HTML
```html
<div id="host"></div>
```

### Worked JavaScript
```javascript
const root=document.querySelector('#host').attachShadow({mode:'open'});const button=document.createElement('button');button.textContent='Shadow button';const output=document.createElement('output');output.textContent='0';let n=0;button.addEventListener('click',()=>output.textContent=String(++n));root.append(button,output);
```

**Hint:** Create elements normally, then append them to the shadow root.

**Knowledge question:** How do you select an element in an open shadow root?

**Explanation:** Query through the host’s shadowRoot

**Try next:** Add a style element inside the shadow root and compare its scope.

## 56. Observe capture before bubbling

An event travels through capture, target and bubble phases. Registering a listener with capture:true lets an ancestor observe the event before target and bubble listeners run. Do not confuse the current listener’s element with the original event target. Logging the order makes propagation easier to understand than memorizing a diagram.

**Mission:** Show capture > target > bubble after one button click.

### Starting HTML
```html
<section id="outer"><button id="inner">Trace event</button></section><output></output>
```

### Worked JavaScript
```javascript
const log=[];const record=word=>{log.push(word);document.querySelector('output').textContent=log.join(' > ');};document.querySelector('#outer').addEventListener('click',()=>record('capture'),{capture:true});document.querySelector('#inner').addEventListener('click',()=>record('target'));document.querySelector('#outer').addEventListener('click',()=>record('bubble'));
```

**Hint:** Pass {capture:true} only to the first ancestor listener.

**Knowledge question:** Which ancestor listener runs first here?

**Explanation:** The capture listener

**Try next:** Add another ancestor and predict the expanded trace.

## 57. Cancel a default action, not propagation

preventDefault cancels a cancelable browser action; it does not stop an event reaching ancestors. A form submission is a useful example because an interface often needs to handle data without navigating. Check defaultPrevented on the same event and separately observe that a parent still receives it.

**Mission:** Cancel form submission while allowing its submit event to reach the outer section.

### Starting HTML
```html
<section id="outer"><form id="entry"><button>Submit practice</button></form></section><output></output>
```

### Worked JavaScript
```javascript
document.querySelector('#entry').addEventListener('submit',event=>event.preventDefault());document.querySelector('#outer').addEventListener('submit',event=>document.querySelector('output').textContent=event.defaultPrevented?'Canceled and bubbled':'Not canceled');
```

**Hint:** Inspect event.defaultPrevented in the outer listener.

**Knowledge question:** Does preventDefault stop bubbling?

**Explanation:** No; default behavior and propagation are separate

**Try next:** Try stopPropagation separately and explain the changed result.

## 58. Delegate clicks on nested icons

A click may land on a span inside a button rather than on the button itself. closest finds the nearest matching ancestor, including the target. Delegate one listener from the list and resolve the intended action with closest. Checking that the resolved control belongs to the component avoids accidentally handling unrelated markup.

**Mission:** Remove the correct row even when its nested span receives the click.

### Starting HTML
```html
<ul id="list"><li><button data-id="a"><span>Remove A</span></button></li><li><button data-id="b"><span>Remove B</span></button></li></ul>
```

### Worked JavaScript
```javascript
const list=document.querySelector('#list');list.addEventListener('click',event=>{const button=event.target.closest('button[data-id]');if(!button||!list.contains(button))return;button.closest('li').remove();});
```

**Hint:** Resolve the button first, then resolve its row.

**Knowledge question:** Why use closest rather than assuming target is the button?

**Explanation:** The click can originate on a nested child

**Try next:** Append a new row after setup and verify delegation still works.

## 59. Clean up listeners with AbortController

An AbortController can own several event listeners registered with its signal. Calling abort removes those listeners together. This is useful when a component has several controls that must share a lifecycle. It does not rewind DOM changes already made by earlier events.

**Mission:** Wire Up and Down to shared numeric state; Stop removes both listeners.

### Starting HTML
```html
<button id="up">Up</button><button id="down">Down</button><button id="stop">Stop controls</button><output>0</output>
```

### Worked JavaScript
```javascript
let n=0;const controller=new AbortController();const render=()=>document.querySelector('output').textContent=String(n);document.querySelector('#up').addEventListener('click',()=>{n++;render();},{signal:controller.signal});document.querySelector('#down').addEventListener('click',()=>{n--;render();},{signal:controller.signal});document.querySelector('#stop').addEventListener('click',()=>controller.abort());
```

**Hint:** Use the same signal for both counter listeners.

**Knowledge question:** What does abort do to listeners using its signal?

**Explanation:** It removes those registered listeners

**Try next:** Create a new controller when implementing a restart action.

## 60. Distinguish user input from programmatic value changes

Assigning input.value changes the control but does not automatically dispatch input or change. A render function should therefore be called intentionally after application-driven changes, or a documented custom event can carry the update. Do not depend on browser events that are not emitted by the operation you used.

**Mission:** Reflect typed text and the Use DOM button’s programmatic value in output.

### Starting HTML
```html
<label>Topic <input id="topic"></label><button id="fill">Use DOM</button><output></output>
```

### Worked JavaScript
```javascript
const field=document.querySelector('#topic');function render(){document.querySelector('output').textContent=field.value;}field.addEventListener('input',render);document.querySelector('#fill').addEventListener('click',()=>{field.value='DOM';render();});
```

**Hint:** Share a render function between the input listener and fill action.

**Knowledge question:** Does assigning value automatically emit an input event?

**Explanation:** No; update the view or dispatch an intended event explicitly

**Try next:** Add a Clear button that uses the same render function.

## 61. Read multiple checkbox values

FormData.getAll returns every value for a repeated field name. get returns only the first. Checkbox controls contribute only while checked, so a form can naturally describe a multi-selection. Render the selected values as text and show a clear empty state instead of assuming at least one option was chosen.

**Mission:** Summarize all checked skill values, or show No skills selected.

### Starting HTML
```html
<form id="skills"><label><input type="checkbox" name="skill" value="HTML" checked>HTML</label><label><input type="checkbox" name="skill" value="CSS" checked>CSS</label><button>Summarize</button></form><output></output>
```

### Worked JavaScript
```javascript
document.querySelector('#skills').addEventListener('submit',event=>{event.preventDefault();const skills=new FormData(event.currentTarget).getAll('skill');document.querySelector('output').textContent=skills.length?skills.join(', '):'No skills selected';});
```

**Hint:** Use the shared field name skill.

**Knowledge question:** Which method reads repeated values?

**Explanation:** FormData.getAll

**Try next:** Add a third checkbox without changing the processing code.

## 62. Use native validity with custom guidance

Native constraints such as required and minlength can handle many basic rules. setCustomValidity adds an application-specific message and must be cleared with an empty string when the problem is fixed. A stale custom error can keep a form invalid even after the user enters a good value. Avoid collecting real personal data in practice forms.

**Mission:** Reject the nickname admin, then clear the custom error for a different nickname.

### Starting HTML
```html
<label>Public nickname <input id="nickname" required></label><p id="help" role="status"></p>
```

### Worked JavaScript
```javascript
const field=document.querySelector('#nickname');field.addEventListener('input',()=>{const blocked=field.value.trim().toLowerCase()==='admin';field.setCustomValidity(blocked?'Choose a non-reserved nickname.':'');document.querySelector('#help').textContent=blocked?'Choose a non-reserved nickname.':'Nickname available';});
```

**Hint:** Always set both the invalid and valid branches.

**Knowledge question:** How is a custom validity error cleared?

**Explanation:** Call setCustomValidity with an empty string

**Try next:** Add a visible description explaining the nickname rule before typing.

## 63. Reset state after a native reset

A form reset restores controls to their default values. The reset event is fired before the reset operation completes, so reading current values synchronously can capture the old state. Schedule a small follow-up after the default action, then render from the restored controls. This example uses a microtask and tests the result after the current task.

**Mission:** Update output while typing and after a native form reset to DOM.

### Starting HTML
```html
<form id="preferences"><label>Topic <input id="topic" value="DOM"></label><button type="reset">Reset topic</button></form><output>DOM</output>
```

### Worked JavaScript
```javascript
const form=document.querySelector('#preferences'),field=document.querySelector('#topic');const render=()=>document.querySelector('output').textContent=field.value;field.addEventListener('input',render);form.addEventListener('reset',()=>queueMicrotask(render));
```

**Hint:** Use queueMicrotask(render) inside the reset listener.

**Knowledge question:** Why delay reading values in a reset listener?

**Explanation:** The reset event occurs before the default reset finishes

**Try next:** Store and restore a second field using the same rendering function.

## 64. Render a dependent select

One selection can determine the choices in another control. Rebuild the second select from a clear data map when the first changes. Replace old options rather than appending duplicates, and choose a predictable initial option. Keeping data outside DOM construction makes the mapping easier to test and extend.

**Mission:** Populate HTML/DOM for Web and JSON/Tables for Data without duplicate options.

### Starting HTML
```html
<label>Track <select id="track"><option value="web">Web</option><option value="data">Data</option></select></label><label>Lesson <select id="lesson"></select></label>
```

### Worked JavaScript
```javascript
const topics={web:['HTML','DOM'],data:['JSON','Tables']};const track=document.querySelector('#track'),lesson=document.querySelector('#lesson');function render(){lesson.replaceChildren(...topics[track.value].map(text=>{const option=document.createElement('option');option.value=text;option.textContent=text;return option;}));}track.addEventListener('change',render);render();
```

**Hint:** Use replaceChildren with freshly created option nodes.

**Knowledge question:** Why replace the options instead of continually appending?

**Explanation:** Old choices no longer belong to the selected track

**Try next:** Add a third track and test switching back and forth.

## 65. Prevent duplicate asynchronous submission

A pending operation needs a visible state. Disable the submit action and use a busy flag so repeated submit events do not start overlapping work. Always restore the control in finally. This lesson simulates a local delay, not a real network save; its status must not imply data was sent anywhere.

**Mission:** Run one local simulation at a time; disable the button while waiting and restore it afterward.

### Starting HTML
```html
<form id="save"><button>Simulate save</button></form><output>Ready</output>
```

### Worked JavaScript
```javascript
let busy=false,count=0;const form=document.querySelector('#save'),button=form.querySelector('button'),out=document.querySelector('output');form.addEventListener('submit',async event=>{event.preventDefault();if(busy)return;busy=true;button.disabled=true;out.textContent='Working locally';try{await new Promise(resolve=>setTimeout(resolve,30));out.textContent='Local simulation '+(++count);}finally{busy=false;button.disabled=false;}});
```

**Hint:** Return early when busy, and reset busy in finally.

**Knowledge question:** Why use a busy flag as well as disabling the button?

**Explanation:** Submission can be triggered through more than one path

**Try next:** Add a simulated failure path and verify that the control is still restored.

## 66. Build a tab state model

A tab interface pairs selectable controls with panels. aria-selected communicates the current tab, hidden controls which panel is shown, and tabindex keeps only the active tab in the ordinary tab order. This lesson focuses on state changes through clicks; the next lesson adds arrow-key movement. Do not use tab roles for ordinary page-navigation links.

**Mission:** Select the first tab initially; clicking another updates selected state, tab stops and panel visibility.

### Starting HTML
```html
<div role="tablist"><button id="tab-a" role="tab" aria-controls="panel-a">HTML</button><button id="tab-b" role="tab" aria-controls="panel-b">DOM</button></div><section id="panel-a" role="tabpanel" aria-labelledby="tab-a">Structure</section><section id="panel-b" role="tabpanel" aria-labelledby="tab-b">Behavior</section>
```

### Worked JavaScript
```javascript
const tabs=[...document.querySelectorAll('[role=tab]')];function activate(active){tabs.forEach(tab=>{const selected=tab===active;tab.setAttribute('aria-selected',String(selected));tab.tabIndex=selected?0:-1;document.getElementById(tab.getAttribute('aria-controls')).hidden=!selected;});}tabs.forEach(tab=>tab.addEventListener('click',()=>activate(tab)));activate(tabs[0]);
```

**Hint:** Use aria-controls to find the panel belonging to each tab.

**Knowledge question:** What must stay synchronized in a tab interface?

**Explanation:** Selected state, focusability and visible panel

**Try next:** Combine this state model with keyboard navigation from the next lesson.

## 67. Move through a toolbar with arrow keys

Roving tabindex keeps one item in the Tab sequence while arrow keys move focus inside a widget. Update tabindex as focus moves and prevent the handled arrow key’s default action. This compact toolbar uses three buttons and wraps at the ends. It is a focus exercise, not a complete pattern for every possible widget.

**Mission:** Move focus with Left/Right, wrap at the ends, and keep one button in the Tab sequence.

### Starting HTML
```html
<div id="tools" role="toolbar" aria-label="Formatting"><button tabindex="0">Bold</button><button tabindex="-1">Italic</button><button tabindex="-1">Underline</button></div>
```

### Worked JavaScript
```javascript
const buttons=[...document.querySelectorAll('#tools button')];document.querySelector('#tools').addEventListener('keydown',event=>{if(!['ArrowRight','ArrowLeft'].includes(event.key))return;const index=buttons.indexOf(document.activeElement);if(index<0)return;event.preventDefault();const next=(index+(event.key==='ArrowRight'?1:-1)+buttons.length)%buttons.length;buttons.forEach((b,i)=>b.tabIndex=i===next?0:-1);buttons[next].focus();});
```

**Hint:** Use the currently focused button’s index, not a separate stale index.

**Knowledge question:** What does roving tabindex accomplish?

**Explanation:** One Tab entry point with internal keyboard movement

**Try next:** Add Home and End to move to the first and last buttons.

## 68. Restore focus after a dialog closes

Opening a dialog changes the interaction context. On close, restore focus to the control that launched it so the user can continue from a predictable place. The native dialog handles important modal behavior, but you still need meaningful content and a clear close control. Test both the visible close action and Escape manually.

**Mission:** Open the modal dialog and restore focus to Review details when closed.

### Starting HTML
```html
<button id="open">Review details</button><dialog id="review"><h2>Practice details</h2><p>No information is sent.</p><button id="close">Close</button></dialog>
```

### Worked JavaScript
```javascript
const trigger=document.querySelector('#open'),dialog=document.querySelector('#review');trigger.addEventListener('click',()=>dialog.showModal());document.querySelector('#close').addEventListener('click',()=>{dialog.close();trigger.focus();});dialog.addEventListener('close',()=>trigger.focus());
```

**Hint:** Keep a reference to the opener outside the event handler.

**Knowledge question:** Why restore focus after closing a dialog?

**Explanation:** To preserve a predictable continuation point

**Try next:** Use Escape in the preview and verify focus restoration.

## 69. Synchronize a toggle button

A toggle button uses aria-pressed to expose its on/off state. Keep state separate from the visible label so the same label can remain stable. A native button already supports Enter and Space, so do not add duplicate keyboard click handlers. Render the status and accessibility state from the same boolean.

**Mission:** Toggle aria-pressed and status while keeping the button label stable.

### Starting HTML
```html
<button id="favorite" aria-pressed="false">Favorite lesson</button><output>Not saved</output>
```

### Worked JavaScript
```javascript
let saved=false;const button=document.querySelector('#favorite');button.addEventListener('click',()=>{saved=!saved;button.setAttribute('aria-pressed',String(saved));document.querySelector('output').textContent=saved?'Saved':'Not saved';});
```

**Hint:** Treat one boolean as the source of truth.

**Knowledge question:** Do native buttons need an extra Enter-to-click handler?

**Explanation:** Usually no; native keyboard activation already exists

**Try next:** Add a visible icon without making the icon the only state indicator.

## 70. Build a small accordion with clear state

An accordion contains disclosure buttons linked to content regions. Each button’s aria-expanded value must match its panel’s hidden state. This version allows only one open panel at a time and lets the current panel close. Native details would be simpler when its built-in behavior fits; custom markup requires you to own the state logic.

**Mission:** Allow one expanded section; selecting another closes the previous, and selecting it again closes it.

### Starting HTML
```html
<h2><button aria-controls="a" aria-expanded="false">HTML</button></h2><div id="a" hidden>Content structure</div><h2><button aria-controls="b" aria-expanded="false">DOM</button></h2><div id="b" hidden>Live document objects</div>
```

### Worked JavaScript
```javascript
const buttons=[...document.querySelectorAll('button[aria-controls]')];buttons.forEach(button=>button.addEventListener('click',()=>{const open=button.getAttribute('aria-expanded')!=='true';buttons.forEach(other=>{const expanded=other===button&&open;other.setAttribute('aria-expanded',String(expanded));document.getElementById(other.getAttribute('aria-controls')).hidden=!expanded;});}));
```

**Hint:** Compute the intended new state before closing other panels.

**Knowledge question:** Which two values must agree?

**Explanation:** aria-expanded and the panel’s visibility

**Try next:** Compare this implementation with native details elements.

## 71. Update a record by stable identity

Array positions can change after sorting or filtering. Store stable IDs in data attributes and use those IDs to find the intended record. A delegated action can then remain correct across re-renders. This example edits a title for the record with id b without relying on its current visible index.

**Mission:** Render two identified records and rename only id b to Better Beta.

### Starting HTML
```html
<ul id="items"></ul><button id="rename">Rename B</button>
```

### Worked JavaScript
```javascript
const items=[{id:'a',name:'Alpha'},{id:'b',name:'Beta'}];const list=document.querySelector('#items');function render(){list.replaceChildren(...items.map(item=>{const li=document.createElement('li');li.dataset.id=item.id;li.textContent=item.name;return li;}));}document.querySelector('#rename').addEventListener('click',()=>{items.find(item=>item.id==='b').name='Better Beta';render();});render();
```

**Hint:** Find the data record, update it, then render.

**Knowledge question:** Why use a stable ID instead of a row index?

**Explanation:** Identity survives changes in ordering

**Try next:** Sort the array before renaming and verify the same record changes.

## 72. Render a visible empty state

Filtering can produce zero matches even when the source collection is not empty. Show a clear message for the visible result set and remove it when matches return. Do not insert the empty-state message into your source array as if it were a real record. The UI should derive it from the filtered collection’s length.

**Mission:** Filter the three lessons and show No matching lessons only for an empty result.

### Starting HTML
```html
<label>Search <input id="find"></label><ul id="list"></ul><p id="empty" hidden>No matching lessons</p>
```

### Worked JavaScript
```javascript
const names=['HTML','DOM','CSS'];const field=document.querySelector('#find'),list=document.querySelector('#list');function render(){const shown=names.filter(n=>n.toLowerCase().includes(field.value.toLowerCase()));list.replaceChildren(...shown.map(n=>{const li=document.createElement('li');li.textContent=n;return li;}));document.querySelector('#empty').hidden=shown.length!==0;}field.addEventListener('input',render);render();
```

**Hint:** Update the list and empty message in the same render function.

**Knowledge question:** Should an empty-state message be a source data record?

**Explanation:** No; derive it from the current results

**Try next:** Add the number of visible results in a status region.

## 73. Undo a removal using a snapshot

Undo requires enough information to restore the previous state. For a one-step deletion, a shallow array snapshot is sufficient when the records are not mutated. Save before the change, then render from the restored array. Clearly limit this example to one undo step rather than implying a complete history system.

**Mission:** Remove the final record and offer one-step undo, disabling undo when unavailable.

### Starting HTML
```html
<ul id="list"></ul><button id="remove">Remove last</button><button id="undo" disabled>Undo removal</button>
```

### Worked JavaScript
```javascript
let items=['HTML','CSS','DOM'],previous=null;const list=document.querySelector('#list'),undo=document.querySelector('#undo');function render(){list.replaceChildren(...items.map(text=>{const li=document.createElement('li');li.textContent=text;return li;}));undo.disabled=previous===null;}document.querySelector('#remove').addEventListener('click',()=>{if(!items.length)return;previous=[...items];items=items.slice(0,-1);render();});undo.addEventListener('click',()=>{if(previous===null)return;items=previous;previous=null;render();});render();
```

**Hint:** Keep previous separate from current items.

**Knowledge question:** When should the undo snapshot be captured?

**Explanation:** Before changing the state

**Try next:** Extend to a bounded history of five snapshots.

## 74. Paginate a local collection

Pagination is a view over source data. Compute a slice from page index and page size, then disable controls at the boundaries. Changing the page should not remove records from the source. This small collection has five items and a page size of two, so the last page contains one item.

**Mission:** Show two items per page and disable navigation at the first and last pages.

### Starting HTML
```html
<ul id="list"></ul><button id="prev">Previous</button><button id="next">Next</button><output></output>
```

### Worked JavaScript
```javascript
const items=['One','Two','Three','Four','Five'];let pageIndex=0;const size=2;function render(){document.querySelector('#list').replaceChildren(...items.slice(pageIndex*size,(pageIndex+1)*size).map(text=>{const li=document.createElement('li');li.textContent=text;return li;}));document.querySelector('#prev').disabled=pageIndex===0;document.querySelector('#next').disabled=(pageIndex+1)*size>=items.length;document.querySelector('output').textContent='Page '+(pageIndex+1);}document.querySelector('#prev').addEventListener('click',()=>{if(pageIndex>0){pageIndex--;render();}});document.querySelector('#next').addEventListener('click',()=>{if((pageIndex+1)*size<items.length){pageIndex++;render();}});render();
```

**Hint:** Use slice with pageIndex multiplied by page size.

**Knowledge question:** Does pagination need to delete hidden records?

**Explanation:** No; render a slice of the source data

**Try next:** Show total pages and handle a source collection that becomes empty.

## 75. Keep selection when changing filters

Selection and visibility are different state dimensions. A Set of selected IDs can persist while the list is filtered. Recreate checked controls from that Set when the view changes, rather than inferring all selection from currently visible checkboxes. This distinction matters in bulk-action interfaces.

**Mission:** Preserve checked selections by ID when filtering hides and reveals a record.

### Starting HTML
```html
<label>Filter <input id="filter"></label><div id="list"></div><output>0 selected</output>
```

### Worked JavaScript
```javascript
const items=[{id:'a',name:'Alpha'},{id:'b',name:'Beta'}],selected=new Set();const list=document.querySelector('#list');function render(){list.replaceChildren(...items.filter(x=>x.name.toLowerCase().includes(document.querySelector('#filter').value.toLowerCase())).map(x=>{const label=document.createElement('label'),box=document.createElement('input');box.type='checkbox';box.dataset.id=x.id;box.checked=selected.has(x.id);label.append(box,x.name);return label;}));document.querySelector('output').textContent=selected.size+' selected';}list.addEventListener('change',e=>{const box=e.target;if(!box.matches('input[data-id]'))return;box.checked?selected.add(box.dataset.id):selected.delete(box.dataset.id);render();});document.querySelector('#filter').addEventListener('input',render);render();
```

**Hint:** Render checked from selected.has(id).

**Knowledge question:** Where should persistent selection be stored?

**Explanation:** In state keyed by record ID

**Try next:** Add a clear-selection control that also updates hidden selections.

## 76. Debounce a local search preview

Debouncing waits for a pause before doing work. Clear the previous timeout when another input arrives, then schedule the latest value. This example delays a local output by 40 milliseconds; it does not fetch anything. Debouncing is useful only when waiting improves the experience or reduces meaningful work.

**Mission:** Render only the latest input value after a short 40ms pause.

### Starting HTML
```html
<label>Search draft <input id="search"></label><output>Waiting</output>
```

### Worked JavaScript
```javascript
let timer;document.querySelector('#search').addEventListener('input',event=>{const value=event.currentTarget.value;clearTimeout(timer);timer=setTimeout(()=>document.querySelector('output').textContent=value,40);});
```

**Hint:** Capture the value before the timer callback.

**Knowledge question:** What does clearing the previous timer prevent?

**Explanation:** Outdated scheduled work from running

**Try next:** Add a visible pending indicator and clear it after rendering.

## 77. Cancel a scheduled message

setTimeout returns an identifier that can be passed to clearTimeout. Cancellation should also update the interface so the user knows nothing further is expected. A cancel action cannot undo work that already finished. Keep a visible status for scheduled, canceled and completed states.

**Mission:** Schedule Finished after 60ms; Cancel must prevent it and show Canceled.

### Starting HTML
```html
<button id="schedule">Schedule message</button><button id="cancel">Cancel</button><output>Idle</output>
```

### Worked JavaScript
```javascript
let timer;const out=document.querySelector('output');document.querySelector('#schedule').addEventListener('click',()=>{clearTimeout(timer);out.textContent='Scheduled';timer=setTimeout(()=>out.textContent='Finished',60);});document.querySelector('#cancel').addEventListener('click',()=>{clearTimeout(timer);out.textContent='Canceled';});
```

**Hint:** Store the timeout identifier outside both handlers.

**Knowledge question:** What can clearTimeout cancel?

**Explanation:** A callback that has not yet run

**Try next:** Disable Cancel when there is no pending timer.

## 78. Coalesce visual writes into one frame

requestAnimationFrame schedules work before a future repaint. A pending flag can combine several synchronous requests into one update. The latest state should win when the callback finally runs. Do not use animation frames as a guaranteed clock for background tasks; browsers may pause them in hidden documents.

**Mission:** Coalesce three immediate clicks into one animation-frame render showing 3.

### Starting HTML
```html
<button id="add">Add</button><output>0</output><p id="renders">0 renders</p>
```

### Worked JavaScript
```javascript
let n=0,pending=false,renders=0;document.querySelector('#add').addEventListener('click',()=>{n++;if(pending)return;pending=true;requestAnimationFrame(()=>{pending=false;document.querySelector('output').textContent=String(n);document.querySelector('#renders').textContent=(++renders)+' renders';});});
```

**Hint:** Set pending before requesting the frame and clear it inside the callback.

**Knowledge question:** What should the scheduled render use?

**Explanation:** The latest state when it runs

**Try next:** Add a cleanup function that cancels a pending frame.

## 79. Ignore stale asynchronous results

Two asynchronous requests can finish in a different order from their start order. A sequence number lets the interface accept only the most recent request’s result. This lesson uses local timers to model the race, so there is no network or AI service. The pattern prevents an older response from overwriting a newer choice.

**Mission:** Keep Fast visible when a later fast action finishes before an earlier slow action.

### Starting HTML
```html
<button id="slow">Slow result</button><button id="fast">Fast result</button><output>Idle</output>
```

### Worked JavaScript
```javascript
let latest=0;async function load(label,delay){const id=++latest;document.querySelector('output').textContent='Loading';await new Promise(r=>setTimeout(r,delay));if(id!==latest)return;document.querySelector('output').textContent=label;}document.querySelector('#slow').addEventListener('click',()=>load('Slow',70));document.querySelector('#fast').addEventListener('click',()=>load('Fast',10));
```

**Hint:** Compare a captured request ID with the latest sequence number.

**Knowledge question:** What should determine which result is accepted?

**Explanation:** Whether it belongs to the latest requested action

**Try next:** Add a cancel action that invalidates the latest sequence.

## 80. Disconnect an observer during teardown

MutationObserver can monitor changes, but it should stop when its component no longer needs updates. disconnect stops observation. Pending notifications may require thought in a real lifecycle; this example waits for each mutation before disconnecting so the behavior is clear. Count only the mutations your observer was configured to watch.

**Mission:** Count added list nodes until Stop observing is clicked.

### Starting HTML
```html
<ul id="list"></ul><button id="add">Add item</button><button id="stop">Stop observing</button><output>0</output>
```

### Worked JavaScript
```javascript
let count=0;const list=document.querySelector('#list'),observer=new MutationObserver(records=>{count+=records.reduce((n,r)=>n+r.addedNodes.length,0);document.querySelector('output').textContent=String(count);});observer.observe(list,{childList:true});document.querySelector('#add').addEventListener('click',()=>{const li=document.createElement('li');li.textContent='Item';list.append(li);});document.querySelector('#stop').addEventListener('click',()=>observer.disconnect());
```

**Hint:** Keep the observer reference accessible to the Stop handler.

**Knowledge question:** What does disconnect stop?

**Explanation:** Further observation by that observer

**Try next:** Use takeRecords to inspect pending records before cleanup.

## 81. Measure a rendered element

getBoundingClientRect returns the element’s rendered border box relative to the viewport. Its measurements can be fractional and include transforms. Read after the relevant styles have been applied. This exercise measures a fixed authored fixture, not the entire browser window, so the result is predictable across preview sizes.

**Mission:** Display the box’s rendered width and height as 180 × 80.

### Starting HTML
```html
<div id="box" style="width:180px;height:80px;border:2px solid teal">Measure me</div><button id="measure">Measure</button><output></output>
```

### Worked JavaScript
```javascript
document.querySelector('#measure').addEventListener('click',()=>{const rect=document.querySelector('#box').getBoundingClientRect();document.querySelector('output').textContent=Math.round(rect.width)+' × '+Math.round(rect.height);});
```

**Hint:** Read width and height from getBoundingClientRect().

**Knowledge question:** What coordinate system does the rectangle use?

**Explanation:** The viewport

**Try next:** Apply a scale transform and compare the rendered measurement.

## 82. Read a computed style

element.style only exposes inline declarations. getComputedStyle reports resolved styles after stylesheets and inheritance have been considered. Do not assume it returns the exact authored spelling; colors and lengths are often normalized. This example reads a CSS class’s color rather than an inline value.

**Mission:** Read the sample’s computed color into output.

### Starting HTML
```html
<style>.sample{color:rgb(15,118,110)}</style><p class="sample">Computed color</p><button id="read">Read color</button><output></output>
```

### Worked JavaScript
```javascript
document.querySelector('#read').addEventListener('click',()=>document.querySelector('output').textContent=getComputedStyle(document.querySelector('.sample')).color);
```

**Hint:** Call getComputedStyle(element).color.

**Knowledge question:** Why might element.style.color be empty here?

**Explanation:** The color comes from a stylesheet rule

**Try next:** Set an inline color and inspect how the resolved value changes.

## 83. Observe element resizing

ResizeObserver reacts when a particular element’s observed size changes. This is different from listening only to window resize, because a component can resize for many reasons. Avoid resizing the same target endlessly inside its callback. This example changes a fixed width through a button and reports the observed content width.

**Mission:** Observe #box and report its content width after growing it to 200px.

### Starting HTML
```html
<div id="box" style="width:120px;height:40px;background:#ccfbf1">Resize target</div><button id="grow">Grow to 200</button><output></output>
```

### Worked JavaScript
```javascript
const box=document.querySelector('#box');const observer=new ResizeObserver(entries=>{document.querySelector('output').textContent=String(Math.round(entries[0].contentRect.width));});observer.observe(box);document.querySelector('#grow').addEventListener('click',()=>box.style.width='200px');
```

**Hint:** Read contentRect.width from the delivered entry.

**Knowledge question:** What does ResizeObserver watch?

**Explanation:** Changes to observed element sizes

**Try next:** Add a cleanup action that disconnects the observer.

## 84. Read a media preference

matchMedia evaluates a media query and exposes whether it currently matches. It can also notify you when the result changes. User preferences such as reduced motion should influence nonessential animation without hiding content. This exercise renders an honest status based on the actual browser preference rather than assuming a fixed setting.

**Mission:** Display Reduced motion or Standard motion based on the actual preference.

### Starting HTML
```html
<output id="motion"></output>
```

### Worked JavaScript
```javascript
const query=matchMedia('(prefers-reduced-motion: reduce)');function render(){document.querySelector('#motion').textContent=query.matches?'Reduced motion':'Standard motion';}query.addEventListener('change',render);render();
```

**Hint:** Keep the MediaQueryList and listen for change.

**Knowledge question:** What does matchMedia.matches represent?

**Explanation:** Whether the query currently matches

**Try next:** Switch your operating system’s motion preference and observe the output.

## 85. Use a live region without replacing its container

A status region should exist before you update its text. Replacing the entire region can make announcements less predictable. Keep the container stable and update textContent when an action completes. Automatic tests can verify DOM state, but a real screen-reader test is still needed to evaluate announcement timing and usefulness.

**Mission:** Update the existing status region without replacing its node.

### Starting HTML
```html
<button id="finish">Finish local task</button><p id="status" role="status" aria-live="polite"></p>
```

### Worked JavaScript
```javascript
document.querySelector('#finish').addEventListener('click',()=>document.querySelector('#status').textContent='Local task complete');
```

**Hint:** Set textContent on the existing paragraph.

**Knowledge question:** What can this automated test establish?

**Explanation:** That the region and text are correct, not how every screen reader announces them

**Try next:** Test with a screen reader and document what is announced.

## 86. Render user text without parsing markup

User input is data, not trusted HTML. textContent renders angle brackets literally rather than creating elements from them. Avoid innerHTML for ordinary messages, comments or names. The preview has additional isolation, but the code you export should still use safe DOM construction when handling untrusted content.

**Mission:** Show the input as literal text, including any angle brackets.

### Starting HTML
```html
<label>Message <input id="message"></label><button id="show">Show text</button><p id="result"></p>
```

### Worked JavaScript
```javascript
document.querySelector('#show').addEventListener('click',()=>document.querySelector('#result').textContent=document.querySelector('#message').value);
```

**Hint:** Assign the value to the paragraph’s textContent.

**Knowledge question:** Which API is appropriate for plain user text?

**Explanation:** textContent

**Try next:** Try an image-shaped string and verify it remains text.

## 87. Validate a link destination before assigning it

A URL can carry a scheme such as https or javascript. Validate the parsed protocol before turning user-provided data into an active link. This exercise accepts only HTTPS and rejects malformed or other-scheme values. A production product may need stricter host and path rules; accepting HTTPS alone does not prove a destination is trustworthy.

**Mission:** Set href only for an absolute HTTPS URL; clear href for invalid or other-scheme input.

### Starting HTML
```html
<label>Destination <input id="url"></label><button id="apply">Apply link</button><a id="link">Preview destination</a><output></output>
```

### Worked JavaScript
```javascript
document.querySelector('#apply').addEventListener('click',()=>{const link=document.querySelector('#link');try{const url=new URL(document.querySelector('#url').value);if(url.protocol!=='https:')throw Error('HTTPS required');link.href=url.href;document.querySelector('output').textContent='HTTPS destination set';}catch{link.removeAttribute('href');document.querySelector('output').textContent='Enter an absolute HTTPS URL';}});
```

**Hint:** Use new URL and inspect protocol before assigning href.

**Knowledge question:** Does HTTPS alone prove a destination is trustworthy?

**Explanation:** No; it is only one part of a URL policy

**Try next:** Add an allowed-host list and test deceptive hostnames.

## 88. Handle missing elements intentionally

A selector can return null when markup changes or a component is optional. Decide whether to skip, report or fail clearly rather than letting an unrelated property access throw later. This example reports a useful message when a target is absent and leaves the rest of the page operational.

**Mission:** Handle the absent #optional target without a runtime error.

### Starting HTML
```html
<button id="attempt">Try update</button><output></output>
```

### Worked JavaScript
```javascript
document.querySelector('#attempt').addEventListener('click',()=>{const target=document.querySelector('#optional');if(!target){document.querySelector('output').textContent='Optional panel is not present';return;}target.textContent='Updated';});
```

**Hint:** Check the result before using textContent.

**Knowledge question:** What is a useful response to an expected missing element?

**Explanation:** Handle the null result explicitly

**Try next:** Add the optional panel and make the successful branch observable.

## 89. Debug a selector within its component

A broad query can select a similarly named element elsewhere on the page. Scope selectors to the component root and inspect which node was actually returned. This exercise contains two outputs with the same class, deliberately showing why a global query would update the wrong place.

**Mission:** Update only the output inside #widget.

### Starting HTML
```html
<section id="other"><output class="value">Unchanged</output></section><section id="widget"><button>Update widget</button><output class="value">Waiting</output></section>
```

### Worked JavaScript
```javascript
const root=document.querySelector('#widget');root.querySelector('button').addEventListener('click',()=>root.querySelector('.value').textContent='Widget updated');
```

**Hint:** Keep root in a variable and query beneath it.

**Knowledge question:** What is the simplest way to narrow this selection?

**Explanation:** Query from the widget root

**Try next:** Add a second widget instance and initialize both independently.

## 90. Keep exception feedback useful

An action may fail because its input is malformed. Catch the expected failure, display a useful message, and allow the next attempt to succeed. Do not swallow errors without feedback or expose private data in messages. This exercise parses a small JSON array locally and reports the count; no data is uploaded.

**Mission:** Count a valid JSON array and show a helpful message for malformed or non-array input.

### Starting HTML
```html
<label>JSON array <textarea id="data">["HTML","DOM"]</textarea></label><button id="parse">Count items</button><output></output>
```

### Worked JavaScript
```javascript
document.querySelector('#parse').addEventListener('click',()=>{try{const data=JSON.parse(document.querySelector('#data').value);if(!Array.isArray(data))throw Error('Expected array');document.querySelector('output').textContent=data.length+' items';}catch{document.querySelector('output').textContent='Enter a valid JSON array';}});
```

**Hint:** Validate Array.isArray after JSON.parse.

**Knowledge question:** What should happen after an expected parse failure?

**Explanation:** The user should be able to correct the input and retry

**Try next:** Add a maximum item count and explain the limit before parsing.

## 91. Keep a disclosure synchronized after reset

A component may have more than one action that changes its state. Put the DOM updates in one render function so reset and toggle cannot disagree about visibility or aria-expanded. This is a small example of a single source of truth: the boolean state drives every representation of the disclosure.

**Mission:** Toggle the panel and let Reset return both visibility and aria-expanded to closed.

### Starting HTML
```html
<button id="toggle" aria-controls="panel" aria-expanded="false">Details</button><button id="reset">Reset</button><section id="panel" hidden>Useful details</section>
```

### Worked JavaScript
```javascript
let open=false;function render(){document.querySelector('#panel').hidden=!open;document.querySelector('#toggle').setAttribute('aria-expanded',String(open));}document.querySelector('#toggle').addEventListener('click',()=>{open=!open;render();});document.querySelector('#reset').addEventListener('click',()=>{open=false;render();});render();
```

**Hint:** Both event handlers should change state then call render.

**Knowledge question:** Why centralize the DOM updates in render?

**Explanation:** Every action uses the same state-to-view mapping

**Try next:** Add a Close control inside the panel that uses the same render function.

## 92. Preserve focus while updating a list

Replacing the focused element can send focus somewhere unexpected. For a small list, update existing nodes when their identity has not changed. This exercise increments a row’s count in place so the row button keeps focus. Full keyed reconciliation is more complex, but the principle is simple: avoid destroying useful interaction state unnecessarily.

**Mission:** Increment the row output without replacing the focused button.

### Starting HTML
```html
<ul><li><button id="add">Add point</button><output>0</output></li></ul>
```

### Worked JavaScript
```javascript
let n=0;const button=document.querySelector('#add'),output=document.querySelector('output');button.addEventListener('click',()=>{output.textContent=String(++n);});
```

**Hint:** Update only the output text.

**Knowledge question:** Why avoid replacing this button during rendering?

**Explanation:** It preserves node identity and focus

**Try next:** Compare with replacing the entire row and observe focus behavior.

## 93. Model a dismissible notification

A notification has a small lifecycle: hidden, shown and dismissed. Use a stable status region for the message, and a separate close action when needed. Avoid moving keyboard focus merely to announce routine status. This local demonstration has no timer, so users control how long the notice stays visible.

**Mission:** Show the notice, dismiss it, and return focus to the show control.

### Starting HTML
```html
<button id="show">Show notice</button><section id="notice" hidden><p id="message" role="status"></p><button id="dismiss">Dismiss</button></section>
```

### Worked JavaScript
```javascript
const notice=document.querySelector('#notice');document.querySelector('#show').addEventListener('click',()=>{notice.hidden=false;document.querySelector('#message').textContent='Your local practice is ready';});document.querySelector('#dismiss').addEventListener('click',()=>{notice.hidden=true;document.querySelector('#show').focus();});
```

**Hint:** Keep notification visibility separate from the message content.

**Knowledge question:** Should routine status updates always steal focus?

**Explanation:** No; use an appropriate announcement region

**Try next:** Test announcement behavior with assistive technology.

## 94. Derive a master checkbox state

A master checkbox summarizes a group. It is checked when every item is selected and indeterminate when some but not all are selected. indeterminate is a JavaScript property, not a persistent HTML attribute. Update the summary after individual changes and update all items when the master is toggled.

**Mission:** Keep the master checkbox checked, unchecked or indeterminate to match its two items.

### Starting HTML
```html
<label><input id="all" type="checkbox">All topics</label><label><input class="item" type="checkbox">HTML</label><label><input class="item" type="checkbox">DOM</label>
```

### Worked JavaScript
```javascript
const all=document.querySelector('#all'),items=[...document.querySelectorAll('.item')];function summarize(){const n=items.filter(x=>x.checked).length;all.checked=n===items.length;all.indeterminate=n>0&&n<items.length;}items.forEach(box=>box.addEventListener('change',summarize));all.addEventListener('change',()=>{items.forEach(box=>box.checked=all.checked);summarize();});summarize();
```

**Hint:** Compute the selected count after each change.

**Knowledge question:** What does indeterminate represent here?

**Explanation:** A partial selection

**Try next:** Add a third item and verify all three summary states.

## 95. Design a bounded stepper

A numeric stepper should enforce its range in state updates, not only through visual disabled buttons. Disable unavailable actions as helpful feedback, but keep guards in the handlers too. This example supports values zero through three. It tests repeated attempts at both boundaries without letting the state escape.

**Mission:** Keep the value between 0 and 3 and disable unavailable actions.

### Starting HTML
```html
<button id="minus">Minus</button><output>0</output><button id="plus">Plus</button>
```

### Worked JavaScript
```javascript
let n=0;function render(){document.querySelector('output').textContent=String(n);document.querySelector('#minus').disabled=n===0;document.querySelector('#plus').disabled=n===3;}document.querySelector('#minus').addEventListener('click',()=>{n=Math.max(0,n-1);render();});document.querySelector('#plus').addEventListener('click',()=>{n=Math.min(3,n+1);render();});render();
```

**Hint:** Use Math.min and Math.max, then render both controls.

**Knowledge question:** Why guard state updates as well as disabling controls?

**Explanation:** The state rule should hold regardless of the action path

**Try next:** Make the minimum and maximum configurable.

## 96. Project: an accessible reading switcher

Combine tab state, keyboard movement and panel visibility into one small reading interface. The active tab is the single Tab entry point; Left and Right move and activate tabs. Use linked IDs for tabs and panels. This is a focused two-tab implementation; a production component also needs testing with its actual content and assistive technologies.

**Mission:** Build synchronized two-tab selection with click and wrapping Left/Right keyboard navigation.

### Starting HTML
```html
<div id="tabs" role="tablist" aria-label="Reading topics"><button id="html-tab" role="tab" aria-controls="html-panel">HTML</button><button id="dom-tab" role="tab" aria-controls="dom-panel">DOM</button></div><section id="html-panel" role="tabpanel" aria-labelledby="html-tab">Structure the page.</section><section id="dom-panel" role="tabpanel" aria-labelledby="dom-tab">Make the page respond.</section>
```

### Worked JavaScript
```javascript
const tabs=[...document.querySelectorAll('[role=tab]')];function activate(index,focus=false){tabs.forEach((tab,i)=>{const selected=i===index;tab.tabIndex=selected?0:-1;tab.setAttribute('aria-selected',String(selected));document.getElementById(tab.getAttribute('aria-controls')).hidden=!selected;});if(focus)tabs[index].focus();}tabs.forEach((tab,i)=>{tab.addEventListener('click',()=>activate(i));tab.addEventListener('keydown',e=>{if(!['ArrowLeft','ArrowRight'].includes(e.key))return;e.preventDefault();activate((i+(e.key==='ArrowRight'?1:-1)+tabs.length)%tabs.length,true);});});activate(0);
```

**Hint:** Reuse the same activate function for both input methods.

**Knowledge question:** What makes this more than a visual tab effect?

**Explanation:** Keyboard behavior and accessibility state match the visible panel

**Try next:** Add Home/End navigation and a third tab, then test with a screen reader.

## 97. Project: a validated task queue

A task composer combines form handling, safe text rendering, delegated deletion and status feedback. Trim input and reject blank titles. Use monotonically increasing IDs rather than visible positions. Render all displayed text with textContent so a task containing angle brackets is still a task name, not markup.

**Mission:** Add nonblank tasks safely, clear the input, remove by ID and announce the outcome.

### Starting HTML
```html
<form id="tasks"><label>Task <input id="title"></label><button>Add task</button></form><ul id="list"></ul><p id="status" role="status"></p>
```

### Worked JavaScript
```javascript
let items=[],nextId=1;const list=document.querySelector('#list'),status=document.querySelector('#status');function render(){list.replaceChildren(...items.map(item=>{const li=document.createElement('li'),span=document.createElement('span'),button=document.createElement('button');span.textContent=item.title;button.textContent='Remove';button.dataset.id=String(item.id);li.append(span,button);return li;}));}document.querySelector('#tasks').addEventListener('submit',e=>{e.preventDefault();const field=document.querySelector('#title'),title=field.value.trim();if(!title){status.textContent='Enter a task';return;}items.push({id:nextId++,title});field.value='';status.textContent='Task added';render();});list.addEventListener('click',e=>{const button=e.target.closest('button[data-id]');if(!button)return;items=items.filter(x=>String(x.id)!==button.dataset.id);status.textContent='Task removed';render();});render();
```

**Hint:** Keep the data array separate from DOM construction.

**Knowledge question:** What should happen to a task title that looks like HTML?

**Explanation:** It should render as literal text

**Try next:** Add completion toggles and a filter while preserving stable IDs.

## 98. Project: a local data explorer

A local data explorer combines JSON parsing, validation, sorting and rendering. Accept only arrays of objects with a string name and finite numeric score. Validate every record before changing the displayed collection so a failed import does not partly replace good data. No file or network access is required; the text area is the data source.

**Mission:** Validate all records before replacing the list, then sort descending by numeric score.

### Starting HTML
```html
<label>Records JSON <textarea id="data">[{"name":"Ada","score":8},{"name":"Lee","score":12}]</textarea></label><button id="load">Load records</button><button id="sort">Highest first</button><ul id="list"></ul><p id="status" role="status"></p>
```

### Worked JavaScript
```javascript
let items=[];const list=document.querySelector('#list'),status=document.querySelector('#status');function render(){list.replaceChildren(...items.map(x=>{const li=document.createElement('li');li.textContent=x.name+': '+x.score;return li;}));}document.querySelector('#load').addEventListener('click',()=>{try{const next=JSON.parse(document.querySelector('#data').value);if(!Array.isArray(next)||!next.every(x=>x&&typeof x.name==='string'&&typeof x.score==='number'&&Number.isFinite(x.score)))throw Error('Invalid records');items=next.map(x=>({name:x.name,score:x.score}));render();status.textContent=items.length+' records loaded';}catch{status.textContent='Use an array of name/score records';}});document.querySelector('#sort').addEventListener('click',()=>{items=[...items].sort((a,b)=>b.score-a.score);render();});
```

**Hint:** Validate into a temporary next variable before assigning items.

**Knowledge question:** When should the current collection be replaced?

**Explanation:** After the entire new dataset passes validation

**Try next:** Add a record limit and a filter without mutating the source order.

## 99. Project: a multistep learning planner

A multistep interface needs clear state, validation and predictable focus movement. Keep entered values in the controls as sections are hidden and shown. This three-step planner asks for a goal, then a duration, then shows a summary. It is a local practice interface with no submission or persistence in the preview.

**Mission:** Validate a goal and duration, navigate steps, preserve input and show a final summary.

### Starting HTML
```html
<section id="step1"><label>Learning goal <input id="goal"></label></section><section id="step2" hidden><label>Minutes <input id="minutes" type="number" value="20" min="5" max="120"></label></section><section id="step3" hidden><h2>Plan summary</h2><output></output></section><button id="back">Back</button><button id="next">Next</button><p id="status" role="status"></p>
```

### Worked JavaScript
```javascript
let step=1;const status=document.querySelector('#status');function render(){for(let i=1;i<=3;i++)document.querySelector('#step'+i).hidden=i!==step;document.querySelector('#back').disabled=step===1;document.querySelector('#next').disabled=step===3;if(step===1)document.querySelector('#goal').focus();if(step===2)document.querySelector('#minutes').focus();if(step===3)document.querySelector('output').textContent=document.querySelector('#goal').value.trim()+' — '+document.querySelector('#minutes').value+' minutes';}document.querySelector('#next').addEventListener('click',()=>{if(step===1&&!document.querySelector('#goal').value.trim()){status.textContent='Enter a learning goal';return;}const minutes=Number(document.querySelector('#minutes').value);if(step===2&&(!Number.isFinite(minutes)||minutes<5||minutes>120)){status.textContent='Choose 5 to 120 minutes';return;}if(step<3)step++;status.textContent='Step '+step+' of 3';render();});document.querySelector('#back').addEventListener('click',()=>{if(step>1)step--;render();});render();
```

**Hint:** Change the step only after its current inputs validate.

**Knowledge question:** What should moving between steps preserve?

**Explanation:** The learner’s entered values

**Try next:** Add an editable summary and a text download in your exported project.

## 100. Capstone: a resilient resource dashboard

Combine filtering, stable IDs, selection state, safe rendering, empty feedback and reset into one small dashboard. The source collection remains constant; saved IDs and the query describe the changing view. Derive counts and button labels from state after every action. The test crosses several views to catch bugs that a single happy-path click would miss.

**Mission:** Build searchable resources, persistent saved IDs, saved-only mode, empty feedback and a full reset.

### Starting HTML
```html
<label>Search resources <input id="query"></label><label><input id="savedOnly" type="checkbox">Saved only</label><button id="reset">Reset dashboard</button><ul id="resources"></ul><p id="empty" hidden>No matching resources</p><output></output>
```

### Worked JavaScript
```javascript
const resources=[{id:'html',title:'HTML structure'},{id:'dom',title:'DOM events'},{id:'forms',title:'Form patterns'}],saved=new Set();const list=document.querySelector('#resources'),query=document.querySelector('#query'),only=document.querySelector('#savedOnly');function render(){const term=query.value.trim().toLowerCase();const visible=resources.filter(x=>x.title.toLowerCase().includes(term)&&(!only.checked||saved.has(x.id)));list.replaceChildren(...visible.map(x=>{const li=document.createElement('li'),span=document.createElement('span'),button=document.createElement('button');span.textContent=x.title;button.dataset.id=x.id;button.textContent=saved.has(x.id)?'Unsave':'Save';button.setAttribute('aria-pressed',String(saved.has(x.id)));li.append(span,button);return li;}));document.querySelector('#empty').hidden=visible.length>0;document.querySelector('output').textContent=visible.length+' visible / '+saved.size+' saved';}query.addEventListener('input',render);only.addEventListener('change',render);list.addEventListener('click',event=>{const button=event.target.closest('button[data-id]');if(!button)return;const id=button.dataset.id;saved.has(id)?saved.delete(id):saved.add(id);render();});document.querySelector('#reset').addEventListener('click',()=>{saved.clear();query.value='';only.checked=false;render();query.focus();});render();
```

**Hint:** Use one render function for query, saved-only, toggles and reset.

**Knowledge question:** What keeps state consistent across all dashboard views?

**Explanation:** Rendering each view from stable source data and shared state

**Try next:** Export the project, add a documented local persistence strategy, and manually test keyboard and screen-reader journeys.

