Build an Accessible Tech Dashboard
Build a persistent learning dashboard and test it with keyboard and Fusion.
Introduction
30 Second Summary
A learning list only helps when you can update it independently and trust it to remember your progress. When a page changes silently, it can leave someone unsure whether their action worked.
In this project, you will build a persistent technology learning dashboard with Semantic HTML, CSS, and JavaScript. You will verify keyboard operation with Fusion before recording separate automated results from Accessibility Insights for Web.
What You'll Build
You will demonstrate a dashboard that remembers your learning topics while Fusion announces each update.
By the end of this project, you'll have:
- A persistent learning dashboard that lets you add or remove technology topics with native controls. Your saved list returns after a reload.
- A keyboard and Fusion test showing that the dashboard works without a mouse. You can find its structure and hear complete update messages.
- A reusable accessibility report that separates manual findings from automated findings.
- Secret Mission: Add a labeled progress filter that announces how many topics remain visible.
Are there any prerequisites?
No previous web development experience is required. You need a Windows computer with Fusion available because the project guides you through every other setup.
Before We Start
This first checkpoint locks in who your technology learning dashboard serves before any hands-on work begins. It also makes independent screen-reader operation a product requirement from the start.
Prepare an Accessible Development Workspace
Your dashboard has to work in the same environment you use to test it. Delaying checks with Fusion would let silent accessibility barriers survive until the end.
In this step, you'll verify Visual Studio Code with Live Preview for local development. You'll also prepare Google Chrome for automated checks with Accessibility Insights for Web.
In this step, get ready to:
- Verify Visual Studio Code with Fusion running.
- Install Live Preview and open your local project folder.
- Install Accessibility Insights for Web in Google Chrome.
Verify Visual Studio Code with Fusion
Visual Studio Code can optimize its interface when it detects screen-reader use. Checking that behavior now gives you an editor you can navigate throughout the project.
- Press the Windows key to open Windows Search.
- Type Fusion into Windows Search.
- Press Enter to start Fusion.
- Press the Windows key again.
- Type Command Prompt into Windows Search.
- Press Enter to open Command Prompt.
- Check your Visual Studio Code version by running this command:
code --version
What does this command check?
The code --version command prints the installed Visual Studio Code version. The first line lets you compare your installation with the required release.
✔️ I see the required version
Good. The first line shows 1.138, so your editor is on the required release.
ⓧ I see an older version
Your existing installation needs the current Windows release before you continue.
- Use the official Windows setup guide to download the User setup installer for 1.138.
- Open the downloaded .exe installer.
- Follow the installer prompts to update Visual Studio Code.
- Close Command Prompt after the installation finishes.
- Reopen Command Prompt through Windows Search.
- Verify the updated version by running this command:
code --version
What should I see?
The first output line should now show 1.138.
ⓧ Command not found
Visual Studio Code is missing or its command-line launcher is unavailable. The Windows User setup adds the code command to your system path.
- Use the official Windows setup guide to download the User setup installer for 1.138.
- Open the downloaded .exe installer.
- Follow the installer prompts to install Visual Studio Code.
- Close Command Prompt after the installation finishes.
- Reopen Command Prompt through Windows Search.
- Verify the installation by running this command:
code --version
What should I see?
The first output line should show 1.138. Restarting Command Prompt makes the newly added system path available.
Keep Fusion running while you launch the editor. Its installed version becomes part of the accessibility report later.
- Press the Windows key to open Windows Search.
- Type Visual Studio Code into Windows Search.
- Press Enter to launch Visual Studio Code.
- Press Alt+F1 in the editor to open Accessibility Help.
You'll hear or see Accessibility Help while Fusion remains active. This confirms that the editor exposes its accessibility guidance in your current setup.
Accessibility Help did not open?
Make sure focus is inside the Visual Studio Code window before pressing Alt+F1.
Confirm that Fusion is still running before restarting Visual Studio Code.
Help me verify Visual Studio Code accessibility support with Fusion.
Install Live Preview and create the workspace
Live Preview serves your files from a stable local web address. That stable origin supports the browser storage you add later.
- Switch back to the Command Prompt window from the version check.
- Install the pinned Live Preview extension by running this command:
code --install-extension ms-vscode.live-server@0.4.20
What does this command install?
The command installs extension ms-vscode.live-server at version 0.4.20. Pinning the version keeps the project instructions consistent with the extension you use.
The terminal prints installation progress followed by a successful completion message.
Live Preview installation failed?
Confirm that code --version works in the same Command Prompt window. Restart the console if Visual Studio Code was installed moments ago.
Check your internet connection before repeating the installation command.
Help me install the pinned Live Preview extension.
- List your installed Visual Studio Code extensions with their versions by running this command:
code --list-extensions --show-versions
What does this command verify?
The command prints one installed extension per line. The Live Preview entry includes its publisher name and installed version.
✔️ I see the pinned extension
The output includes ms-vscode.live-server@0.4.20. Live Preview is installed at the required version.
ⓧ I see a different version
- Replace the existing version with the pinned release by running this command:
code --install-extension ms-vscode.live-server@0.4.20
What does this command change?
Visual Studio Code installs Live Preview at version 0.4.20 using the extension's canonical identifier.
- Verify the replacement by running this command:
code --list-extensions --show-versions
What should I see?
The extension list should now include ms-vscode.live-server@0.4.20.
ⓧ The extension is not listed
- Install the missing extension by running this command:
code --install-extension ms-vscode.live-server@0.4.20
What does this command change?
Visual Studio Code downloads the pinned Live Preview package and adds it to your installed extensions.
- Confirm the installation by running this command:
code --list-extensions --show-versions
What should I see?
The output should now include ms-vscode.live-server@0.4.20.
The project needs a named folder inside Documents so every later file has a predictable location.
- Press Windows+E to open File Explorer.
- Select Documents in the left navigation pane.
- Select New.
- Select Folder.
- Type accessible-learning-dashboard as the folder name.
- Press Enter to create the folder.
You'll see accessible-learning-dashboard listed inside Documents. That visible folder confirms the local workspace now exists.
Visual Studio Code may display a Workspace Trust dialog when the folder opens. You created this empty folder yourself, so you can trust its contents.
- Switch back to Visual Studio Code from earlier.
- Select File from the menu bar.
- Select Open Folder....
- Choose Documents/accessible-learning-dashboard in the folder dialog.
- Select Select Folder.
- Select Yes, I trust the authors if Workspace Trust appears.
The Explorer view shows accessible-learning-dashboard as the open workspace. Your project folder is ready for the dashboard files.
Prepare Chrome for accessibility checks
Google Chrome provides the browser environment used with Fusion. Accessibility Insights for Web adds an automated check that complements your manual keyboard and screen-reader testing.
- Press the Windows key to open Windows Search.
- Type Google Chrome into Windows Search.
- Press Enter to launch the browser.
✔️ Google Chrome opens
Google Chrome is ready for the extension installation below.
ⓧ Google Chrome is not installed
- Download the Windows installation file from the official Google Chrome page.
- Open the downloaded installation file from Downloads.
- Follow the installer instructions.
- Launch Google Chrome through Windows Search after the installation finishes.
You'll see a Google Chrome window open when the installation is complete.
No Google sign-in is required for this project. The extension installs directly from its official Chrome Web Store listing.
- Open the official Accessibility Insights for Web listing in Google Chrome.
- Confirm that the listing identifies version 2.49.0.
- Select Add to Chrome.
- Select Add extension when Chrome asks for confirmation.
That completes the browser setup. Accessibility Insights for Web is now available beside your manual Fusion checks.
Before you use the shortcut, what do you expect Chrome to open?
- Press Ctrl+Shift+K in Google Chrome.
You'll see Accessibility Insights for Web open. The shortcut confirms that the extension is installed and available for the audits later in the project.
✔️ The required extension opens
The official listing shows 2.49.0. The extension also opens with Ctrl+Shift+K.
ⓧ I see an older version
- Right-click the Accessibility Insights for Web icon beside the Chrome address bar.
- Select Remove from Chrome.
- Select Remove to confirm.
- Return to the official Chrome Web Store listing.
- Select Add to Chrome.
- Select Add extension.
- Press Ctrl+Shift+K to verify the replacement.
The listing should now identify version 2.49.0.
ⓧ The extension does not open
Chrome also provides a keyboard route through the browser toolbar.
- Press Shift+Alt+T to move focus to the Chrome toolbar.
- Press Left Arrow until Chrome identifies Accessibility Insights for Web.
- Press Space to open the extension.
The extension still does not open?
Return to the official listing and confirm that the extension is installed. Reinstall it if the page still shows Add to Chrome.
Help me open Accessibility Insights for Web in Chrome.
Before the final check, what three results should prove that your editor and browser tools are ready?
- Verify the Visual Studio Code version and installed extension list by running these commands in Command Prompt:
code --version
code --list-extensions --show-versions
What should these checks prove?
The first command confirms your editor release. The second command confirms the pinned Live Preview installation.
- Press Ctrl+Shift+K in Google Chrome to complete the browser check.
The terminal prints 1.138 and ms-vscode.live-server@0.4.20. Accessibility Insights for Web opens in Chrome at version 2.49.0.
One of the final checks failed?
Restart Command Prompt if the code command is unavailable after installation. Repeat the matching setup path above if a version differs.
Use the Chrome toolbar fallback if the Accessibility Insights shortcut does not respond.
Help me complete the workspace verification.
Your accessibility test bench is ready. Next, you'll build the semantic dashboard shell and inspect its structure with Fusion.
Build the Semantic Dashboard Shell
Your accessible development workspace is ready. The dashboard now needs a page structure that browsers and Fusion can expose clearly before any interactive behavior is added.
You will build that foundation with Semantic HTML in Visual Studio Code. Native elements give the dashboard built-in accessibility behavior.
In this step, get ready to:
- Create the dashboard structure with landmarks and labeled native controls.
- Style the page with readable spacing and persistent keyboard focus.
- Verify the page structure with Fusion in Google Chrome.
Create the semantic HTML structure
A semantic page shell gives each region a clear purpose. The first version includes a page title, a skip link, a header, and a main landmark.
- In the Explorer sidebar, activate the new-file control for the Documents/accessible-learning-dashboard folder.
- Enter index.html as the file name.
- Add the initial document shell with this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Accessible Tech Learning Dashboard</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<header>
<div class="page-width">
<h1>Accessible Tech Learning Dashboard</h1>
<p>Track the technologies you want to learn and the progress you make.</p>
</div>
</header>
<main id="main-content" class="page-width" tabindex="-1">
</main>
</body>
</html>
What Does This Structure Provide?
- The title gives the browser tab a meaningful name.
- The skip link targets #main-content so keyboard users can bypass the header.
- The main element exposes the page's primary landmark.
- The tabindex="-1" value lets the skip link move focus to the main landmark.
Live Preview serves the folder from a local web server. Google Chrome displays that served page for each visual and Fusion check.
- Save index.html.
- Press Ctrl+Shift+P in VS Code to open the command launcher.
- Type Live Preview: Show Preview (External Browser) into the command launcher.
- Press Enter to run the highlighted command.
Chrome opens a page titled Accessible Tech Learning Dashboard. You should see the dashboard heading and its introductory sentence.
Does the Browser Show a Blank Page?
Confirm that index.html is saved inside Documents/accessible-learning-dashboard. Check that the opening and closing HTML tags match the snippet.
Run Live Preview: Show Preview (External Browser) again from the VS Code command launcher.
Help me troubleshoot my initial HTML page in Live Preview.
The form needs visible labels and native controls so its purpose survives outside the visual layout. A fieldset groups the progress choices under one shared legend.
- In index.html, place the cursor before the closing </main> tag.
- Add the topic form shell with this code:
<section aria-labelledby="add-topic-heading">
<h2 id="add-topic-heading">Add a learning topic</h2>
<form id="topic-form">
<div class="field">
<label for="topic-name">Topic name</label>
<input
id="topic-name"
name="topic-name"
type="text"
aria-describedby="topic-help"
required
>
<p id="topic-help" class="help-text">
For example: Python basics or accessible game menus.
</p>
</div>
<fieldset>
<legend>Starting progress</legend>
</fieldset>
<button type="submit">Add topic</button>
</form>
</section>
How Does the Form Get Its Names?
- The section references add-topic-heading for its accessible name.
- The label's for value matches the topic-name input ID.
- The input references topic-help so its example can be included in the control description.
- The native submit button provides built-in keyboard operation.
- Save index.html.
- Refresh the dashboard in Chrome.
You should see an Add a learning topic section with a Topic name field. You should also see the Starting progress group and Add topic button.
Is the Form Missing?
Check that the new section sits inside main. It must appear before the closing </main> tag.
Confirm that the for="topic-name" value matches id="topic-name" exactly.
Help me find a missing form in my semantic HTML.
Radio buttons with the same name value form one choice group. The checked option provides a predictable starting value.
- In index.html, place the cursor below the Starting progress legend.
- Add the first two progress choices with this code:
<div class="choice">
<input
id="progress-not-started"
name="progress"
type="radio"
value="not-started"
checked
>
<label for="progress-not-started">Not started</label>
</div>
<div class="choice">
<input
id="progress-in-progress"
name="progress"
type="radio"
value="in-progress"
>
<label for="progress-in-progress">In progress</label>
</div>
What Makes These One Group?
- Both controls use name="progress" so selecting one clears the other.
- Each visible label matches its radio button ID.
- The checked attribute makes Not started the initial selection.
- Save index.html.
- Refresh the dashboard in Chrome.
You should see Not started and In progress inside the Starting progress group. Not started should be selected.
Are the Radio Buttons Outside the Group?
Confirm that both choice elements sit between the opening and closing fieldset tags.
Check each label against its matching input ID. A single character difference breaks that connection.
Help me repair my progress radio group.
- In index.html, place the cursor below the In progress choice.
- Add the final progress choice with this code:
<div class="choice">
<input
id="progress-complete"
name="progress"
type="radio"
value="complete"
>
<label for="progress-complete">Complete</label>
</div>
How Does the Final Choice Fit?
The Complete radio button shares name="progress" with the other choices. Its visible label matches progress-complete exactly.
- Save index.html.
- Refresh the dashboard in Chrome.
You should now see three progress choices. Selecting Complete should clear the previous selection.
Can More Than One Choice Stay Selected?
Check that every radio input uses name="progress". Different names create separate groups.
Help me fix radio buttons that allow multiple selections.
The second section gives the future topic list a clear heading. Its empty message also tells you the current state without relying on a blank area.
- In index.html, place the cursor below the Add a learning topic section.
- Add the learning topics section with this code:
<section aria-labelledby="topics-heading">
<h2 id="topics-heading">Your learning topics</h2>
<p id="empty-message">No topics yet. Add your first learning topic.</p>
<ul id="topic-list" class="topic-list"></ul>
</section>
What Does the Empty List Communicate?
- The section uses topics-heading as its accessible name.
- The empty-message paragraph explains why no list items are present.
- The topic-list element preserves list semantics for the topics added later.
- Save index.html.
- Refresh the dashboard in Chrome.
You should see the Your learning topics heading followed by the empty-state message. The page now exposes one main landmark and a complete form.
Is the Topics Section Missing?
Confirm that the second section remains inside main. Check that aria-labelledby="topics-heading" matches the heading ID.
Help me repair the learning topics section.
✔️ Awesome, I've got everything!
Your saved index.html now contains the complete semantic dashboard shell.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Accessible Tech Learning Dashboard</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<header>
<div class="page-width">
<h1>Accessible Tech Learning Dashboard</h1>
<p>Track the technologies you want to learn and the progress you make.</p>
</div>
</header>
<main id="main-content" class="page-width" tabindex="-1">
<section aria-labelledby="add-topic-heading">
<h2 id="add-topic-heading">Add a learning topic</h2>
<form id="topic-form">
<div class="field">
<label for="topic-name">Topic name</label>
<input
id="topic-name"
name="topic-name"
type="text"
aria-describedby="topic-help"
required
>
<p id="topic-help" class="help-text">
For example: Python basics or accessible game menus.
</p>
</div>
<fieldset>
<legend>Starting progress</legend>
<div class="choice">
<input
id="progress-not-started"
name="progress"
type="radio"
value="not-started"
checked
>
<label for="progress-not-started">Not started</label>
</div>
<div class="choice">
<input
id="progress-in-progress"
name="progress"
type="radio"
value="in-progress"
>
<label for="progress-in-progress">In progress</label>
</div>
<div class="choice">
<input
id="progress-complete"
name="progress"
type="radio"
value="complete"
>
<label for="progress-complete">Complete</label>
</div>
</fieldset>
<button type="submit">Add topic</button>
</form>
</section>
<section aria-labelledby="topics-heading">
<h2 id="topics-heading">Your learning topics</h2>
<p id="empty-message">No topics yet. Add your first learning topic.</p>
<ul id="topic-list" class="topic-list"></ul>
</section>
</main>
</body>
</html>
What Should Match?
Compare the nesting and identifiers carefully. Every label, description, section name, and skip-link target depends on an exact match.
Style the dashboard for clear focus
CSS makes the semantic structure easier to scan without changing its meaning. Responsive widths keep the page readable when the browser window becomes narrow.
- In the Explorer sidebar, activate the new-file control for the Documents/accessible-learning-dashboard folder.
- Enter styles.css as the file name.
- Add the global page styles with this code:
:root {
color: #111111;
background: #ffffff;
font-family: system-ui, sans-serif;
font-size: 1rem;
line-height: 1.6;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.page-width {
width: min(100% - 2rem, 48rem);
margin-inline: auto;
}
What Do the Global Styles Control?
- The root rule sets dark text against a white background.
- The system font and line height keep paragraphs readable.
- The page-width rule adds side spacing and limits long lines.
- The universal sizing rule keeps borders inside declared widths.
- Save styles.css.
- Refresh the dashboard in Chrome.
The page should now use a readable system font. Its content should sit within a centered column with space at both sides.
Does the Page Still Use Default Spacing?
Confirm that styles.css is saved beside index.html. Check that the stylesheet link uses href="styles.css".
Help me connect my stylesheet to the dashboard.
- Add the skip-link and header styles below the existing code in styles.css with this code:
.skip-link {
position: absolute;
top: 0;
left: 0;
padding: 0.75rem 1rem;
color: #ffffff;
background: #111111;
transform: translateY(-120%);
}
.skip-link:focus {
transform: translateY(0);
}
header {
padding-block: 1.5rem;
color: #ffffff;
background: #16324f;
}
header h1,
header p {
margin-block: 0 0.5rem;
}
How Does the Header Support Navigation?
- The header uses light text on a dark background for clear contrast.
- The skip link stays above the page while it is hidden from the normal visual flow.
- Keyboard focus moves the skip link into view.
- Save styles.css.
- Refresh the dashboard in Chrome.
The header should now have a dark blue background with white text. The skip link should remain ready to appear when it receives keyboard focus.
Is the Header Still White?
Check that the header rule appears after the closing brace for .skip-link:focus. Confirm that every declaration ends inside the correct braces.
Help me fix my header and skip-link styles.
- Add the content spacing and inherited control typography below the existing code in styles.css with this code:
main {
padding-block: 1.5rem 3rem;
}
section + section {
margin-top: 2.5rem;
}
.field,
fieldset {
margin-bottom: 1.25rem;
}
label,
legend {
font-weight: 700;
}
input[type="text"],
select,
button {
font: inherit;
}
Why Share These Rules?
- The main area receives space above and below its content.
- Adjacent sections receive enough separation to remain distinct.
- Labels and legends use heavier text to identify controls.
- Inputs and buttons inherit the page font for consistent sizing.
- Save styles.css.
- Refresh the dashboard in Chrome.
You should see more space around the main content and between the two sections. The Topic name label and Starting progress legend should appear heavier.
Are the Sections Still Crowded?
Confirm that section + section includes the plus sign. Check that the form elements remain inside their original sections in index.html.
Help me repair the dashboard spacing rules.
- Add the text-input and fieldset styles below the existing code in styles.css with this code:
input[type="text"],
select {
display: block;
width: 100%;
max-width: 32rem;
margin-top: 0.35rem;
padding: 0.7rem;
border: 2px solid #444444;
border-radius: 0.25rem;
}
fieldset {
max-width: 32rem;
padding: 1rem;
border: 2px solid #444444;
}
.choice + .choice {
margin-top: 0.5rem;
}
.help-text {
margin-block: 0.35rem 0;
}
How Do These Rules Support Reflow?
- The text input can shrink with the page while keeping a readable maximum width.
- The input and fieldset use visible borders that remain understandable without color alone.
- The choice and help-text rules separate related content without excessive gaps.
- Save styles.css.
- Refresh the dashboard in Chrome.
The Topic name field should now stretch within a readable maximum width. The Starting progress choices should sit inside a bordered group.
Is the Topic Field Still Narrow?
Check the selector spelling for input[type="text"]. Confirm that the input in index.html still uses type="text".
Help me fix the text-input or fieldset layout.
- Add the button and keyboard-focus styles below the existing code in styles.css with this code:
button {
padding: 0.65rem 1rem;
border: 2px solid #005a9c;
border-radius: 0.25rem;
color: #ffffff;
background: #005a9c;
cursor: pointer;
}
button:hover {
border-color: #003f6b;
background: #003f6b;
}
:focus-visible {
outline: 3px solid #ffbf47;
outline-offset: 3px;
}
Why Keep Focus Visible?
- The button has a visible boundary and text contrast in its default state.
- The hover rule gives pointer users a clear state change.
- The :focus-visible rule adds a persistent yellow outline for keyboard navigation.
- Save styles.css.
- Refresh the dashboard in Chrome.
- Press Tab repeatedly to move through the link and form controls.
The Add topic button should appear blue with white text. Every focused link or control should receive a yellow outline.
Is Keyboard Focus Hard to See?
Confirm that the selector begins with a colon and reads :focus-visible. Check that the outline has a width of 3px.
Help me restore the visible keyboard focus outline.
The remaining selectors prepare consistent list cards and reusable visually hidden text. These patterns preserve readable visual structure while keeping assistive text available to screen readers.
- Add the topic-list support styles below the existing code in styles.css with this code:
.topic-list {
padding: 0;
list-style: none;
}
.topic-card {
margin-block: 1rem;
padding: 1rem;
border: 2px solid #767676;
border-radius: 0.35rem;
}
.topic-card h3 {
margin-top: 0;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
What Do These Utility Styles Provide?
- The topic list removes default list padding while keeping its list semantics.
- Topic cards receive reusable spacing and borders.
- The visually-hidden class removes text from the visual layout while preserving it for assistive technology.
- Save styles.css.
- Use the find control in styles.css to locate .visually-hidden.
You should find one complete visually hidden rule immediately after the topic-card heading rule. This confirms the reusable support styles are saved in the stylesheet.
Can You Not Find the Utility Rule?
Check the bottom of styles.css for the .topic-list, .topic-card, and .visually-hidden selectors. Confirm that each selector has a closing brace.
Help me check the utility selectors in my stylesheet.
✔️ Awesome, I've got everything!
Your saved styles.css now covers layout, contrast, controls, focus, topic cards, and visually hidden text.
ⓧ I'd like to double check the full code
:root {
color: #111111;
background: #ffffff;
font-family: system-ui, sans-serif;
font-size: 1rem;
line-height: 1.6;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.page-width {
width: min(100% - 2rem, 48rem);
margin-inline: auto;
}
.skip-link {
position: absolute;
top: 0;
left: 0;
padding: 0.75rem 1rem;
color: #ffffff;
background: #111111;
transform: translateY(-120%);
}
.skip-link:focus {
transform: translateY(0);
}
header {
padding-block: 1.5rem;
color: #ffffff;
background: #16324f;
}
header h1,
header p {
margin-block: 0 0.5rem;
}
main {
padding-block: 1.5rem 3rem;
}
section + section {
margin-top: 2.5rem;
}
.field,
fieldset {
margin-bottom: 1.25rem;
}
label,
legend {
font-weight: 700;
}
input[type="text"],
select,
button {
font: inherit;
}
input[type="text"],
select {
display: block;
width: 100%;
max-width: 32rem;
margin-top: 0.35rem;
padding: 0.7rem;
border: 2px solid #444444;
border-radius: 0.25rem;
}
fieldset {
max-width: 32rem;
padding: 1rem;
border: 2px solid #444444;
}
.choice + .choice {
margin-top: 0.5rem;
}
.help-text {
margin-block: 0.35rem 0;
}
button {
padding: 0.65rem 1rem;
border: 2px solid #005a9c;
border-radius: 0.25rem;
color: #ffffff;
background: #005a9c;
cursor: pointer;
}
button:hover {
border-color: #003f6b;
background: #003f6b;
}
:focus-visible {
outline: 3px solid #ffbf47;
outline-offset: 3px;
}
.topic-list {
padding: 0;
list-style: none;
}
.topic-card {
margin-block: 1rem;
padding: 1rem;
border: 2px solid #767676;
border-radius: 0.35rem;
}
.topic-card h3 {
margin-top: 0;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
What Should Match?
Compare the selector order, property values, and closing braces. The final file should match this reference exactly.
Test the shell with Fusion
Why Test Before Adding JavaScript?
This check reveals what the browser exposes from HTML alone. Clear headings, landmarks, labels, and native controls create a dependable base for later interaction.
Testing now isolates structural problems before dynamic updates add more moving parts. Each result points back to a small section of index.html.
Before you begin the check, which page regions and controls do you expect Fusion to expose?
- Switch back to the dashboard running in Chrome.
- Press Insert+T to hear the current window title.
- Press H repeatedly to move through the headings.
- Press Q to move to the main region.
- Press E to move to the Topic name edit field.
- Press A repeatedly to move through the progress radio buttons.
- Press B to move to the Add topic button.
- Press Tab repeatedly to inspect the visible focus outline.
Fusion should announce Accessible Tech Learning Dashboard as the title. It should also expose one main region and the three-page heading structure.
Fusion should identify the Topic name edit field, the Starting progress group, its three choices, and the Add topic button. Keyboard focus should remain visible as a yellow outline.
Does Fusion Miss a Name or Region?
Return to index.html and compare every for value with its control ID. Check each aria-labelledby value against the matching heading ID.
Confirm that the page contains one main element. Keep the radio buttons inside the Starting progress fieldset.
Help me investigate a structure or control name that Fusion does not announce correctly.
That is a strong first win. Your dashboard now communicates its structure visually and through Fusion before a single interactive feature exists.
Your semantic dashboard shell is ready. Next, you will add keyboard-operated topic controls and experience the accessibility gap created by a silent visual update.
Add Topics and Discover the Silent Update
Your semantic dashboard already gives Fusion a clear page title. It also exposes headings, landmarks, and labeled controls.
The next risk appears when JavaScript changes the DOM. A topic card can appear visually while Fusion receives no message about the update.
In this step, get ready to:
- Connect the dashboard to JavaScript.
- Render in-memory topics with descriptive native controls.
- Compare visual updates with Fusion feedback during keyboard operation.
Connect the dashboard to JavaScript
The dashboard needs a script that can read its form controls. The script also needs a temporary array for the topics added during this browser session.
- Return to Visual Studio Code from earlier.
- Select the accessible-learning-dashboard folder in the file list.
- Use the new-file control beside the folder name to create app.js.
You should see app.js beside index.html and styles.css in the file list.
- Select index.html from the file list.
- Find the closing body tag at the bottom of the file:
</body>
What does this line mark?
The closing body tag marks the end of the page content. Loading the script directly above it lets the browser process the dashboard elements first.
- Insert the script reference immediately above the closing body tag so the bottom of the file looks like this:
<script src="app.js"></script>
</body>
How does the script connect?
The script element loads app.js from the same folder as index.html. The browser can then use the script to respond to the form.
- Save index.html.
✔️ Awesome, I've got everything!
Great. Your HTML now loads app.js before the page body closes.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Accessible Tech Learning Dashboard</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<header>
<div class="page-width">
<h1>Accessible Tech Learning Dashboard</h1>
<p>Track the technologies you want to learn and the progress you make.</p>
</div>
</header>
<main id="main-content" class="page-width" tabindex="-1">
<section aria-labelledby="add-topic-heading">
<h2 id="add-topic-heading">Add a learning topic</h2>
<form id="topic-form">
<div class="field">
<label for="topic-name">Topic name</label>
<input
id="topic-name"
name="topic-name"
type="text"
aria-describedby="topic-help"
required
>
<p id="topic-help" class="help-text">
For example: Python basics or accessible game menus.
</p>
</div>
<fieldset>
<legend>Starting progress</legend>
<div class="choice">
<input
id="progress-not-started"
name="progress"
type="radio"
value="not-started"
checked
>
<label for="progress-not-started">Not started</label>
</div>
<div class="choice">
<input
id="progress-in-progress"
name="progress"
type="radio"
value="in-progress"
>
<label for="progress-in-progress">In progress</label>
</div>
<div class="choice">
<input
id="progress-complete"
name="progress"
type="radio"
value="complete"
>
<label for="progress-complete">Complete</label>
</div>
</fieldset>
<button type="submit">Add topic</button>
</form>
</section>
<section aria-labelledby="topics-heading">
<h2 id="topics-heading">Your learning topics</h2>
<p id="empty-message">No topics yet. Add your first learning topic.</p>
<ul id="topic-list" class="topic-list"></ul>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
- Switch back to app.js.
- Add the form references and progress formatter by entering this code:
const form = document.querySelector("#topic-form");
const topicName = document.querySelector("#topic-name");
const topicList = document.querySelector("#topic-list");
const emptyMessage = document.querySelector("#empty-message");
let topics = [];
function formatProgress(progress) {
if (progress === "in-progress") {
return "In progress";
}
if (progress === "complete") {
return "Complete";
}
return "Not started";
}
What does this code do?
- The four document.querySelector() calls connect the script to the existing form, text field, topic list, and empty message.
- The topics array holds topic objects for the current page session.
- The formatProgress() function converts each radio value into readable text for the topic card.
- Save app.js.
- Return to the Google Chrome preview from earlier.
- Reload the page.
You should see the existing dashboard form and its empty-topic message. This confirms the page still loads after connecting app.js.
Did the dashboard stop loading?
Confirm that app.js is in the same accessible-learning-dashboard folder as index.html. Check that the script reference spells the filename exactly.
Check the brackets and quotation marks in formatProgress() against the code above.
Help me fix the JavaScript connection in my dashboard.
Render topics from the form
Each topic is stored as an object with a name and progress value. The render function turns those objects into list items with headings, progress text, and descriptive native buttons.
- Switch back to app.js.
- Add the rendering function below formatProgress() by entering this code:
function renderTopics() {
topicList.replaceChildren();
emptyMessage.hidden = topics.length > 0;
topics.forEach((topic, index) => {
const item = document.createElement("li");
const heading = document.createElement("h3");
const progress = document.createElement("p");
const removeButton = document.createElement("button");
item.className = "topic-card";
heading.textContent = topic.name;
progress.textContent = `Progress: ${formatProgress(topic.progress)}`;
removeButton.type = "button";
removeButton.textContent = `Remove ${topic.name}`;
removeButton.addEventListener("click", () => {
removeTopic(index);
});
item.appendChild(heading);
item.appendChild(progress);
item.appendChild(removeButton);
topicList.appendChild(item);
});
}
renderTopics();
How are topic cards built?
- The empty replaceChildren() call clears old cards before rebuilding the list.
- Each topic becomes a native list item with an h3 heading and readable progress text.
- Each native button receives a descriptive accessible name such as Remove Python basics.
- The topic index connects each Remove button to the matching object in the array.
- Save app.js.
- Return to the Google Chrome preview.
- Reload the page.
You should still see No topics yet. Add your first learning topic. because the in-memory array starts empty. Seeing that message confirms the initial render completed.
Did the empty message disappear?
Check that topics starts as an empty array. Confirm that the hidden check uses topics.length > 0.
Help me debug the initial topic render.
- In app.js, find the final renderTopics() call.
- Insert the form submission listener immediately above that call by entering this code:
form.addEventListener("submit", (event) => {
event.preventDefault();
const name = topicName.value.trim();
const selectedProgress = document.querySelector(
'input[name="progress"]:checked',
);
if (!name || !selectedProgress) {
topicName.focus();
return;
}
topics.push({
name,
progress: selectedProgress.value,
});
renderTopics();
});
What happens on submission?
- The submit listener uses preventDefault() to keep the browser on the current page.
- The selected topic name and radio value become one object in topics.
- The guard returns focus to the text field if the required values are unavailable.
- The final renderTopics() call rebuilds the visible list from the updated array.
- Save app.js.
Before you submit, predict whether Fusion will report the new topic without you navigating away from the Add topic button.
- Return to the Google Chrome preview with Fusion running.
- Move keyboard focus to the Topic name field.
- Enter Keyboard shortcuts.
- Move keyboard focus to the In progress radio button.
- Select the In progress radio button.
- Move keyboard focus to the Add topic button.
- Press Enter.
You should see a Keyboard shortcuts card with Progress: In progress and a Remove Keyboard shortcuts button.
The visual interaction works. Fusion remains on the Add topic button without a reliable announcement that the list changed.
Why is the update silent?
The script inserts a new card into the page. Fusion has no dedicated message that describes the action or the updated total.
Manual navigation can find the card after the change. Independent operation needs immediate feedback at the moment the update happens.
Remove topics with the keyboard
A native button already supplies keyboard behavior. Its topic-specific name also tells Fusion exactly which item the action affects.
- Return to app.js.
- Add removeTopic() below renderTopics() by entering this code:
function removeTopic(index) {
topics.splice(index, 1);
renderTopics();
topicName.focus();
}
How does removal work?
- The splice() call removes the topic at the button's matching array index.
- The next render removes the corresponding card from the visible list.
- The final focus() call returns keyboard focus to the Topic name field.
- Save app.js.
- Return to the Google Chrome preview.
- Move keyboard focus to the Remove Keyboard shortcuts button.
- Press Enter.
You should see the Keyboard shortcuts card disappear. Keyboard focus returns to the Topic name field.
Fusion still receives no complete message about the removal. The same accessibility barrier affects both types of dynamic update.
Does the Remove button do nothing?
Confirm that removeTopic() appears after the closing brace of renderTopics(). Check that the function calls topics.splice(index, 1) before rendering again.
Help me fix the Remove button.
- In app.js, scroll to the bottom of the submit listener.
- Find this code:
topics.push({
name,
progress: selectedProgress.value,
});
renderTopics();
});
renderTopics();
What does the current ending do?
The current ending stores the new topic and renders its card. The form values remain unchanged after submission.
- Replace that ending with this version:
topics.push({
name,
progress: selectedProgress.value,
});
renderTopics();
topicName.value = "";
document.querySelector("#progress-not-started").checked = true;
topicName.focus();
});
renderTopics();
What changes after submission?
The topic name clears after a successful addition. The progress choice returns to Not started.
Focus returns to the Topic name field so the next keyboard action begins from a predictable location.
- Save app.js.
✔️ Awesome, I've got everything!
Great. Double check that you saved app.js before testing the complete keyboard flow.
ⓧ I'd like to double check the full code
const form = document.querySelector("#topic-form");
const topicName = document.querySelector("#topic-name");
const topicList = document.querySelector("#topic-list");
const emptyMessage = document.querySelector("#empty-message");
let topics = [];
function formatProgress(progress) {
if (progress === "in-progress") {
return "In progress";
}
if (progress === "complete") {
return "Complete";
}
return "Not started";
}
function renderTopics() {
topicList.replaceChildren();
emptyMessage.hidden = topics.length > 0;
topics.forEach((topic, index) => {
const item = document.createElement("li");
const heading = document.createElement("h3");
const progress = document.createElement("p");
const removeButton = document.createElement("button");
item.className = "topic-card";
heading.textContent = topic.name;
progress.textContent = `Progress: ${formatProgress(topic.progress)}`;
removeButton.type = "button";
removeButton.textContent = `Remove ${topic.name}`;
removeButton.addEventListener("click", () => {
removeTopic(index);
});
item.appendChild(heading);
item.appendChild(progress);
item.appendChild(removeButton);
topicList.appendChild(item);
});
}
function removeTopic(index) {
topics.splice(index, 1);
renderTopics();
topicName.focus();
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const name = topicName.value.trim();
const selectedProgress = document.querySelector(
'input[name="progress"]:checked',
);
if (!name || !selectedProgress) {
topicName.focus();
return;
}
topics.push({
name,
progress: selectedProgress.value,
});
renderTopics();
topicName.value = "";
document.querySelector("#progress-not-started").checked = true;
topicName.focus();
});
renderTopics();
Before the final check, predict which details Fusion can report immediately after a topic is added or removed.
- Return to the Google Chrome preview with Fusion running.
- Move keyboard focus to the Topic name field.
- Enter Semantic HTML.
- Move keyboard focus to the Complete radio button.
- Select the Complete radio button.
- Move keyboard focus to the Add topic button.
- Press Enter.
You should see the Semantic HTML card with a Complete progress state. Focus returns to the Topic name field without a complete Fusion announcement.
- Enter Keyboard focus in the Topic name field.
- Move keyboard focus to the In progress radio button.
- Select the In progress radio button.
- Move keyboard focus to the Add topic button.
- Press Enter.
- Move keyboard focus to the Remove Keyboard focus button.
- Press Enter.
You should see the Keyboard focus card appear and disappear. Fusion gives no reliable topic name, action, or remaining total at either moment.
You found the barrier through direct testing: the dashboard works visually and through the keyboard, while its dynamic updates remain silent to Fusion. Next, you will expose complete status messages without moving focus to them.
Make Dynamic Changes Speak
Your dashboard already changes its DOM when someone adds or removes a topic. However, Fusion remains silent because the visual update has no message for assistive technology.
A status region gives each change a complete announcement. The message can speak while keyboard focus stays on a predictable control.
In this step, get ready to:
- Add an empty status region to the page.
- Send validation, addition, and removal messages through one announcement function.
- Repeat the keyboard test with Fusion.
Add the status region
The status container must exist before an update happens. Its content starts empty because JavaScript supplies each message when the dashboard changes.
- Switch back to index.html in the Visual Studio Code Explorer sidebar.
- Find the closing </section> tag for the learning topics section.
- Add the empty status container below that section by copying this code:
<p
id="status-message"
class="visually-hidden"
role="status"
aria-atomic="true"
></p>
How does the status region work?
- The role="status" attribute exposes polite updates to assistive technology.
- The aria-atomic="true" attribute asks Fusion to announce the complete message after each update.
- The visually-hidden class keeps the message available to assistive technology without adding visible text to the layout.
- Save index.html.
- Confirm that #status-message sits inside main after the learning topics section.
Status region in the wrong place?
Check that the status paragraph appears before the closing </main> tag. Keep it outside both dashboard sections so it can report changes from either one.
Compare the four attributes carefully if Fusion cannot detect later announcements.
Help me check my status region markup.
✔️ Awesome, I've got everything!
Good work. Your page now has a dedicated place for dynamic announcements.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Accessible Tech Learning Dashboard</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<header>
<div class="page-width">
<h1>Accessible Tech Learning Dashboard</h1>
<p>Track the technologies you want to learn and the progress you make.</p>
</div>
</header>
<main id="main-content" class="page-width" tabindex="-1">
<section aria-labelledby="add-topic-heading">
<h2 id="add-topic-heading">Add a learning topic</h2>
<form id="topic-form">
<div class="field">
<label for="topic-name">Topic name</label>
<input
id="topic-name"
name="topic-name"
type="text"
aria-describedby="topic-help"
required
>
<p id="topic-help" class="help-text">
For example: Python basics or accessible game menus.
</p>
</div>
<fieldset>
<legend>Starting progress</legend>
<div class="choice">
<input
id="progress-not-started"
name="progress"
type="radio"
value="not-started"
checked
>
<label for="progress-not-started">Not started</label>
</div>
<div class="choice">
<input
id="progress-in-progress"
name="progress"
type="radio"
value="in-progress"
>
<label for="progress-in-progress">In progress</label>
</div>
<div class="choice">
<input
id="progress-complete"
name="progress"
type="radio"
value="complete"
>
<label for="progress-complete">Complete</label>
</div>
</fieldset>
<button type="submit">Add topic</button>
</form>
</section>
<section aria-labelledby="topics-heading">
<h2 id="topics-heading">Your learning topics</h2>
<p id="empty-message">No topics yet. Add your first learning topic.</p>
<ul id="topic-list" class="topic-list"></ul>
</section>
<p
id="status-message"
class="visually-hidden"
role="status"
aria-atomic="true"
></p>
</main>
<script src="app.js"></script>
</body>
</html>
What should I compare?
Check the placement of #status-message near the end of main. Confirm that the existing app.js script remains immediately before </body>.
JavaScript needs a reference to the new element before it can change the message. One small helper then gives every dashboard action the same announcement path.
- Switch back to app.js.
- Select the opening section from const form through the end of formatProgress().
- Replace the selected section with this updated opening code:
const form = document.querySelector("#topic-form");
const topicName = document.querySelector("#topic-name");
const topicList = document.querySelector("#topic-list");
const emptyMessage = document.querySelector("#empty-message");
const statusMessage = document.querySelector("#status-message");
let topics = [];
function formatProgress(progress) {
if (progress === "in-progress") {
return "In progress";
}
if (progress === "complete") {
return "Complete";
}
return "Not started";
}
function announce(message) {
statusMessage.textContent = message;
}
What does this code do?
- The statusMessage constant connects JavaScript to #status-message.
- The announce() function places a complete message inside the status region.
- Updating textContent triggers the polite announcement without focusing the hidden paragraph.
- Save app.js.
Seeing a JavaScript error?
Check that #status-message is spelled identically in index.html and app.js.
Make sure announce() appears after the closing brace of formatProgress().
Help me connect my JavaScript to the status region.
Connect announcements to every outcome
Each announcement needs enough context to make sense without looking at the page. Removal messages identify the topic before the array changes because that object disappears during the update.
- Return to the dashboard preview in Google Chrome.
- Enter Screen reader testing in the Topic name field.
- Activate Add topic to place the test topic in the list.
You should see a topic card for Screen reader testing. This gives the removal path a topic to report.
- Switch back to app.js.
- Find the current removeTopic() function.
- Replace that function with this announced removal flow:
function removeTopic(index) {
const removedTopic = topics[index];
topics.splice(index, 1);
renderTopics();
announce(`${removedTopic.name} removed. ${topics.length} topics remain.`);
topicName.focus();
}
How does removal stay predictable?
- The removedTopic constant preserves the topic name before splice() removes it.
- The announcement reports the removed topic plus the remaining total.
- The final focus() call returns keyboard focus to the Topic name field.
- Save app.js.
- Return to the dashboard preview.
- Reach Remove Screen reader testing using the keyboard.
- Activate the removal button.
Fusion should announce Screen reader testing removed. 0 topics remain. Focus returns to the Topic name field.
Removal still sounds silent?
Confirm that announce() runs after renderTopics() inside removeTopic().
Check that Fusion is running before you activate the removal button.
Help me debug the removal announcement.
The submit path has two outcomes. It reports missing input during validation or announces the complete topic details after a successful addition.
- Find the current submit listener near the bottom of app.js.
- Replace the listener with this announced submission flow:
form.addEventListener("submit", (event) => {
event.preventDefault();
const name = topicName.value.trim();
const selectedProgress = document.querySelector(
'input[name="progress"]:checked',
);
if (!name || !selectedProgress) {
announce("Enter a topic name and choose a progress value.");
topicName.focus();
return;
}
topics.push({
name,
progress: selectedProgress.value,
});
renderTopics();
announce(
`${name} added with progress ${formatProgress(selectedProgress.value)}. ${topics.length} total topics.`,
);
topicName.value = "";
document.querySelector("#progress-not-started").checked = true;
topicName.focus();
});
What does the submission announce?
- The validation branch sends one instruction when required topic information is missing.
- The success branch includes the topic name plus its readable progress.
- The success branch also includes the total number of topics.
- The final focus() call prepares the form for another keyboard entry.
- Save app.js.
- Return to the dashboard preview.
- Enter Keyboard accessibility in the Topic name field.
- Select In progress.
- Activate Add topic.
Fusion should announce Keyboard accessibility added with progress In progress. 1 total topics. Focus returns to the empty Topic name field.
Addition appears but stays silent?
Check that the successful announce() call appears after renderTopics().
Confirm that the message uses selectedProgress.value inside formatProgress().
Help me debug the addition announcement.
✔️ Awesome, I've got everything!
That is the silent-update barrier repaired. Save app.js before the final keyboard test.
ⓧ I'd like to double check the full code
const form = document.querySelector("#topic-form");
const topicName = document.querySelector("#topic-name");
const topicList = document.querySelector("#topic-list");
const emptyMessage = document.querySelector("#empty-message");
const statusMessage = document.querySelector("#status-message");
let topics = [];
function formatProgress(progress) {
if (progress === "in-progress") {
return "In progress";
}
if (progress === "complete") {
return "Complete";
}
return "Not started";
}
function announce(message) {
statusMessage.textContent = message;
}
function renderTopics() {
topicList.replaceChildren();
emptyMessage.hidden = topics.length > 0;
topics.forEach((topic, index) => {
const item = document.createElement("li");
const heading = document.createElement("h3");
const progress = document.createElement("p");
const removeButton = document.createElement("button");
item.className = "topic-card";
heading.textContent = topic.name;
progress.textContent = `Progress: ${formatProgress(topic.progress)}`;
removeButton.type = "button";
removeButton.textContent = `Remove ${topic.name}`;
removeButton.addEventListener("click", () => {
removeTopic(index);
});
item.appendChild(heading);
item.appendChild(progress);
item.appendChild(removeButton);
topicList.appendChild(item);
});
}
function removeTopic(index) {
const removedTopic = topics[index];
topics.splice(index, 1);
renderTopics();
announce(`${removedTopic.name} removed. ${topics.length} topics remain.`);
topicName.focus();
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const name = topicName.value.trim();
const selectedProgress = document.querySelector(
'input[name="progress"]:checked',
);
if (!name || !selectedProgress) {
announce("Enter a topic name and choose a progress value.");
topicName.focus();
return;
}
topics.push({
name,
progress: selectedProgress.value,
});
renderTopics();
announce(
`${name} added with progress ${formatProgress(selectedProgress.value)}. ${topics.length} total topics.`,
);
topicName.value = "";
document.querySelector("#progress-not-started").checked = true;
topicName.focus();
});
renderTopics();
What should I compare?
Confirm that app.js contains one statusMessage reference plus one announce() helper. Check the validation branch plus the successful add and remove paths.
Repeat the keyboard test
The earlier test proved that a visual update could remain silent. This repeat checks that complete messages now reach Fusion without sending focus to the status paragraph.
Before the final test, what do you expect Fusion to announce when a topic is removed or added?
- Reach Remove Keyboard accessibility using the keyboard.
- Activate the removal button.
Fusion should announce Keyboard accessibility removed. 0 topics remain. Focus returns to the Topic name field.
- Enter ARIA practice in the Topic name field.
- Select Complete.
- Reach Add topic using the keyboard.
- Activate Add topic.
Fusion should announce ARIA practice added with progress Complete. 1 total topics. Focus returns to the Topic name field.
You repaired the barrier without moving focus to the message itself. The visual list plus the spoken result now describe the same change.
Your dashboard now speaks every successful change while keeping keyboard focus predictable. Next, you will preserve the topic list across reloads and document the complete accessibility test.
Save Progress and Run an Accessibility Audit
Your dashboard now gives Fusion a complete message whenever a topic changes. Keyboard focus also returns to the Topic name field after each action.
A useful dashboard must preserve its state after a reload. An accessibility claim also needs repeatable evidence from manual testing plus Accessibility Insights for Web.
In this step, get ready to:
- Save topics in browser storage so they return after a reload.
- Test the complete dashboard with the keyboard and Fusion.
- Run an automated accessibility check and document the results.
Persist topics across reloads
Browser storage keeps data for the page's local origin across browser sessions. The Live Preview server provides the stable origin that localStorage needs.
- In app.js, add this function immediately below formatProgress() to restore saved topics:
function loadTopics() {
try {
const savedTopics = localStorage.getItem(storageKey);
if (!savedTopics) {
return [];
}
const parsedTopics = JSON.parse(savedTopics);
return Array.isArray(parsedTopics) ? parsedTopics : [];
} catch {
return [];
}
}
How does loading work?
- The storage key identifies this dashboard's saved topic data.
- The function retrieves the stored JSON text and converts it back into a JavaScript value.
- The array check prevents another type of stored value from replacing the topic list.
- The fallback returns an empty array when data is missing or unavailable.
- Replace the opening declarations in app.js with these storage-aware declarations:
const storageKey = "accessible-learning-dashboard-topics";
const form = document.querySelector("#topic-form");
const topicName = document.querySelector("#topic-name");
const topicList = document.querySelector("#topic-list");
const emptyMessage = document.querySelector("#empty-message");
const statusMessage = document.querySelector("#status-message");
let topics = loadTopics();
What do these declarations change?
- The storageKey value gives the topic data a consistent name in browser storage.
- The topics array now starts with the result from loadTopics().
- JavaScript can call the function here because function declarations are available before their position in the file.
- Save app.js.
- Return to the Live Preview page from earlier.
- Refresh the page to confirm the dashboard loads with the empty-topic message or any data already stored under this key.
The page loading confirms that the dashboard can safely read its storage area. The next change writes each new topic into that same area.
Does the dashboard stop loading?
Check that loadTopics() appears below formatProgress(). Confirm that the opening declaration calls the function with matching capitalization.
Check each brace in loadTopics() against the snippet above.
Help me debug topic loading in app.js.
- In app.js, add this function immediately below loadTopics() to save the current topic array:
function saveTopics() {
try {
localStorage.setItem(storageKey, JSON.stringify(topics));
return true;
} catch {
return false;
}
}
How does saving work?
- The JSON conversion turns the topic array into text that browser storage can keep.
- The function returns true when the write succeeds.
- The function returns false when browser storage is unavailable.
- In the form submission listener, replace the block that adds the topic and announces it with this storage-aware block:
topics.push({
name,
progress: selectedProgress.value,
});
const saved = saveTopics();
renderTopics();
const storageResult = saved
? ""
: " The topic is available for this session, but browser storage is unavailable.";
announce(
`${name} added with progress ${formatProgress(selectedProgress.value)}. ${topics.length} total topics.${storageResult}`,
);
What changes after an addition?
- The new topic enters the in-memory array before saveTopics() writes the complete array.
- The dashboard renders the updated list after the storage attempt.
- The announcement includes a session-only fallback when the browser rejects the write.
- Save app.js.
- Add Semantic HTML with Not started progress.
- Add Accessible names with In progress selected.
Before you reload, consider whether both topics will return after the page rebuilds.
- Reload the Live Preview page in Chrome.
You should see both saved topics return with their selected progress values. Your first persistent dashboard state is working.
Do the topics disappear after reload?
Confirm that saveTopics() runs after topics.push(). Check that storageKey has the same spelling in both storage functions.
Keep the page on its Live Preview address because browser storage belongs to that local origin.
Help me debug topic persistence after adding a topic.
- Replace the existing removeTopic() function in app.js with this persistent version:
function removeTopic(index) {
const removedTopic = topics[index];
topics.splice(index, 1);
const saved = saveTopics();
renderTopics();
const storageResult = saved
? ""
: " The change is available for this session, but browser storage is unavailable.";
announce(
`${removedTopic.name} removed. ${topics.length} topics remain.${storageResult}`,
);
topicName.focus();
}
What changes after a removal?
- The selected topic leaves the array before the new state is saved.
- The announcement reports the removed topic plus the remaining total.
- The session-only fallback preserves a complete message when storage fails.
- Focus returns to the Topic name field after the update.
- Save app.js.
- Remove Semantic HTML from the dashboard.
- Reload the Live Preview page.
You should see only Accessible names after the reload. This confirms that removals persist too.
Does the removed topic return?
Confirm that saveTopics() runs after topics.splice(). The save must happen before the refreshed page calls loadTopics().
Help me debug a removed topic that returns after reload.
✔️ Awesome, I've got everything!
Great. Save app.js and keep the Live Preview page available for the manual audit.
ⓧ I'd like to double check the full code
const storageKey = "accessible-learning-dashboard-topics";
const form = document.querySelector("#topic-form");
const topicName = document.querySelector("#topic-name");
const topicList = document.querySelector("#topic-list");
const emptyMessage = document.querySelector("#empty-message");
const statusMessage = document.querySelector("#status-message");
let topics = loadTopics();
function formatProgress(progress) {
if (progress === "in-progress") {
return "In progress";
}
if (progress === "complete") {
return "Complete";
}
return "Not started";
}
function loadTopics() {
try {
const savedTopics = localStorage.getItem(storageKey);
if (!savedTopics) {
return [];
}
const parsedTopics = JSON.parse(savedTopics);
return Array.isArray(parsedTopics) ? parsedTopics : [];
} catch {
return [];
}
}
function saveTopics() {
try {
localStorage.setItem(storageKey, JSON.stringify(topics));
return true;
} catch {
return false;
}
}
function announce(message) {
statusMessage.textContent = message;
}
function renderTopics() {
topicList.replaceChildren();
emptyMessage.hidden = topics.length > 0;
topics.forEach((topic, index) => {
const item = document.createElement("li");
const heading = document.createElement("h3");
const progress = document.createElement("p");
const removeButton = document.createElement("button");
item.className = "topic-card";
heading.textContent = topic.name;
progress.textContent = `Progress: ${formatProgress(topic.progress)}`;
removeButton.type = "button";
removeButton.textContent = `Remove ${topic.name}`;
removeButton.addEventListener("click", () => {
removeTopic(index);
});
item.appendChild(heading);
item.appendChild(progress);
item.appendChild(removeButton);
topicList.appendChild(item);
});
}
function removeTopic(index) {
const removedTopic = topics[index];
topics.splice(index, 1);
const saved = saveTopics();
renderTopics();
const storageResult = saved
? ""
: " The change is available for this session, but browser storage is unavailable.";
announce(
`${removedTopic.name} removed. ${topics.length} topics remain.${storageResult}`,
);
topicName.focus();
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const name = topicName.value.trim();
const selectedProgress = document.querySelector(
'input[name="progress"]:checked',
);
if (!name || !selectedProgress) {
announce("Enter a topic name and choose a progress value.");
topicName.focus();
return;
}
topics.push({
name,
progress: selectedProgress.value,
});
const saved = saveTopics();
renderTopics();
const storageResult = saved
? ""
: " The topic is available for this session, but browser storage is unavailable.";
announce(
`${name} added with progress ${formatProgress(selectedProgress.value)}. ${topics.length} total topics.${storageResult}`,
);
topicName.value = "";
document.querySelector("#progress-not-started").checked = true;
topicName.focus();
});
renderTopics();
Create and complete the manual test report
Manual testing checks whether a person can understand and operate the complete experience. The report keeps keyboard findings separate from Fusion findings so each test method has a clear result.
- In the Visual Studio Code Explorer sidebar from earlier, create accessibility-report.md inside Documents/accessible-learning-dashboard.
- Add the report environment plus manual-test sections by pasting this first section:
# Accessibility Test Report
## Test environment
- Operating system: Windows
- Screen reader and magnifier: Fusion
- Fusion version: [enter the installed version]
- Browser: Google Chrome
- Browser version: [enter the installed version]
- Automated tool: Accessibility Insights for Web 2.49.0
- Test date: [enter the date]
## Keyboard results
- Skip link reaches the main content: Pass / Fail
- All controls are reachable without a mouse: Pass / Fail
- Control order is logical: Pass / Fail
- Every focused control has a visible indicator: Pass / Fail
- Add and Remove actions work from the keyboard: Pass / Fail
- No keyboard trap is present: Pass / Fail
## Fusion results
- Page title is meaningful: Pass / Fail
- Heading order communicates the page structure: Pass / Fail
- Main landmark is discoverable: Pass / Fail
- Topic name and progress controls have clear names: Pass / Fail
- Add and Remove buttons have clear names: Pass / Fail
- Add and Remove status messages are announced: Pass / Fail
- Focus returns to a predictable location after removal: Pass / Fail
What does this section capture?
The environment identifies the exact combination you tested. The two result sections separate visual keyboard behavior from the information Fusion announces.
- Save accessibility-report.md.
- Confirm that the Explorer sidebar lists accessibility-report.md beside index.html, styles.css, and app.js.
Is the report missing from Explorer?
Confirm that you created the file inside Documents/accessible-learning-dashboard. Check that the filename ends with .md.
Help me create the report in the correct project folder.
- Place the cursor after the final Fusion result in accessibility-report.md.
- Add the automated-results and limitations sections by pasting:
## Automated results
- Check performed: [enter the available automated check]
- Failures found: [enter number]
- Failures repaired: [describe each repair]
- Items requiring manual review: [describe them]
## Known limitations
- This test covers one Windows, browser, and assistive-technology combination.
- Automated results do not replace keyboard and screen-reader testing.
- Additional browser and assistive-technology combinations remain future test work.
Why record limitations?
The automated section records what the extension checked. The limitations prevent one test environment from being presented as universal accessibility coverage.
- Save accessibility-report.md.
- Confirm that the report now has separate Keyboard results, Fusion results, Automated results, and Known limitations headings.
Does the report structure look incomplete?
Check that Automated results begins after the final Fusion result. Confirm that Known limitations is the final section.
Help me compare my accessibility report with the required structure.
✔️ Awesome, I've got everything!
Your reusable report template is ready. Fill it with the evidence from the tests below.
ⓧ I'd like to double check the full code
# Accessibility Test Report
## Test environment
- Operating system: Windows
- Screen reader and magnifier: Fusion
- Fusion version: [enter the installed version]
- Browser: Google Chrome
- Browser version: [enter the installed version]
- Automated tool: Accessibility Insights for Web 2.49.0
- Test date: [enter the date]
## Keyboard results
- Skip link reaches the main content: Pass / Fail
- All controls are reachable without a mouse: Pass / Fail
- Control order is logical: Pass / Fail
- Every focused control has a visible indicator: Pass / Fail
- Add and Remove actions work from the keyboard: Pass / Fail
- No keyboard trap is present: Pass / Fail
## Fusion results
- Page title is meaningful: Pass / Fail
- Heading order communicates the page structure: Pass / Fail
- Main landmark is discoverable: Pass / Fail
- Topic name and progress controls have clear names: Pass / Fail
- Add and Remove buttons have clear names: Pass / Fail
- Add and Remove status messages are announced: Pass / Fail
- Focus returns to a predictable location after removal: Pass / Fail
## Automated results
- Check performed: [enter the available automated check]
- Failures found: [enter number]
- Failures repaired: [describe each repair]
- Items requiring manual review: [describe them]
## Known limitations
- This test covers one Windows, browser, and assistive-technology combination.
- Automated results do not replace keyboard and screen-reader testing.
- Additional browser and assistive-technology combinations remain future test work.
The environment section must record the software you actually used. Capturing the installed versions makes the results repeatable.
- In Fusion, open the Fusion menu.
- Select Help.
- Select About.
- Record the displayed version here: your installed Fusion version.
- In Chrome, select More in the top-right corner.
- Select Help.
- Select About Google Chrome.
- Record the version shown beneath the Google Chrome heading here: your installed Chrome version.
Your report now has enough context to identify the exact test environment.
- Record today's date here: your test date.
- Replace the Fusion version guidance in accessibility-report.md with your installed Fusion version.
- Replace the browser version guidance with your installed Chrome version.
- Replace the test-date guidance with your test date.
The manual pass now tests the complete workflow without a mouse. Each result belongs in the matching Keyboard results or Fusion results line.
- Return to the Live Preview dashboard in Chrome.
- Keep Fusion from the previous test running.
- Activate the skip link using only the keyboard.
- Navigate through the dashboard headings using Fusion.
- Find the main landmark using Fusion.
- Move through every form control and button using only the keyboard.
- Add WCAG checks with Complete selected.
- Remove Accessible names using its descriptive Remove button.
Fusion should announce the add and remove messages without moving focus to the hidden status region. Focus should return to the Topic name field after the removal.
What does the manual pass prove?
The keyboard pass checks whether every action is reachable and visibly focused. The Fusion pass checks whether the page structure, control names, and status messages make sense without relying on the visual layout.
- Add Accessible names again with In progress selected.
- Reload the Live Preview page.
- Confirm that WCAG checks and Accessible names both return.
- Record Pass or Fail for every item under Keyboard results.
- Record Pass or Fail for every item under Fusion results.
- Save accessibility-report.md.
Does a manual test fail?
Record the failure before changing anything. Repeat the exact keyboard or Fusion path to confirm the result is consistent.
Check the related label, heading, landmark, focus style, button name, or status announcement in the project files.
Help me investigate a failed keyboard or Fusion check.
Run the automated check and finish the report
Automated checks can identify detectable code-level failures. They supplement the keyboard and Fusion evidence because many usability barriers require human judgment.
- Return to the Live Preview dashboard tab in Chrome.
- Open Accessibility Insights for Web by pressing Ctrl+Shift+K.
- Run the available automated check against the Live Preview page.
- Review every reported failure.
- Remediate each reported failure by following its location and guidance.
- Run the same automated check again after any repair.
- Record the performed check and failure count under Automated results.
- Record each repair and item requiring manual review under Automated results.
Why keep the results separate?
An automated result reports what the tool could inspect. The keyboard and Fusion results report whether a person could operate and understand the dashboard.
Together they create stronger evidence while preserving the limits of each testing method.
Does the extension fail to open or finish?
Confirm that the Live Preview page is the active Chrome tab before pressing Ctrl+Shift+K. Check that Accessibility Insights for Web remains installed if the shortcut produces no response.
Rerun the check after the page finishes loading if the results appear incomplete.
Help me run the automated accessibility check.
- Save the completed accessibility-report.md file.
Before your final reload, consider whether both saved topics will return with the same progress values.
- Reload the Live Preview page in Chrome.
- Confirm that WCAG checks and Accessible names return with their saved progress.
- Review accessibility-report.md to confirm that every environment, keyboard, Fusion, and automated entry contains your result.
That's the full accessibility workflow working: your dashboard preserves its data, supports independent keyboard and Fusion use, and carries a reusable test record.
Secret mission
Add an Accessible Progress Filter
Your dashboard already announces when topics are added or removed. In this challenge, you will add a progress filter that keeps native keyboard behavior and announces how many topics remain visible without moving focus.
Clean Up Your Resources
Clean Up Your Resources
Everything you built lives on your Windows computer with no ongoing costs. Choose whether to keep the local workspace running, pause it for later, or delete its files and saved topics.
Resources you used:
- The accessible-learning-dashboard folder in Documents contains index.html, styles.css, app.js, and accessibility-report.md.
- Browser storage holds the saved topic data for the local Live Preview origin.
- The active Live Preview server hosts the dashboard on your computer.
Keep everything running
No action needed. Choose this option if you want to continue adding topics or testing the progress filter.
- Your project files remain in the accessible-learning-dashboard folder.
- Your saved topics remain available in the dashboard.
- The Live Preview server continues running for local testing.
- Everything remains free because the project uses only local resources.
Pause - I'll come back to this later
Shut down the local server to free its running process. Your project files and saved topics stay available for another session.
- Return to Visual Studio Code from earlier.
- Run Live Preview: Stop Server from its command interface.
The local server stops. Your files and saved topics remain ready for the next session.
- Return to Visual Studio Code when you are ready to continue.
- Run Live Preview: Show Preview (External Browser) from its command interface.
You will see the dashboard reopen with its saved topics available.
Delete - I don't want to use this again
Remove the saved topics before deleting the local project folder. Your installed development and accessibility tools stay available for other projects.
- Return to the dashboard preview from earlier.
- Select All topics from the Show topics control.
You can now reach every saved topic regardless of its progress value.
- Activate each Remove button.
You should see No topics yet. Add your first learning topic. after the final removal. This confirms that the saved topic data is empty.
- Return to Visual Studio Code from earlier.
- Run Live Preview: Stop Server from its command interface.
The local server is now stopped.
Folder deletion is permanent. Your installed tools remain available after the project folder is removed.
- Use the Windows file browser to open your Documents folder.
- Select the accessible-learning-dashboard folder.
- Press Shift+Delete to remove the folder permanently.
- Confirm the deletion when Windows asks.
You should no longer see the accessible-learning-dashboard folder in Documents.
Nice Work!
Nice Work!
You did it! You built a persistent learning dashboard that Fusion users can operate independently from the keyboard.
You've learned how to:
- Build a persistent technology-learning dashboard with HTML, CSS, and JavaScript. Restore saved topics after a reload through browser storage.
- Use native labels and controls to expose meaningful names. Verify landmarks, headings, keyboard focus, and independent navigation with Fusion.
- Repair a silent DOM update with a complete status message that preserves focus. Separate manual findings from automated findings in a reusable accessibility report.
- Secret Mission: Extend the dashboard with an accessible Show topics filter. Announce the visible result count without stealing focus.
Ready to quiz yourself?