What you’ll leave with
An interactive reading-list lab, its complete HTML source, a tested state transition, and a worked review worksheet.
Before you begin. A browser is enough for the interactive lab. The optional controller tests require Node.js 22+. The downloadable HTML opens locally and uses no network services.
An interface can look finished while leaving its most important question unanswered: did my action work? A saved-link form with balanced spacing and a beautiful button can still discard a pasted URL when a write fails. That is a design problem even if every pixel matches the mockup.
In this walkthrough, we review a reading-list form through one task and its interruptions. The case is intentionally small enough to use yourself. You will see the state model, the implementation, and the evidence a useful review should produce. The lab below is an in-memory interaction exercise; persistence belongs to a separate implementation task.
Start by trying a normal save. Then save the same URL again. Finally enable “Make the next save fail” and submit a different URL. That third attempt is where the form earns its keep: the link stays in the input, the old list stays visible, and the next action is clear.
1. Give the review a starting point and a finish line
A request to “review this page” invites a list of preferences. A task gives you a way to distinguish a preference from a blocker. Our reader has a complete article URL on the clipboard and wants to return to it later. For the interaction lab, the finish line is a visible saved row and clear confirmation. For the full product, the finish line also includes returning after reload.
Begin from the entry state without explaining the controls. Can someone identify what belongs in the field and what the primary action does? Use an ordinary URL, then a very long one. Read the field label without the placeholder: placeholders disappear while typing, so they cannot carry the entire instruction.
Record what actually happens before suggesting a fix. “The form feels confusing” is hard to reproduce. “After the simulated write fails, the input is empty and no row appears” identifies a lost piece of user work. It also tells the implementer where to look: the order of state changes around persistence.
| Attempt | Action | Evidence to capture |
|---|---|---|
| First save | Paste a valid URL and choose Save link | Visible row, confirmation, focus position |
| Repeat | Save the same URL with another fragment | One row, explicit duplicate feedback |
| Failure | Enable the failure control and save a new URL | Original input and old rows retained |
| Recovery | Submit the retained URL again | One new row, cleared input, success status |
| Keyboard | Complete the same sequence without a pointer | Reachable controls and visible focus |
2. Design the state transitions before the decoration
A form has more than empty and filled states. It can be validating, saving, saved, duplicated, or failed. The distinction affects what remains editable, what gets announced, and what the reader can do next. Write these states down before adding visual treatments; otherwise a loading spinner can hide an undefined recovery path.
During saving, the example briefly makes the input read-only and disables the submit button. This avoids replacing text that the user edits while an earlier value is being saved. When the operation settles, the input becomes editable again and receives focus. That is a deliberate choice for a form where repeated entry is the likely next task; another workflow may need a different focus destination.
After a duplicate, we keep the entered value and show a status message. It is a completed no-op, not a malformed field. After an invalid URL, we mark the input invalid and provide a field-specific correction. After a storage failure, we use a form-level failure message because a valid URL did not cause the error. Conflating those cases makes recovery harder.
| State | Input | Rows | Feedback |
|---|---|---|---|
| Saving | Preserved, temporarily read-only | Existing rows stay visible | Saving… |
| Saved | Cleared | New row added after write succeeds | Link saved. |
| Duplicate | Preserved | Unchanged | Already in your list. |
| Invalid | Preserved and marked invalid | Unchanged | How to correct the URL |
| Write failed | Preserved, still valid | Unchanged | Could not save; try again |
3. Make recovery a property of the code
The easiest recovery bug to introduce is clearing the field too soon. If you set the input to an empty string before awaiting persistence, an exception leaves the reader without the thing they were trying to save. You can patch that by restoring a backup, but it is simpler to return a cleared input only on confirmed success.
The small controller below receives current rows, input, and a persistence function. It returns the next visible state. A failure returns the original array and exact input string; a success returns the new array and an empty input. This separation lets a unit test verify preservation without needing a browser or manufacturing a real storage outage.
We remove fragments for duplicate comparison and retain path and query differences. The implementation also rejects non-HTTP(S) schemes and embedded credentials before constructing a link. It inserts labels as text, not HTML. These details matter because a seemingly harmless saved-link interface handles arbitrary strings from a reader.
export async function saveLink(rows, input, persist) {
let url;
try { url = new URL(input.trim()); }
catch { return { status: "invalid", message: "Enter a complete HTTP(S) URL.", rows, input }; }
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
return { status: "invalid", message: "Use HTTP(S) without embedded credentials.", rows, input };
}
url.hash = "";
if (rows.includes(url.href)) {
return { status: "duplicate", message: "Already in your list.", rows, input };
}
const next = [...rows, url.href];
try { await persist(next); }
catch { return { status: "failed", message: "Could not save. Your link is still here; try again.", rows, input }; }
return { status: "saved", message: "Link saved.", rows: next, input: "" };
}
4. Inspect the complete interaction lab
The live lab near the start of this article uses the same standalone HTML available below. Open the downloaded file in a browser to inspect it without the surrounding website. Its simulated 450 ms write makes the pending state visible, and its failure control makes recovery reproducible. Reloading resets the list; this is intentionally not a persistence demonstration.
Notice how the label, help text, and field error have distinct jobs. The label names the control. The help describes the accepted input. The associated error explains what needs to change. Successful and duplicate outcomes use a status region; a failed write uses an alert region. This follows the principle of making feedback programmatically available, but actual announcement behavior still needs assistive-technology testing.
The embedded preview is sandboxed, so opening saved links may be blocked there. Use the downloaded HTML for that part of the task. There is no page fetching, server call, browser storage, or analytics in the lab. The rendered rows demonstrate feedback, not completion of the full product's save-and-return promise.
Read the complete reading-list-lab.html · 89 lines
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
<title>Reading-list interaction lab</title>
<style>
*{box-sizing:border-box}body{margin:0;padding:24px;background:#fffdf8;color:#292622;font:17px/1.6 system-ui}
main{max-width:620px;margin:auto}h1{font:32px/1.2 Georgia,serif}label{display:block;margin-top:16px}
input[type=url]{display:block;width:100%;padding:12px;font:inherit;border:1px solid #827b71;border-radius:3px}
button{margin-top:12px;padding:10px 18px;font:inherit;background:#303b32;color:white;border:0;border-radius:3px}
button:disabled{opacity:.65}a{color:#375838;overflow-wrap:anywhere}li{margin:10px 0}
:focus-visible{outline:3px solid #a54b2a;outline-offset:4px}
#field-error,#failure{color:#963216}small{display:block;color:#5e5951}
fieldset{margin-top:28px;border:1px solid #d2c9b9}fieldset label{margin:0}
</style></head><body><main>
<h1>Save something for later.</h1>
<p>This lab keeps links in memory. Reloading resets it. No links are fetched.</p>
<form id="save-form" novalidate>
<label for="url">Article URL</label>
<input id="url" type="url" aria-describedby="url-help field-error" placeholder="https://example.com/article" required>
<small id="url-help">Use a complete HTTP or HTTPS address.</small>
<p id="field-error"></p>
<button id="save" type="submit">Save link</button>
</form>
<p id="status" role="status"></p><p id="failure" role="alert"></p>
<h2>Your list</h2><p id="empty">No links yet. Paste an article above to start.</p><ul id="list"></ul>
<fieldset><legend>Try a failure</legend><label><input id="fail" type="checkbox"> Make the next save fail</label></fieldset>
<script type="module">
async function saveLink(rows, input, persist) {
let url;
try { url = new URL(input.trim()); }
catch { return { status: "invalid", message: "Enter a complete HTTP(S) URL.", rows, input }; }
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
return { status: "invalid", message: "Use HTTP(S) without embedded credentials.", rows, input };
}
url.hash = "";
if (rows.includes(url.href)) {
return { status: "duplicate", message: "Already in your list.", rows, input };
}
const next = [...rows, url.href];
try { await persist(next); }
catch { return { status: "failed", message: "Could not save. Your link is still here; try again.", rows, input }; }
return { status: "saved", message: "Link saved.", rows: next, input: "" };
}
const form = document.querySelector('#save-form');
const input = document.querySelector('#url');
const button = document.querySelector('#save');
const status = document.querySelector('#status');
const fieldError = document.querySelector('#field-error');
const failure = document.querySelector('#failure');
const fail = document.querySelector('#fail');
const list = document.querySelector('#list');
let rows = [];
let busy = false;
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (busy) return;
busy = true;
button.disabled = true;
input.readOnly = true;
form.setAttribute('aria-busy', 'true');
button.textContent = 'Saving…';
status.textContent = ''; fieldError.textContent = ''; failure.textContent = '';
input.removeAttribute('aria-invalid');
const shouldFail = fail.checked;
fail.checked = false;
const result = await saveLink(rows, input.value, async () => {
await new Promise(resolve => setTimeout(resolve, 450));
if (shouldFail) throw new Error('Simulated write failure');
});
rows = result.rows; input.value = result.input;
list.replaceChildren(...rows.map(url => {
const item = document.createElement('li');
const link = document.createElement('a');
link.href = url; link.textContent = url;
link.target = '_blank'; link.rel = 'noopener noreferrer';
item.append(link); return item;
}));
document.querySelector('#empty').hidden = rows.length > 0;
if (result.status === 'invalid') {
input.setAttribute('aria-invalid', 'true'); fieldError.textContent = result.message;
} else if (result.status === 'failed') { failure.textContent = result.message; }
else { status.textContent = result.message; }
busy = false; button.disabled = false; input.readOnly = false;
button.textContent = 'Save link'; form.removeAttribute('aria-busy');
input.focus();
});
</script></main></body></html>5. Review the narrow layout while something is wrong
A pristine empty form is the least demanding responsive test. Fill it with a long address and trigger the longer failure message before reducing the viewport. Check that the input, feedback, and submit action remain readable. Long link labels should wrap; code examples may scroll locally, but the entire page should not force horizontal navigation.
At 320 pixels, inspect whether the primary action is still identifiable and reachable without precision clicking. Then increase text size or zoom in a desktop browser. A layout that fits a small viewport can still clip text after enlargement. The useful question is whether the task survives, not whether a screenshot looks like a scaled version of the desktop layout.
Walk through the form using Tab, Shift+Tab, and Enter. The focus indicator should be visible on every actionable control. After a failed submission, verify that focus returns somewhere useful and that the pasted value remains available for correction or retry. Do not trap focus in the form just to enforce a preferred sequence.
Finally, inspect the experience with a screen reader in the browsers you intend to support. A role attribute is implementation evidence, not proof that the announcement arrives at the right time. Record the actual combinations tested. If this step is unavailable, say so in the review instead of declaring the page accessible from source inspection alone.
6. Turn the finding into a regression check
The first test below captures the most expensive failure: losing the exact input after a write rejects. It asserts both input preservation and the unchanged row array. The retry test confirms that success clears the input only after the write resolves. The last test verifies that duplicates and rejected schemes never invoke persistence.
These tests do not check focus, layout, announcements, or the loading label. Those need browser-level checks. Keep the distinction visible in the handoff so the next person does not infer broad usability coverage from three passing controller tests.
import test from "node:test";
import assert from "node:assert/strict";
import { saveLink } from "./save-link.mjs";
test("failed write retains exact input and existing list", async () => {
const rows = ["https://example.com/old"];
const input = " https://example.com/new ";
const result = await saveLink(rows, input, async () => { throw new Error("quota"); });
assert.equal(result.status, "failed");
assert.equal(result.rows, rows);
assert.equal(result.input, input);
});
test("successful retry adds a row and clears input", async () => {
let written;
const result = await saveLink([], "https://example.com/a#part", async rows => { written = rows; });
assert.equal(result.status, "saved");
assert.deepEqual(written, ["https://example.com/a"]);
assert.deepEqual(result.rows, written);
assert.equal(result.input, "");
});
test("duplicates and invalid schemes never write", async () => {
const persist = async () => assert.fail("should not write");
assert.equal((await saveLink(["https://example.com/"], "https://example.com/#x", persist)).status, "duplicate");
for (const input of ["javascript:alert(1)", "not a URL", "https://me:secret@example.com"]) {
const result = await saveLink([], input, persist);
assert.equal(result.status, "invalid");
assert.equal(result.input, input);
}
});
node --test save-link.test.mjsA useful finding includes the conditions, observed behavior, expected behavior, impact, and retest. The downloadable worksheet shows that structure with a constructed before/after case. Replace its pending browser coverage with your actual observations; do not copy the example as if it were evidence from your product.
# Reading-list review worksheet — worked example
Scope: interaction lab, not the complete persisted reading-list product.
Evidence below describes a constructed before/after example, not a customer study.
## F1 — Entered URL disappears after a failed write
Impact: blocks recovery; the reader must find the URL again.
Reproduce: enter https://example.com/article, force the write to reject, save.
Before: input clears before the write; no row is stored.
Expected: exact input and old rows remain; an actionable failure is announced.
Change: clear input only after persist resolves; show failure in role=alert.
Retest: force failure, retain input, retry without retyping, see one row.
Automated coverage: save-link.test.mjs tests the retained state and retry.
Browser coverage to record: browser/version, viewport, keyboard path, actual result.
## F2 — Duplicate save looks like a broken button
Impact: uncertainty; encourages repeated clicks.
Reproduce: save the same canonical URL twice.
Expected: one row and “Already in your list.” status.
Change: return duplicate before writing; expose the status near the form.
Retest: repeat with a different fragment; verify one row.
## Still unverified by the controller tests
Screen-reader announcements; zoom; visual focus; browser storage;
real network errors; other browsers; cross-device persistence.
7. Prioritize by interruption to the task
Fix loss of entered work before adjusting the color of a secondary separator. Fix missing duplicate feedback before refining the saving animation. This is not an argument against visual quality: hierarchy, spacing, and readable typography help people understand the task. It is an argument for connecting each design change to what it makes easier to see or do.
Once recovery is sound, refine the reading order. Put the field label above the input, keep the primary action close, and place feedback where it will be encountered after submission. Keep the list visible through a failed save so the reader does not wonder whether existing work disappeared. Use a restrained error treatment that explains the next action without overwhelming the rest of the page.
The accompanying skill gives an agent this review sequence. Supply a working URL and permitted test data, ask it to reproduce findings, and require the same journey after a fix. A review is complete when its evidence explains what improved and what remains untested. A polished screenshot is one useful artifact in that review, but the recoverable task is the result we are building toward.
Take it into your project
Files, a full article, and an agent skill.
Download the files you need into the same folder. Each article explains which examples run on their own and which are reference documents.
- save-link.mjs ↓
The state transition shared by the tests and browser lab.
- save-link.test.mjs ↓
Three executable preservation, retry, and validation tests.
- reading-list-lab.html ↓
Complete self-contained browser lab. Open locally; state resets on reload.
- review.md ↓
A filled-in review worksheet with evidence boundaries.
- article.md ↓
The complete guide, with its inline examples and references, for offline reading.
Use the method with your agent
The article explains the decisions; the skill gives your agent reusable instructions. Review it, create a folder named review-an-interface in your client’s configured skills directory, and save the file there as SKILL.md. Installation and activation vary by client. In a client without skill support, paste the instructions into your task.
Use review-an-interface on the reading-list page. Test saving a URL, a duplicate save, a failed request, and returning to the list. Check keyboard use and 320px/390px widths. Restore any test changes.Inspect the complete skill before using it
---
name: review-an-interface
description: "Evaluate and improve a working interface using keyboard tasks, responsive layouts, recovery states, and concrete evidence. Use for an implemented page or flow ready for browser review."
---
# Review an interface through real tasks
Prioritized reproducible findings and a retested task path.
## Inputs
A working URL, the target task, expected states, and permitted test data.
## Follow the user task
Start from the entry page. Complete the primary action without relying on source-code knowledge. Record confusing labels, blocked paths, and missing feedback. Use disposable data and preserve existing user state.
## Check interaction states
Exercise loading, empty, populated, invalid, failed, and retried states where the app supports them. Verify entered values survive a failed submission. Keyboard focus should follow a useful order and remain visible. Error messages should explain the next action and relate to their controls.
## Inspect responsive behavior
Check a narrow viewport and the normal desktop layout. Read long titles and error messages. Distinguish deliberate local horizontal scrolling from whole-page overflow. Ensure the selected tab remains discoverable. Do not infer accessibility conformance from a screenshot alone.
## Close the loop
For each finding provide steps, expected/actual behavior, impact, and evidence. If fixes are requested, make the smallest coherent change and repeat the affected journey. Restore viewport and test data. State untested assistive technologies or browsers explicitly.
## Example request
Use review-an-interface on the reading-list page. Test saving a URL, a duplicate save, a failed request, and returning to the list. Check keyboard use and 320px/390px widths. Restore any test changes.
References & scope
The worked examples and recommendations are original instructional material. These references support the specific formats and APIs used; they do not establish a benchmark or endorse the example architecture.
- W3C: user notifications in forms ↗
Reference for associating field errors and making feedback available to assistive technology.
- Playwright best practices ↗
A next step for browser checks using observable behavior and user-facing locators.