---
title: 'Accessible Component Patterns'
source: 'https://academia.sh/en/courses/accessible-patterns'
language: en
updated: '2026-08-19T05:19:50+00:00'
license: 'CC BY-SA 4.0'
lessons: 25
outcomes:
    - 'Specifies common components together with keyboard and screen reader behavior'
    - 'Designs focus management for complex interaction patterns'
    - 'Makes interface text and localization requirements part of the system'
---

# Accessible Component Patterns

An advanced course that teaches how to write common interface components as specifications with their role, name, state, keyboard contract, and measurable constraints; how to manage focus in complex patterns; and how to make interface text part of the system.

## Basic Components

Specifying the button, text input, selection control, dropdown list, and form patterns by role, name, state, and keyboard contract; tying the link-versus-button distinction to behavior.

### Buttons

Source: https://academia.sh/en/courses/accessible-patterns/buttons

The six sections of a component specification, the behavior the native element provides for free, the pressed-state declaration, the keyboard contract, and auditing the clickable area against target size criteria.

The component catalog is established, the naming convention is written, and versioning
and contribution are working. Every entry in the catalog carries a name, a maturity
level, and a usage guideline. But a component's presence in the catalog does not mean
what it does is written down: the "primary button" entry says how the button looks, not
how it is operated from the keyboard, or which name and which state it presents in the
accessibility tree.

This course writes that missing half. Each lesson treats a component as a
**specification** and answers the same six questions in the same order. The library
catalog interface — the search field, the filter selections, the results table, the
record detail, and the borrowing form — is the shared example across every lesson. The
first component is the button, because most of the remaining components contain one.

#### The Six Sections of a Specification

A component specification is written in this order: the problem the component solves
and when it is not used; what the native counterpart is; where role, name, and state are
computed from; the keyboard contract; measurable constraints; and the common mistake and
how it is caught.

The order is not arbitrary. If the third section is reached before the first two are
answered, a meaningless container gets chosen instead of a native element, and the
remaining four sections are written on top of that wrong choice. This is why the first
rule from the Frontend Quality course is also the specification's second entry: ARIA is
not used when a native element carrying the required meaning exists.

#### What the Button Does

The button is the control that **starts an action** on the page: running a search,
clearing filters, adding a record to the borrowing list, submitting a form. Their shared
property is that the address bar's address does not change when they run.

Where a button is not used follows the same measure: if the user is going to another
resource, the component is a link, not a button. Clicking a record in the catalog opens
the record detail page; that is not an action, it is a navigation. The full rule for the
distinction, and how its violation is caught, is in this topic's last lesson.

#### The Native Element First

The counterpart is the `button` element, and its `type` attribute takes three values.
The `submit` value ties the element to form submission and **is the value in effect
inside a form when the attribute is not written**; the `button` value carries no default
action, and behavior is determined solely by the code attached to it; the `reset` value
returns fields to their initial values.

Skipping this distinction produces a concrete breakage: when a "Clear filters" button
placed inside the borrowing form does not carry `type="button"`, the button submits the
form. A user who wants to clear a field ends up borrowing the record instead.

Beyond role, the native element brings five more things for free: focusability,
operability by the Enter and Space keys, notifying the tree of the disabled state, a
relationship to form submission, and adding its own name–value pair to the submission
list as a submitter. None of these come from writing a role onto a `div` element.

#### Role, Name, and State

Role comes implicitly from the `button` element; it is not declared separately.

The name is computed from the button's content. In buttons that carry text, this works
on its own. In icon-only buttons — the flagging and borrow buttons in the results
table — the button ends up nameless if the icon's alternative text is empty. The rule in
the catalog specification is this: an icon button's name is written on the button, not
on the icon; the icon is presentational in every case.

Two state declarations belong to the button. The first is being disabled: the `disabled`
attribute both notifies the tree and takes the element out of focus. `aria-disabled="true"`
only notifies — the element still takes focus and remains clickable; this is chosen when
the user is meant to be able to find the button and read why it does not work, and the
click has to be ignored on the code side.

The second is the **pressed state** (`aria-pressed`): it declares whether a toggle button
is on or off. The "Only items on the shelf" filter in the catalog is such a button. This
state is separate from the transient **active** interaction state defined in the
Fundamentals of Interface Design course: one ends when the key is released, the other
persists until the user presses again. Visual design has to encode the two on separate
channels.

#### Keyboard Contract

| Key | Behavior |
|---|---|
| `Tab` | Brings focus to the button; every button is its own tab stop. |
| `Shift+Tab` | Returns to the previous tab stop. |
| `Enter` | Runs the button; triggers the moment the key is pressed. |
| `Space` | Runs the button; triggers when the key is **released**. |

The difference between the two keys' trigger moments exists on its own in the native
element and has to be reproduced in a hand-built button. The Space key triggering on
release gives the user the option to hold the key down and back out of the button; the
Enter key triggering on press is the expected behavior for form submission.

Focus stays on the button after it runs. If the button is being removed from the page —
if the "Clear filters" button is hidden once no filters remain — where focus goes has to
be specified; if it is not, focus falls back to the document root.

#### The Clickable Area Is Measured

A button's visual size is not a design preference; it is a constraint tied to criteria.
The 2.5.8 Target Size (Minimum) criterion requires every target to be at least 24×24 CSS
pixels and defines five exceptions; the most useful of these is the **spacing
exception**: a 24-pixel-diameter circle placed at the center of a target that stays small
satisfies the criterion if it does not touch another target or another small target's
circle. The 2.5.5 Target Size (Enhanced) criterion raises the same measure to 44 pixels.

```js
// target.mjs — auditing button target size against the 2.5.8 and 2.5.5 criteria

// Catalog toolbar and results-table row: boxes in CSS pixels.
// x,y top-left corner; w,h width and height.
const INTERFACE = [
  { name: "button#search",             x: 16,  y: 16, w: 88,  h: 44 },
  { name: "button#clear-filters",      x: 112, y: 16, w: 140, h: 44 },
  { name: "button#view-list",          x: 600, y: 26, w: 24,  h: 24 },
  { name: "button#view-grid",          x: 628, y: 26, w: 24,  h: 24 },
  { name: "a#record[K-118]",           x: 60,  y: 80, w: 220, h: 20 },
  { name: "button#flag[K-118]",        x: 700, y: 80, w: 20,  h: 20 },
  { name: "button#borrow[K-118]",      x: 720, y: 80, w: 20,  h: 20 },
];

const MIN = 24;        // 2.5.8 Target Size (Minimum), AA
const ENHANCED = 44;   // 2.5.5 Target Size (Enhanced), AAA

const center = (t) => [t.x + t.w / 2, t.y + t.h / 2];

// Whether a 24 px diameter circle intersects a box
function circleBoxIntersects(t, k) {
  const [cx, cy] = center(t);
  const nx = Math.max(k.x, Math.min(cx, k.x + k.w));
  const ny = Math.max(k.y, Math.min(cy, k.y + k.h));
  return Math.hypot(cx - nx, cy - ny) < MIN / 2;
}

// Two 24 px diameter circles intersecting: distance between centers < 24
function circleCircleIntersects(a, b) {
  const [ax, ay] = center(a);
  const [bx, by] = center(b);
  return Math.hypot(ax - bx, ay - by) < MIN;
}

const small = (t) => Math.min(t.w, t.h) < MIN;

// Spacing exception: a small target's circle must not touch another
// target or another small target's circle.
function spacingException(t, all) {
  const collided = [];
  for (const b of all) {
    if (b === t) continue;
    const hit = small(b) ? circleCircleIntersects(t, b) : circleBoxIntersects(t, b);
    if (hit) collided.push(b.name);
  }
  return collided;
}

function audit(heading, ui) {
  console.log(heading);
  console.log("target                    size     smallest edge  2.5.8   2.5.5   spacing exception");
  for (const t of ui) {
    const edge = Math.min(t.w, t.h);
    const collided = small(t) ? spacingException(t, ui) : [];
    const passes258 = !small(t) || collided.length === 0;
    const passes255 = Math.min(t.w, t.h) >= ENHANCED;
    const note = !small(t)
      ? "not required"
      : collided.length === 0
        ? "applied"
        : "collides: " + collided.join(", ");
    console.log(
      t.name.padEnd(24) +
        `${t.w}x${t.h}`.padEnd(9) +
        String(edge).padStart(14) +
        (passes258 ? "  passes" : "  FAILS").padEnd(8) +
        (passes255 ? "  passes" : "  FAILS").padEnd(8) +
        "  " + note,
    );
  }
  const remaining = ui.filter((t) => small(t) && spacingException(t, ui).length > 0);
  console.log(`2.5.8 violations: ${remaining.length} targets`);
}

audit("current toolbar and row controls:", INTERFACE);

// Fix: inline row controls are raised to 24x24 and their spacing opened to 8 px.
const FIXED = INTERFACE.map((t) => {
  if (t.name === "button#flag[K-118]") return { ...t, w: 24, h: 24, x: 696, y: 78 };
  if (t.name === "button#borrow[K-118]") return { ...t, w: 24, h: 24, x: 728, y: 78 };
  return t;
});

console.log("");
audit("inline row controls at 24x24, spacing 8 px:", FIXED);

// Solving without enlarging the target: the box stays the same, the spacing opens.
const SPACING_ONLY = INTERFACE.map((t) =>
  t.name === "button#borrow[K-118]" ? { ...t, x: 726 } : t,
);

console.log("");
audit("boxes stay 20x20, only the spacing opens to 6 px:", SPACING_ONLY);
```

```
current toolbar and row controls:
target                    size     smallest edge  2.5.8   2.5.5   spacing exception
button#search           88x44                44  passes  passes  not required
button#clear-filters    140x44               44  passes  passes  not required
button#view-list        24x24                24  passes  FAILS   not required
button#view-grid        24x24                24  passes  FAILS   not required
a#record[K-118]         220x20               20  passes  FAILS   applied
button#flag[K-118]      20x20                20  FAILS   FAILS   collides: button#borrow[K-118]
button#borrow[K-118]    20x20                20  FAILS   FAILS   collides: button#flag[K-118]
2.5.8 violations: 2 targets

inline row controls at 24x24, spacing 8 px:
target                    size     smallest edge  2.5.8   2.5.5   spacing exception
button#search           88x44                44  passes  passes  not required
button#clear-filters    140x44               44  passes  passes  not required
button#view-list        24x24                24  passes  FAILS   not required
button#view-grid        24x24                24  passes  FAILS   not required
a#record[K-118]         220x20               20  passes  FAILS   applied
button#flag[K-118]      24x24                24  passes  FAILS   not required
button#borrow[K-118]    24x24                24  passes  FAILS   not required
2.5.8 violations: 0 targets

boxes stay 20x20, only the spacing opens to 6 px:
target                    size     smallest edge  2.5.8   2.5.5   spacing exception
button#search           88x44                44  passes  passes  not required
button#clear-filters    140x44               44  passes  passes  not required
button#view-list        24x24                24  passes  FAILS   not required
button#view-grid        24x24                24  passes  FAILS   not required
a#record[K-118]         220x20               20  passes  FAILS   applied
button#flag[K-118]      20x20                20  passes  FAILS   applied
button#borrow[K-118]    20x20                20  passes  FAILS   applied
2.5.8 violations: 0 targets
```

The three runs give three separate pieces of information.

In the first run, the toolbar satisfies the criterion; the results table's inline row
controls do not. The distance between the two buttons' centers is 20 pixels; 24 is
required for the two circles not to intersect. The record link is also 20 pixels tall,
but it satisfies the spacing exception, because there is no other target around it.

The second run applies the direct fix: once the boxes are raised to 24×24, the exception
is not needed at all. The third run corrects a misreading of the criterion — enlarging
the boxes is not mandatory. Raising the spacing to just 6 pixels alone, moving the
centers to 26 pixels, satisfies the criterion. This is the route to use when a dense
results table needs to keep its row height.

The 2.5.5 column shows "FAILS" for every icon button. This is not a violation: the
44-pixel measure is AAA level. The column's presence in the specification shows in
advance which buttons need to be enlarged if touch use is the target.

#### Hierarchy Is Not a Behavior Difference

The catalog toolbar uses three visual weights: the filled primary button ("Search"), the
outlined secondary button ("Clear filters"), and the unfilled plain button (the view
switchers). This distinction is entirely visual; the role, name, keyboard contract, and
target size criterion are the same for all three.

The one rule that has to be written into the specification is that a view holds **at
most one primary button**. Two primary buttons erase the "this is the recommended
action" information that visual hierarchy carries. This rule does not come from a
conformance criterion; it is the component-level counterpart of the contrast and
emphasis decision from the Fundamentals of Interface Design course.

#### Common Mistake

The three most commonly produced defects and how they are caught are as follows.

**The nameless icon button.** A button carrying an icon with empty alternative text
appears nameless in the tree. Catching it needs no tooling: every button's accessible
name on the page is listed, and the empty one is searched for. Automated checkers'
presence rule catches this.

**The visible text and the name diverging.** If the button reads "Borrow" while its
`aria-label` declaration says "Add," the name a voice-interface user speaks matches no
element; the 2.5.3 Label in Name criterion forbids this. Catching it means checking
whether the visible text is contained within the accessible name.

**The disabled button left without a reason.** When the borrow button for a record that
is not on the shelf is turned off by writing `disabled`, the button drops out of focus
and the user cannot read why. Catching it is a keyboard tour: every control that is
visible on screen but cannot be found while tabbing through the page is in this state.
The specification's decision, when the reason needs to be readable, is to declare the
button with `aria-disabled` and tie the reason with `aria-describedby`.

#### Summary

- A component specification has six sections: the problem and where it is not used, the
  native counterpart, role–name–state, the keyboard contract, measurable constraints, and
  the common mistake and how it is caught.
- A button starts an action; if the address changes, the component is not a button. When
  the `type` attribute is not written, a button inside a form becomes a submit button.
- The name is computed from the button's content; in icon buttons the name is written on
  the button. `disabled` takes the element out of focus; `aria-disabled` only notifies
  and leaves it in focus.
- The Enter key triggers on press, the Space key on release; a hand-built button has to
  reproduce this difference.
- 2.5.8 requires 24×24 CSS pixels from every target; the spacing exception can also be
  satisfied without enlarging the box, by raising the distance between centers above 24
  pixels.
- The distinction between primary, secondary, and plain buttons is purely visual; a view
  holds at most one primary button.

#### Next Step

The button is a control that runs without taking anything from the user: its name is
fixed, it carries no value, and the only thing it declares is whether it is pressed. The
catalog interface's actual data-collecting component is the search field, and there a
name alone is not enough — the field has a label, a format hint, a help text, and, when
needed, an error message. All four of these sit around the field on screen, but in the
accessibility tree they are separate declarations, and the order in which they are
announced differs. The next lesson specifies the text field through the arrangement of
these four texts.

### Text Inputs

Source: https://academia.sh/en/courses/accessible-patterns/text-inputs

The separate places the label, help text, error message, and placeholder occupy in the accessibility tree; computing the name and description, auditing the error binding, and the text field's keyboard contract.

In the button specification, the name came from a single source and did not change. The
catalog's search field and the borrowing form's membership number field are different in
this respect: four separate texts can stand around the field on screen — the label, the
help text, the error message, and the placeholder inside the field. All four appear to
the eye at the same distance.

In the accessibility tree, though, the four do not sit in the same place. One becomes the
field's **name**, one its **description**, one falls outside both, and one, when written
incorrectly, overrides the others. This lesson computes that distribution.

#### The Problem the Field Solves

The text field is the control where the user types a value that **cannot be enumerated
in advance**: a search string, a membership number, a note for staff. The measure is
enumerability. If the "borrow duration" field in the borrowing form can only take three
values, it is not a text field; when the options can be enumerated, a selection control
or a dropdown list is used instead.

The second boundary is format. Asking for values like a date, time, color, or file as
free text makes the user guess the format and leaves the entire validation burden to the
code. Defined input types exist for these values.

#### The Native Element First

The counterpart is the `input` element; `textarea` for multi-line text. The `type`
attribute determines both the field's role and its validation: `search` brings the
search-box role, `email` brings email-address validation, `number` brings the bounded
number-field role, `date` brings date selection.

Choosing the type does three things at once. The role in the accessibility tree changes;
the browser's built-in constraint validation engages; on touch devices, the keyboard
suited to the field opens. None of the three is written separately.

What kind of personal data a field collects can also be declared separately. The
`autocomplete` attribute states a value's **purpose** programmatically on fields like
member name, address, and email address; this is the counterpart of the 1.3.5 Identify
Input Purpose criterion, and it also lets the user autofill their own data.

#### Where the Four Texts Sit in the Tree

**The label** is the field's name, and it is persistent. It is written with the `label`
element; the `for` attribute ties to the field's id, or the field is nested inside the
label.

**Help text** is format or constraint information: "The 8-digit number on the card." It
is not the field's name, it is its description, and it is tied with `aria-describedby`.
It is preferred to sit **above, not below**, the field on screen; text below the field
can end up under the keyboard that opens while the field is being filled in.

**The error message** is also a description, and it is tied with the same mechanism. It
is written **alongside** the help text, not in its place.

**The placeholder** is the example value sitting inside the field, and it disappears once
the field is filled in. It is last in the name computation order: it turns into the name
only if no other source exists. This does not mean it can be used in place of a label.

```js
// field.mjs — computing name, description, and state declarations in a text field

// Text-bearing nodes in the document (label and aria-describedby targets)
const DOCUMENT = {
  "member-help": "The 8-digit number on the card.",
  "member-error": "Membership number must be 8 digits.",
  "return-help": "At most 21 days from now can be selected.",
  "label:search": "Search the catalog",
  "label:member-no": "Membership number",
  "label:return": "Return date",
};

// Name order: aria-labelledby -> aria-label -> label element -> title -> placeholder
function name(a) {
  if (a["aria-labelledby"]) {
    const p = a["aria-labelledby"].split(" ").map((k) => DOCUMENT[k]).filter(Boolean);
    if (p.length > 0) return [p.join(" "), "aria-labelledby"];
  }
  if (a["aria-label"]) return [a["aria-label"], "aria-label"];
  if (DOCUMENT["label:" + a.id]) return [DOCUMENT["label:" + a.id], "label element"];
  if (a.title) return [a.title, "title (last resort)"];
  if (a.placeholder) return [a.placeholder, "placeholder (last resort)"];
  return ["", "no name"];
}

// Description order: aria-describedby -> title -> placeholder (if not already used for the name)
function description(a, nameSource) {
  if (a["aria-describedby"]) {
    const p = a["aria-describedby"].split(" ").map((k) => DOCUMENT[k]).filter(Boolean);
    if (p.length > 0) return [p.join(" "), "aria-describedby"];
  }
  if (a.title && nameSource !== "title (last resort)") return [a.title, "title"];
  if (a.placeholder && nameSource !== "placeholder (last resort)") return [a.placeholder, "placeholder"];
  return ["", "no description"];
}

function states(a) {
  const d = [];
  if (a.required || a["aria-required"] === "true") d.push("required");
  if (a["aria-invalid"] === "true") d.push("invalid");
  if (a.readonly) d.push("read-only");
  if (a.disabled) d.push("disabled");
  return d.length ? d.join(", ") : "-";
}

// --- Catalog search field and borrowing form fields -------------------------
const FIELDS = [
  ["label + placeholder",
   { id: "search", type: "search", placeholder: "title, author, or ISBN" }],
  ["placeholder only",
   { id: "search-2", type: "search", placeholder: "title, author, or ISBN" }],
  ["label + help text",
   { id: "member-no", required: true, "aria-describedby": "member-help" }],
  ["error replaces help",
   { id: "member-no", required: true, "aria-invalid": "true", "aria-describedby": "member-error" }],
  ["error added to help",
   { id: "member-no", required: true, "aria-invalid": "true",
     "aria-describedby": "member-help member-error" }],
  ["error link broken",
   { id: "member-no", required: true, "aria-invalid": "true",
     "aria-describedby": "member-help member-error-2" }],
  ["date, label + help",
   { id: "return", type: "date", "aria-describedby": "return-help" }],
  ["title only",
   { id: "note", title: "A note for staff" }],
  ["aria-label overrides label",
   { id: "member-no", "aria-label": "Number" }],
];

const G = 31;   // field column width

console.log("field".padEnd(G) + "accessible name".padEnd(28) + "name source".padEnd(27) + "state");
for (const [heading, a] of FIELDS) {
  const [n, k] = name(a);
  console.log(heading.padEnd(G) + (n === "" ? "(empty)" : '"' + n + '"').padEnd(28) +
    k.padEnd(27) + states(a));
}

console.log("\n" + "field".padEnd(G) + "description".padEnd(71) + "source");
for (const [heading, a] of FIELDS) {
  const [, k] = name(a);
  const [c, ck] = description(a, k);
  console.log(heading.padEnd(G) + (c === "" ? "(empty)" : '"' + c + '"').padEnd(71) + ck);
}

// --- Rule check ---------------------------------------------------------
const VISIBLE = { search: "Search the catalog", "member-no": "Membership number", return: "Return date" };

console.log("\nrule check:");
let findings = 0;
for (const [heading, a] of FIELDS) {
  const [n, k] = name(a);
  const [c] = description(a, k);
  const b = [];
  if (n === "") b.push("field is nameless");
  if (k.includes("last resort")) b.push("name comes from a last-resort source, no persistent label");
  const visible = VISIBLE[a.id];
  if (visible && n && !n.toLocaleLowerCase("en-US").includes(visible.toLocaleLowerCase("en-US")))
    b.push(`visible label ("${visible}") is not contained in the accessible name`);
  if (a["aria-describedby"]) {
    const broken = a["aria-describedby"].split(" ").filter((x) => !DOCUMENT[x]);
    if (broken.length) b.push("broken reference: " + broken.join(", "));
  }
  if (a["aria-invalid"] === "true" && !c.includes(DOCUMENT["member-error"]))
    b.push("invalid field, error message is not in the description");
  if (a["aria-invalid"] === "true" && a["aria-describedby"] &&
      !a["aria-describedby"].split(" ").includes("member-help"))
    b.push("error notification overrode the help text");
  for (const x of b) { findings++; console.log("  " + heading.padEnd(G) + x); }
}
console.log(`\n${FIELDS.length} fields, ${findings} findings`);
```

```
field                          accessible name             name source                state
label + placeholder            "Search the catalog"        label element              -
placeholder only               "title, author, or ISBN"    placeholder (last resort)  -
label + help text              "Membership number"         label element              required
error replaces help            "Membership number"         label element              required, invalid
error added to help            "Membership number"         label element              required, invalid
error link broken              "Membership number"         label element              required, invalid
date, label + help             "Return date"               label element              -
title only                     "A note for staff"          title (last resort)        -
aria-label overrides label     "Number"                    aria-label                 -

field                          description                                                            source
label + placeholder            "title, author, or ISBN"                                               placeholder
placeholder only               (empty)                                                                no description
label + help text              "The 8-digit number on the card."                                      aria-describedby
error replaces help            "Membership number must be 8 digits."                                  aria-describedby
error added to help            "The 8-digit number on the card. Membership number must be 8 digits."  aria-describedby
error link broken              "The 8-digit number on the card."                                      aria-describedby
date, label + help             "At most 21 days from now can be selected."                            aria-describedby
title only                     (empty)                                                                no description
aria-label overrides label     (empty)                                                                no description

rule check:
  placeholder only               name comes from a last-resort source, no persistent label
  error replaces help            error notification overrode the help text
  error link broken              broken reference: member-error-2
  error link broken              invalid field, error message is not in the description
  title only                     name comes from a last-resort source, no persistent label
  aria-label overrides label     visible label ("Membership number") is not contained in the accessible name

9 fields, 6 findings
```

#### Reading the Findings

**The placeholder turns into the name, but turning into the name is not enough.** In the
second row, the search field's name becomes "title, author, or ISBN." The computation
accepts this, and the automated checker does not see a presence-rule violation either.
The defect is elsewhere: once the user starts typing into the field, the name is wiped
from the screen, and what the field wants stays invisible. The first row shows the
correct arrangement — the label is the name, the placeholder carries the description,
and each does its own job.

**The error message can override the help text.** In the fourth row, the
`aria-describedby` value has been **replaced** with the help text's id. The field's
description is now only the error message: the user hears the error but loses the
"8-digit" information. The fifth row shows the correct operation — it is added to the id
list, both ids stand together in the list, and the description becomes the combination
of both texts. This is the direct consequence of `aria-describedby` being a
**space-separated ID reference list**.

**A broken reference drops silently.** In the sixth row, the error node's id has been
written incorrectly. The computation skips that id, and the description comes only from
the help text: the field is reported as invalid, but the reason is announced nowhere.
This is the check's most valuable finding, because the error message is visibly present
on screen, and someone looking at it cannot see the defect.

**A name declaration overrides the visible label.** In the last row, the field's `label`
element says "Membership number," and its `aria-label` declaration says "Number." The
2.5.3 Label in Name criterion requires the visible label text to be contained within the
accessible name; here it is not.

#### Keyboard Contract

| Key | Behavior |
|---|---|
| `Tab` | Brings focus to the field; the field is a single tab stop. |
| Typing keys | Changes the value. |
| `Home` / `End` | Moves the cursor to the start and end of the line. |
| Arrow keys | Moves the cursor within the text. |
| `Enter` | Performs implicit submission in a form with a single text field. |

The contract's real constraint is **which keys the field must not capture**. Arrow keys,
`Home`, `End`, and `Backspace` belong to text editing; a component that contains the
field cannot use these keys for its own navigation. This constraint is binding in
dropdown list and menu patterns: if a suggestion list tied to the search field is going
to use the arrow keys, it has to leave the key to the field whenever the cursor needs to
move within the text.

Also, seizing the field's focus programmatically — focusing the search field the moment
the page loads — cuts the keyboard user off from the navigation at the top of the page.
Focus is moved only after an action the user has initiated.

#### Measurable Constraints

**3.3.2 Labels or Instructions** requires that a label or instruction be provided for
every field that requests input from the user. This is the criterion behind the
placeholder not substituting for the label.

**1.4.3 Contrast (Minimum)** also covers placeholder text: the placeholder has to carry
at least a 4.5:1 contrast ratio with the field surface. Fading the placeholder is a
common design preference, and it removes the criterion directly.

**1.4.11 Non-text Contrast** covers the field's border: the boundary showing where the
field starts and ends has to separate from the page surface by at least 3:1. In fields
built with only an underline, this measure is computed over the single edge.

**1.4.4 Resize Text** requires that text be scalable up to 200 percent; a fixed-height
field box clips the text at that scale.

**3.3.1 Error Identification** requires the error to be conveyed in text. A red border
alone is not a notification; the `aria-invalid` state and the tied error message are the
notification itself.

#### Common Mistake

**The label being visually removed and moved into the placeholder.** Catching it is the
check's second row: every field whose name source is flagged as "last resort" is in this
state. If the label genuinely should not be visible — like the standalone search field
above the results table — the element itself stays in the document and is only hidden
from the screen; it is not removed from the tree.

**The error message being shown only on screen.** A text node added below the field is
not tied to the field in the tree unless the `aria-describedby` binding is established.
Catching it means checking whether every field flagged as invalid has the error text
within its description; the check's last two rules do this.

**Requiredness being conveyed only with an asterisk.** The asterisk next to the label is
a visual mark; in the tree, requiredness comes from the `required` attribute. What the
asterisk means has to be written separately, and the attribute has to be present in every
case.

#### Summary

- A text field is used when the value cannot be enumerated in advance; a selection
  control or a dropdown list is chosen for enumerable values.
- The `type` attribute determines the role, built-in validation, and touch keyboard
  together; `autocomplete` declares the field's input purpose programmatically.
- The label goes to the name; the help text and error message go to the description.
  `aria-describedby` is an ID reference list, and the error message is added alongside
  the help text, not in its place.
- The placeholder is last in the name computation order; turning into the name does not
  make it a label, because it is wiped from the screen once the field is filled in.
- A broken ID reference drops silently, and the field appears invalid without a reason;
  the check catches this despite the error message being visible on screen.
- Arrow keys, `Home`, and `End` belong to text editing; a component that contains the
  field cannot use these keys for its own navigation.

#### Next Step

A text field carries a single value, and that value's name is the field's name. The
"Notification method" options in the borrowing form, by contrast, consist of three
separate controls, and all three share one question: "How should the notification be
sent?" That question is not any control's name — it is the **group's** name. The next
lesson takes up the checkbox, the radio button, and the switch: it specifies how the
group is named, which state declaration each of the three controls carries, and why only
one of them has arrow keys in its keyboard contract.

### Selection Controls

Source: https://academia.sh/en/courses/accessible-patterns/selection-controls

The separate problems solved by the checkbox, radio button, and switch; naming the group, declaring the mixed state, and testing two different keyboard contracts.

In the text field, the name's source was the field's own label. The "Notification
method" section in the borrowing form, by contrast, consists of three separate
controls — "Email," "SMS," "No notification" — and the question the user is answering is
not written in any of the three names. The question belongs to the group itself.

This lesson specifies three selection controls: the checkbox, the radio button, and the
switch. All three look similar, but the problems they solve and their keyboard contracts
differ. Setting up the distinction incorrectly produces an interface that keeps working
with the keyboard but behaves differently than expected.

#### Three Controls, Three Separate Problems

**The checkbox** is an independent on/off choice. In the catalog's filter panel, "Only
items on the shelf," "Only e-books," and "Periodicals" are independent of one another;
none of them turns another off. Zero of them can be selected, or all of them can.

**The radio button** is a mutually exclusive choice, and it always sits inside a group.
The borrow duration is 14, 21, or 28 days; when one is selected, the other turns off. A
single radio button on its own is meaningless — once the user selects it, they cannot
back out of the selection.

**The switch** is an on/off **setting**, and it takes effect the instant it is changed.
The "Send return reminders" setting in the user's account is like this: it is changed,
it is saved, and it does not wait for a submit button on the page.

The distinction follows from this: a switch is not used inside a form that will be
confirmed with a submit button; a checkbox is used instead. The promise a switch makes is
"this took effect now"; a switch waiting for the form to be submitted breaks that
promise.

#### The Native Element First

The checkbox and the radio button have native counterparts: the `checkbox` and `radio`
types of the `input` element. Both bring role, checked state, keyboard contract, and
inclusion in the submission list together. What ties radio buttons into a group is their
`name` attribute being the same; the group arises from this name, not from adjacency in
the markup.

The switch has **no native counterpart**. The way it is met is a button carrying a
`role="switch"` declaration; its state is written with `aria-checked`. Writing
`role="switch"` onto a checkbox is also defined, and in that case the element's native
checked state passes into the tree as the switch state.

#### The Group's Name

When the group has no name, the user hears the three options but not the question. The
native counterpart is the `fieldset` and `legend` elements: the `fieldset` element
produces a group role in the tree, and the `legend` element's text becomes the group's
name.

```html
<fieldset>
  <legend>Notification method</legend>
  <p><label><input type="radio" name="notification" value="email" checked> Email</label></p>
  <p><label><input type="radio" name="notification" value="sms"> SMS</label></p>
  <p><label><input type="radio" name="notification" value="none"> No notification</label></p>
</fieldset>

<fieldset>
  <legend>Filters</legend>
  <p><label><input type="checkbox" name="shelf" value="on-shelf"> Only items on the shelf</label></p>
  <p><label><input type="checkbox" name="type" value="ebook"> Only e-books</label></p>
</fieldset>
```

Two details are binding. First, each control's own label is built with the `label`
element; the label wrapping the control (an implicit binding) gives the same result
without writing a `for`/`id` pair, and it also adds the label text to the clickable area.
Second, the radio buttons' `name` value is the same; when it is written differently,
three separate single-option groups form, and the user can select all three at once.

When the group element cannot be used, the counterpart is a `role="group"` declaration,
or `role="radiogroup"` for radio buttons; the group's name is tied to a heading element
with `aria-labelledby`. This route is chosen only when the `fieldset` element's layout
constraints cause a problem.

#### Role, Name, and State

All three controls' names come from their own labels; the group's name does not replace
the control's name, it becomes its context.

The state declaration is checkedness. In native elements it passes to the tree from the
`checked` property; in a hand-built control, it is written with the `aria-checked` value
and updated on every change.

The checkbox has a third state: the **mixed state** (`mixed`). The "Select all" checkbox
in the filter panel is in this state while some subset of the child checkboxes is
selected. In the native element, the mixed state is set not with an attribute but with
the `indeterminate` property, and it is declared to the tree as mixed. The mixed state is
not a third option: when the user clicks it, the control either selects all or clears
all, and it does not return to mixed.

The switch has no mixed state. Whether the switch's state is **also** conveyed in text is
a design decision: "On"/"Off" text is not part of the control's name, because the name
does not change; the state is declared only through `aria-checked`, and the text becomes
an additional indicator.

#### Two Keyboard Contracts

Radio group:

| Key | Behavior |
|---|---|
| `Tab` | Brings focus to the group; the entire group is a **single** tab stop, and focus lands on the selected button. |
| Arrow keys | Move focus and selection together; wrap to the start at the ends. |
| `Space` | Selects the focused button. |
| `Tab` (while inside the group) | Exits the entire group, moves to the next stop. |

Checkbox and switch:

| Key | Behavior |
|---|---|
| `Tab` | Each control is its own tab stop. |
| `Space` | Toggles the state. |
| Arrow keys | Have no effect; focus and selection do not change. |

The difference between the two contracts is that the radio group uses the **roving
tabindex** arrangement: only one item in the group is in the tab order, and the rest are
reached with the arrow keys. A group of fifteen options thus occupies one stop, not
fifteen. Checkboxes cannot use this arrangement because they are independent; the user
has to reach each one separately.

The test below builds both contracts as a state machine and checks them with key
sequences.

```js
// selection.test.mjs — the radio group's and checkbox group's keyboard contract
// Run: node --test selection.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";

const ITEMS = ["Email", "SMS", "No notification"];
const FORWARD = new Set(["ArrowDown", "ArrowRight"]);
const BACK = new Set(["ArrowUp", "ArrowLeft"]);

// focus: null means focus is outside the group
const radio = (selected = null) => ({ kind: "radio", items: ITEMS, selected, focus: null });
const checkbox = () => ({ kind: "checkbox", items: ITEMS, selected: new Set(), focus: null });

// Number of tab stops the group occupies
function tabStops(d) {
  return d.kind === "radio" ? 1 : d.items.length;
}

function key(d, t) {
  const n = d.items.length;
  if (d.kind === "radio") {
    if (d.focus === null) return t === "Tab" ? { ...d, focus: d.selected ?? 0 } : d;
    if (t === "Tab") return { ...d, focus: null };
    if (FORWARD.has(t)) { const i = (d.focus + 1) % n; return { ...d, focus: i, selected: i }; }
    if (BACK.has(t)) { const i = (d.focus + n - 1) % n; return { ...d, focus: i, selected: i }; }
    if (t === "Space") return { ...d, selected: d.focus };
    return d;
  }
  if (d.focus === null) return t === "Tab" ? { ...d, focus: 0 } : d;
  if (t === "Tab") return d.focus === n - 1 ? { ...d, focus: null } : { ...d, focus: d.focus + 1 };
  if (t === "Space") {
    const s = new Set(d.selected);
    if (s.has(d.focus)) s.delete(d.focus); else s.add(d.focus);
    return { ...d, selected: s };
  }
  return d;   // arrow keys do not affect the checkbox group
}

const apply = (d, keys) => keys.reduce(key, d);

test("the radio group is a single tab stop, checkboxes occupy one stop per item", () => {
  assert.equal(tabStops(radio()), 1);
  assert.equal(tabStops(checkbox()), 3);
});

test("focus lands on the selected item when entering the group", () => {
  assert.equal(apply(radio(2), ["Tab"]).focus, 2);
});

test("with no selection, focus lands on the first item on entry and no selection is made", () => {
  const d = apply(radio(), ["Tab"]);
  assert.equal(d.focus, 0);
  assert.equal(d.selected, null);
});

test("arrow keys move focus and selection together, wrapping to the start at the end", () => {
  const d = apply(radio(0), ["Tab", "ArrowDown", "ArrowDown", "ArrowDown"]);
  assert.equal(d.focus, 0);
  assert.equal(d.selected, 0);
  const g = apply(radio(0), ["Tab", "ArrowUp"]);
  assert.equal(g.selected, 2);
});

test("Tab exits the entire group in a single press", () => {
  const d = apply(radio(1), ["Tab", "ArrowDown", "Tab"]);
  assert.equal(d.focus, null);
  assert.equal(d.selected, 2);
});

test("arrow keys do nothing on a checkbox", () => {
  const d = apply(checkbox(), ["Tab", "ArrowDown", "ArrowRight"]);
  assert.equal(d.focus, 0);
  assert.equal(d.selected.size, 0);
});

test("checkboxes toggle independently of one another", () => {
  const d = apply(checkbox(), ["Tab", "Space", "Tab", "Tab", "Space"]);
  assert.deepEqual([...d.selected].sort(), [0, 2]);
  const k = apply(d, ["Space"]);
  assert.deepEqual([...k.selected], [0]);
});

test("Space selects the focused item in a radio group", () => {
  const d = apply(radio(0), ["Tab", "Space"]);
  assert.equal(d.selected, 0);
});
```

```
✔ the radio group is a single tab stop, checkboxes occupy one stop per item (0.301542ms)
✔ focus lands on the selected item when entering the group (0.070375ms)
✔ with no selection, focus lands on the first item on entry and no selection is made (0.042ms)
✔ arrow keys move focus and selection together, wrapping to the start at the end (0.048625ms)
✔ Tab exits the entire group in a single press (0.305208ms)
✔ arrow keys do nothing on a checkbox (0.063833ms)
✔ checkboxes toggle independently of one another (0.412541ms)
✔ Space selects the focused item in a radio group (0.055291ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 34.528375
```

The durations in parentheses and the total duration vary from run to run; the test's
result does not change.

The third test catches the contract's most commonly skipped point: when the group is
entered while no option is checked, focus lands on the first item, but **no selection is
made**. The rule that focus drags the selection along applies only to the arrow keys;
the moment of entry is exempt from it. When this distinction is missed in hand-built
groups, the user ends up selecting a borrow duration they did not want, just by pressing
Tab.

The fifth test is the direct consequence of the roving tabindex: even though two steps
were taken with the arrow keys inside the group, `Tab` exits the group with a single
press.

#### Measurable Constraints

**Target size.** A checkbox's visual box is typically between 16 and 20 pixels and does
not satisfy the 2.5.8 criterion on its own. The label text being clickable together with
the control — the `label` element provides this on its own — enlarges the target up to
the text's width. The vertical spacing between boxes in a filter list also has to be
opened enough to satisfy 2.5.8's spacing exception.

**1.4.11 Non-text Contrast.** The border of the box and the button has to separate from
the page surface by at least 3:1. The same measure applies to the checked indicator: the
mark has to be distinguishable from the fill inside the box.

**1.4.1 Use of Color.** The checked state cannot be conveyed by fill color alone; a
checkmark, a dot, or the switch's shifted position is the second channel.

**3.2.2 On Input.** Changing a checkbox must not submit the form on its own or change the
page. Refreshing the results table when a box is checked in the filter panel is
acceptable behavior, because the context does not change; the page address changing and
focus being lost is not acceptable.

#### Common Mistake

**Using a checkbox for a mutually exclusive choice.** Catching it is behavioral: if the
first box turns off on its own when the user checks the second one, the wrong component
has been chosen. For the user, this means the reason for the turn-off is announced
nowhere.

**Leaving the group nameless.** Catching it is done with a presence rule: every group
carrying radio buttons has to have a name. A heading text sitting above the group on
screen is not a name unless a binding is established.

**Placing a switch in a form that waits for a submit button.** Catching it means asking
whether a saving step is required after the switch is changed. If it is, the component is
converted to a checkbox.

**Declaring the mixed state as `false`.** If the "Select all" checkbox appears off while
some subset of the child checkboxes is selected, the user is given wrong information.
Catching it means comparing the count of the child checkboxes against the parent
checkbox's declared state.

#### Summary

- The checkbox is an independent on/off choice, the radio button is a mutually exclusive
  choice, and the switch is a setting that takes effect the instant it is changed; a
  switch is not used in a form that waits to be submitted.
- The checkbox and radio button have native counterparts; the switch does not and is
  built with `role="switch"`. The radio group is formed by the `name` attribute being
  identical.
- The group's name is given with `fieldset` and `legend`; the group name does not replace
  the control's name, it becomes its context.
- The radio group uses roving tabindex: the group occupies a single stop, and the arrow
  keys move focus and selection together; checkboxes, by contrast, are separate stops and
  do not respond to the arrow keys.
- On first entry into the group, if no option is selected, focus lands on the first item
  but no selection is made; this is the rule most often skipped in hand-built groups.
- The mixed state is not a third option, it is a summary of the child selections; when
  the user clicks, either all are selected or all are cleared.

#### Next Step

A radio group works when the options are enumerable and few in number. The "Subject"
field in the catalog's filter panel, though, carries two hundred fifty values, and not
all of them can be shown on screen. This requires handing the selection work off to a
closable list: the user opens the list, navigates it, narrows it by typing, and selects a
value. The next lesson specifies this component as two separate patterns — the native
selection element and the hand-built combobox — and shows why the second is the pattern
that requires the most declarations.

### Dropdown Lists

Source: https://academia.sh/en/courses/accessible-patterns/dropdown-lists

Comparing the native selection element with the hand-built combobox; separating the active-option declaration from real focus, testing the keyboard contract, and auditing the invariants of reachable states.

A radio group works when the options fit on screen. The "Subject" field in the catalog's
filter panel carries more than two hundred fifty values; not all of them can be shown on
screen, and even if they were, they could not be navigated. This requires handing the
selection work off to a closable list.

The pattern has two implementations, and the difference between them cannot be
summarized in a single line: one arrives with a single element, the other requires
hand-writing ten separate declarations. This lesson specifies both and counts the cost
of the second.

#### The Problem the Pattern Solves

The dropdown list selects a single value from a set of options that is **enumerable but
crowded**. It has two boundaries. At the lower boundary, the list is unnecessary if there
are fewer than five options: four borrow-duration options stand on screen as a radio
group, and the user sees all of them at a glance. At the upper boundary, if the options
come from a set the user does not know in advance — like the author names across the
entire catalog — search is required, not a list.

The list is also not the right component wherever multiple values need to be selected. A
multi-select dropdown pattern does not show the user all of their selections at once; a
checkbox group or a pattern that displays selections separately is used instead.

#### The Native Element First

The counterpart is the `select` element. What it brings: the combobox role, a name
computed from the `label` element, an open/closed state declaration, the selected option
declared as the value, grouping with `optgroup`, the entire keyboard contract, and
type-ahead search.

It has a cost, and it has to be written into the specification: the appearance of the
opened option list cannot be controlled from the presentation layer. The list is
constructed outside the page's rendering plane; the typeface, color, row height, and any
extra information placed inside an option are not open to design decisions.

In the catalog's "Subject" field, this cost is affordable. The results-ordering
criterion, borrow-duration, and page-size selectors are all met with the native element.
A hand-built component is chosen only if one of three requirements exists: showing extra
information in the option row (record count, subtitle), filtering by typing, or fetching
options from the server by search.

#### The Cost of the Custom Component

The declarations that have to be reproduced in a hand-built component are: role
(`role="combobox"`), open/closed state (`aria-expanded`), the id of the controlled list
(`aria-controls`), the list role (`role="listbox"`), option roles (`role="option"`), the
active option's declaration (`aria-activedescendant`), the selected option's declaration
(`aria-selected`), the autocomplete format (`aria-autocomplete`), the entire keyboard
contract, and announcing the filter result.

```html
<label for="subject">Subject</label>
<input id="subject" role="combobox" aria-expanded="true" aria-controls="subject-list"
       aria-activedescendant="subject-2" aria-autocomplete="list" autocomplete="off">
<ul id="subject-list" role="listbox" aria-label="Subject options">
  <li id="subject-1" role="option" aria-selected="false">Architecture</li>
  <li id="subject-2" role="option" aria-selected="true">Microbiology</li>
</ul>
<p role="status">2 subjects listed</p>
```

The distinctive part of this markup is the `aria-activedescendant` attribute. Keyboard
focus **never leaves the field**; what navigates the list is not focus, it is a
declaration. When the user presses an arrow key, the cursor in the field stays put, and
in the tree, a different option is announced as active. This distinction preserves the
field's text-editing behavior; if focus actually moved to the list, continuing to type
would be impossible.

The distinction's consequence for design is that **two separate indicators** are
required: a focus ring in the field, an active-option highlight in the list. If either is
missing, the user cannot see either where they are or what will be selected.

#### Keyboard Contract

| Key | Behavior |
|---|---|
| `Tab` | The field is a single tab stop; focus stays in the field even while the list is open. The list closes and focus moves out. |
| `Arrow Down` | Opens the list and activates the first option if closed; moves to the next one if open. |
| `Arrow Up` | Opens the list and activates the last option if closed; moves to the previous one if open. |
| `Enter` | Makes the active option the value and closes the list. |
| `Escape` | Closes the list and keeps the value if open; clears the field if closed. |
| Typing keys | Opens the list and filters the options. |
| `Home` / `End` | Moves the cursor within the text; does not belong to the list. |

The arrow keys **not wrapping** at the ends is the point where this diverges from the
radio group's contract. Wrapping in a radio group does not lose the user, because the
entire group is on screen. Wrapping in a long list throws the user to the other end of
the list with a single keystroke and makes it impossible for them to understand where
they are without reading.

The test below builds the contract as a state machine, checks it with key sequences, and
in the last test scans every reachable state to verify three invariants.

```js
// dropdown.test.mjs — the combobox's keyboard contract and invariants
// Run: node --test dropdown.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";

const SUBJECTS = ["Microeconomics", "Microbiology", "Music History", "Mathematics"];
const lower = (s) => s.toLocaleLowerCase("en-US");

const initial = () => ({ focusInField: true, open: false, active: null, text: "", value: null });
const options = (d) => SUBJECTS.filter((k) => lower(k).startsWith(lower(d.text)));

function key(d, t) {
  const s = options(d);
  if (t === "ArrowDown") {
    if (!d.open) return { ...d, open: true, active: s.length ? 0 : null };
    if (d.active === null) return { ...d, active: s.length ? 0 : null };
    return { ...d, active: Math.min(d.active + 1, s.length - 1) };   // no wraparound
  }
  if (t === "ArrowUp") {
    if (!d.open) return { ...d, open: true, active: s.length ? s.length - 1 : null };
    if (d.active === null) return { ...d, active: s.length ? s.length - 1 : null };
    return { ...d, active: Math.max(d.active - 1, 0) };
  }
  if (t === "Enter") {
    if (d.open && d.active !== null)
      return { ...d, open: false, active: null, text: s[d.active], value: s[d.active] };
    return d;
  }
  if (t === "Escape") {
    if (d.open) return { ...d, open: false, active: null };          // close the list
    return { ...d, text: "", value: null };                          // clear the field
  }
  if (t === "Tab") return { ...d, open: false, active: null, focusInField: false };
  if (t.startsWith("type:")) return { ...d, text: d.text + t.slice(5), open: true, active: null };
  return d;
}

const apply = (d, keys) => keys.reduce(key, d);

test("when closed, ArrowDown opens the list and makes the first option active", () => {
  const d = apply(initial(), ["ArrowDown"]);
  assert.equal(d.open, true);
  assert.equal(options(d)[d.active], "Microeconomics");
});

test("when closed, ArrowUp opens the list and makes the last option active", () => {
  const d = apply(initial(), ["ArrowUp"]);
  assert.equal(options(d)[d.active], "Mathematics");
});

test("ArrowDown does not wrap to the start at the last option", () => {
  const d = apply(initial(), ["ArrowDown", "ArrowDown", "ArrowDown", "ArrowDown", "ArrowDown"]);
  assert.equal(d.active, 3);
  assert.equal(options(d)[d.active], "Mathematics");
});

test("Enter makes the active option the value and closes the list", () => {
  const d = apply(initial(), ["ArrowDown", "ArrowDown", "Enter"]);
  assert.equal(d.value, "Microbiology");
  assert.equal(d.open, false);
  assert.equal(d.active, null);
});

test("typing opens the list and filters it, the active option resets", () => {
  const d = apply(initial(), ["type:M", "type:i"]);
  assert.deepEqual(options(d), ["Microeconomics", "Microbiology"]);
  assert.equal(d.active, null);
});

test("Escape closes the list first, clears the field on the second press", () => {
  const first = apply(initial(), ["type:M", "type:i", "ArrowDown", "Escape"]);
  assert.equal(first.open, false);
  assert.equal(first.text, "Mi");
  const second = apply(first, ["Escape"]);
  assert.equal(second.text, "");
  assert.equal(second.value, null);
});

test("Tab closes the list and takes focus out of the field", () => {
  const d = apply(initial(), ["ArrowDown", "Tab"]);
  assert.equal(d.open, false);
  assert.equal(d.focusInField, false);
});

test("with no match, the list opens but there is no active option", () => {
  const d = apply(initial(), ["type:Z"]);
  assert.deepEqual(options(d), []);
  const e = apply(d, ["ArrowDown"]);
  assert.equal(e.active, null);
});

test("invariants hold across every reachable state, and focus stays in the field", () => {
  const KEYS = ["ArrowDown", "ArrowUp", "Enter", "Escape", "type:M", "type:i", "type:a"];
  const stateKey = (d) => [d.open, d.active, d.text, d.value].join("|");
  const seen = new Map([[stateKey(initial()), initial()]]);
  const queue = [initial()];
  while (queue.length) {
    const d = queue.shift();
    for (const t of KEYS) {
      const y = key(d, t);
      if (y.text.length > 3) continue;               // scan length is capped
      if (seen.has(stateKey(y))) continue;
      seen.set(stateKey(y), y);
      queue.push(y);
    }
  }
  for (const d of seen.values()) {
    assert.equal(d.focusInField, true, "focus left the field");
    if (!d.open) assert.equal(d.active, null, "active option remained in a closed list");
    if (d.active !== null) {
      assert.ok(d.open, "list is closed while an active option exists");
      assert.ok(d.active < options(d).length, "active option is not in the list");
    }
  }
  console.log(`  states scanned: ${seen.size}`);
});
```

```
  states scanned: 90
✔ when closed, ArrowDown opens the list and makes the first option active (0.369792ms)
✔ when closed, ArrowUp opens the list and makes the last option active (0.049834ms)
✔ ArrowDown does not wrap to the start at the last option (0.061833ms)
✔ Enter makes the active option the value and closes the list (0.041791ms)
✔ typing opens the list and filters it, the active option resets (0.286583ms)
✔ Escape closes the list first, clears the field on the second press (0.056916ms)
✔ Tab closes the list and takes focus out of the field (0.042209ms)
✔ with no match, the list opens but there is no active option (0.049791ms)
✔ invariants hold across every reachable state, and focus stays in the field (0.932875ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 35.087417
```

The duration values vary from run to run. The number of states scanned, though, depends
on the typing length being capped at three characters; when the cap is removed, the state
space is infinite, because the user can type as many characters as they want.

#### Reading the Invariants

The last test checks three rules, and all three are places where the screen and the tree
can diverge.

**No active option can remain in a closed list.** If the list is closed without clearing
the `aria-activedescendant` value, an option that no longer exists in the tree appears
active. This leads the user to think they are still inside the list after closing it.

**The active option has to be in the list.** When the list shortens after filtering, the
old index becomes invalid. In the test, this is prevented by the `type:` keys resetting
the active option; without the reset, the declaration would point to an id that no longer
exists and would drop silently.

**Focus does not leave the field.** All ninety states satisfy the condition. This follows
from the pattern's definition: an implementation where focus moves to the list is not the
`aria-activedescendant` pattern, it is a different pattern, and in that case the ability
to filter by typing is lost.

#### Measurable Constraints

**4.1.2 Name, Role, Value.** The field's name, its combobox role, its open/closed state,
and its selected value have to be programmatically readable. In a hand-built component,
all four are written separately.

**2.4.7 Focus Visible.** Real focus is in the field; its indicator has to be present in
the field. The active-option highlight does not replace this indicator, it is added to
it.

**1.4.11 Non-text Contrast.** The active-option highlight has to separate from
neighboring rows by at least 3:1. A highlight built only with a light gray surface does
not satisfy this measure.

**1.4.13 Content on Hover or Focus.** The opened list is content that appears on focus,
and it has to satisfy three conditions: it has to be dismissible with `Escape`, the
pointer has to be able to move over the list, and it must not disappear without the user
taking an action.

**Announcing the filter result.** How many options remain in a list filtered by typing
is conveyed with a polite live region. The announcement is sent not on every keystroke
but after typing stops; otherwise, the announcement queue prevents the user from hearing
what they type.

#### Common Mistake

**Focus moving to the list.** Catching it means trying to type while the list is open: if
the character typed does not go to the field, focus has shifted. In this pattern, the fix
is not moving focus back, it is switching to the `aria-activedescendant` declaration.

**The open/closed state not being updated.** The `aria-expanded` value staying `false`
while the list is visible on screen is the tree contradicting the screen. Catching it is
a uniqueness and consistency check: the list's visibility is compared against the
declared state.

**The native element being abandoned for the sake of appearance.** The hand-built
component's cost is ten declarations, and each is a separate source of error. Catching it
happens at the specification stage: if none of the requirements — extra information in
the option row, filtering by typing, or fetching from the server — exists, the native
element is the decision.

#### Summary

- A dropdown list is for option sets that are enumerable but do not fit on screen; a
  radio group is used for fewer than five options, and search is used for sets that are
  not known in advance.
- The `select` element brings the role, name, state, value, and keyboard contract
  together; its cost is that the opened list's appearance cannot be controlled from the
  presentation layer.
- The hand-built component reproduces ten separate declarations; it is chosen only when
  inline extra information, filtering by typing, or fetching from the server is required.
- In the `aria-activedescendant` pattern, focus does not leave the field; what navigates
  the list is a declaration, and this is why two separate visual indicators are required.
- Arrow keys do not wrap at the ends; wrapping behavior belongs only to radio groups whose
  entirety sits on screen.
- No active-option declaration can remain in a closed list, and the active option always
  has to be in the filtered list; resetting the declaration after filtering guarantees
  this.

#### Next Step

The four components so far were each taken up on their own: a button, a field, a group, a
list. The borrowing form, by contrast, carries all of them together and raises new
questions. By what measure are fields grouped? When a form is split into three steps, how
is the step change announced, and where does focus go? If submission fails, does the user
lose what they typed? The next lesson specifies the form as a whole and shows where
components that are correct individually can work incorrectly together.

### Form Patterns

Source: https://academia.sh/en/courses/accessible-patterns/form-patterns

The criterion for grouping fields, focus and announcement management in a multi-step form, save and undo behavior, and comparing two layouts with a rule check.

The previous four lessons specified components one at a time. The borrowing form, though, carries a
button, three fields, and a radio group; even if each one is built correctly on its own, the form as a
whole may not work. Which group a field belongs to, how a step change is announced in a form split into
three steps, and whether submission can be undone are not written in any component specification.

This lesson specifies the form as a whole. What is measured is the defects that
individually correct components can produce together.

#### The Splitting Criterion

A form is not split because it has many fields. The splitting criterion is that the
fields form **decision sets independent of one another**: in the borrowing form, record
selection, member information, and the duration decision are separate sets; each can be
validated on its own, and the user does not move to the next without completing one.

When the criterion is not met, splitting causes harm. Spreading a five-field
correction-report form across three pages keeps the user from seeing all of it at once
and adds a burden that requires focus management at every transition. Short forms stay
in a single view.

#### Field Grouping

The criterion for grouping is that the fields answer a **shared question**. "Return
date" and "Borrow duration" answer a shared question; "Membership number" and "Borrow
duration" do not. Standing close together on screen is not a grouping justification.

Fields that share a question are wrapped with `fieldset` and `legend`; the group's name
is the question itself. A group carrying only one field is not built — that field's
label is already the question, and an additional group name doubles the length of the
announcement.

```html
<h2 id="step-2" tabindex="-1">Member information</h2>
<p id="step-status" role="status">Step 2 / 3</p>

<form action="/borrow/step-2" method="post">
  <fieldset>
    <legend>Notification method</legend>
    <p><label><input type="radio" name="notification" value="email" checked> Email</label></p>
    <p><label><input type="radio" name="notification" value="none"> No notification</label></p>
  </fieldset>

  <p><label for="member-no">Membership number</label>
     <input id="member-no" name="member-no" required inputmode="numeric"
            aria-describedby="member-help">
     <span id="member-help">The 8-digit number on the card.</span></p>

  <p><button type="submit">Next</button>
     <button type="button" name="back">Back</button></p>
</form>
```

The order and type of the buttons are part of the specification. The "Next" button is
the form's **first submit button**; a user who presses Enter while in a text field runs
this button. The "Back" button carries `type="button"`; if it did not, implicit
submission would take the form backward, and the user would lose a step.

#### A Step Change Is Not a Page Change

In a transition made by refreshing the page, focus returns to the start of the document,
and the user enters the new document from the top. In a transition made without
refreshing the page, nothing happens on its own: the content on screen changes, focus
stays on the old button, and for a user who cannot see the screen, it looks as if nothing
happened.

The specification requires three things: focus moves to the new step's heading, the
transition is announced with a polite live region, and the step indicator states which
step it is in text. The heading carrying `tabindex="-1"` is so it can take focus without
entering the tab order.

The script below runs the same scenario under two layouts and checks four rules.

```js
// form.mjs — auditing focus management, announcements, and value preservation in a multi-step form

const STEPS = [
  { name: "Record confirmation", heading: "step-1", fields: [
    { id: "record", label: "Record number", required: true,
      rule: (v) => /^K-\d{3}$/.test(v), message: "Record number must be in the K-000 format." },
  ] },
  { name: "Member information", heading: "step-2", fields: [
    { id: "member-no", label: "Membership number", required: true,
      rule: (v) => /^\d{8}$/.test(v), message: "Membership number must be 8 digits." },
    { id: "email", label: "Notification address", required: false,
      rule: (v) => v === "" || v.includes("@"), message: "Enter a valid email address." },
  ] },
  { name: "Duration and confirmation", heading: "step-3", fields: [
    { id: "duration", label: "Borrow duration", required: true,
      rule: (v) => ["14", "21", "28"].includes(v), message: "Select a borrow duration." },
  ] },
];

const initial = () => ({
  step: 0, values: {}, errors: [], focus: "input#record", announcement: "", done: false,
});

function validate(step, values) {
  const h = [];
  for (const a of STEPS[step].fields) {
    const v = values[a.id] ?? "";
    if (a.required && v === "") h.push([a.id, a.label, "This field is required."]);
    else if (v !== "" && !a.rule(v)) h.push([a.id, a.label, a.message]);
  }
  return h;
}

// layout: movesFocus, announcesTransition, preservesValues
function machine(layout) {
  const focusTo = (target, previous) => (layout.movesFocus ? target : previous);
  const announce = (m) => (layout.announcesTransition ? m : "");
  return (d, e) => {
    if (e.kind === "input") return { ...d, values: { ...d.values, [e.id]: e.value } };
    if (e.kind === "back") {
      if (d.step === 0) return d;
      const y = d.step - 1;
      return { ...d, step: y, errors: [],
        values: layout.preservesValues ? d.values : {},
        focus: focusTo("h2#" + STEPS[y].heading, d.focus),
        announcement: announce(`Step ${y + 1} / ${STEPS.length}: ${STEPS[y].name}`) };
    }
    if (e.kind === "next" || e.kind === "submit") {
      const errors = validate(d.step, d.values);
      if (errors.length > 0) {
        return { ...d, errors, focus: focusTo("div#error-summary", d.focus),
          announcement: announce(`${errors.length} field(s) need correction`) };
      }
      if (e.kind === "submit" && d.step === STEPS.length - 1) {
        return { ...d, errors: [], done: true, focus: focusTo("h2#result", d.focus),
          announcement: announce("Borrow request recorded") };
      }
      const y = Math.min(d.step + 1, STEPS.length - 1);
      return { ...d, step: y, errors: [],
        focus: focusTo("h2#" + STEPS[y].heading, d.focus),
        announcement: announce(`Step ${y + 1} / ${STEPS.length}: ${STEPS[y].name}`) };
    }
    return d;
  };
}

const SCENARIO = [
  { kind: "input", id: "record", value: "K-118" },
  { kind: "next" },
  { kind: "input", id: "member-no", value: "1234" },
  { kind: "next" },
  { kind: "input", id: "member-no", value: "10045512" },
  { kind: "next" },
  { kind: "back" },
  { kind: "next" },
  { kind: "input", id: "duration", value: "21" },
  { kind: "submit" },
];

const FOCUSABLE = new Set([
  "input#record", "div#error-summary", "h2#step-1", "h2#step-2", "h2#step-3", "h2#result",
]);

function run(heading, layout, logTrace) {
  const next = machine(layout);
  let d = initial();
  const trace = [];
  console.log(heading);
  if (logTrace) console.log("  action".padEnd(24) + "step".padEnd(31) + "focus".padEnd(20) + "announcement");
  for (const e of SCENARIO) {
    const previous = d;
    d = next(d, e);
    trace.push({ e, previous, d });
    if (!logTrace) continue;
    const label = e.kind === "input" ? `input ${e.id}` : e.kind;
    const stepName = d.done ? "result" : `${d.step + 1}/${STEPS.length} ${STEPS[d.step].name}`;
    console.log("  " + label.padEnd(22) + stepName.padEnd(31) + d.focus.padEnd(20) +
      (d.announcement || "-"));
  }

  const findings = [];
  for (const { e, previous, d: s } of trace) {
    if (e.kind === "input") continue;
    if (s.focus === previous.focus && s.step !== previous.step)
      findings.push(`${e.kind}: step changed, focus did not move`);
    if (s.errors.length > 0 && s.focus !== "div#error-summary")
      findings.push(`${e.kind}: error present, focus did not move to the summary`);
    if (!FOCUSABLE.has(s.focus))
      findings.push(`${e.kind}: focus target is not focusable (${s.focus})`);
    if (s.announcement === "") findings.push(`${e.kind}: transition was not announced`);
    for (const k of Object.keys(previous.values)) {
      if (previous.values[k] !== s.values[k])
        findings.push(`${e.kind}: value of the ${k} field was lost`);
    }
  }
  console.log(`  findings: ${findings.length}`);
  for (const b of findings) console.log("    " + b);
  return trace;
}

run("flawed layout (focus does not move, transitions are not announced, values are cleared going back):",
  { movesFocus: false, announcesTransition: false, preservesValues: false }, false);

console.log("");
const trace = run("fixed layout:",
  { movesFocus: true, announcesTransition: true, preservesValues: true }, true);

const failed = trace.find(({ d }) => d.errors.length > 0).d;
console.log("\nsummary links in the failed transition:");
for (const [id, label, message] of failed.errors) {
  console.log(`  <a href="#${id}">${label}: ${message}</a>`);
}
```

```
flawed layout (focus does not move, transitions are not announced, values are cleared going back):
  findings: 14
    next: step changed, focus did not move
    next: transition was not announced
    next: error present, focus did not move to the summary
    next: transition was not announced
    next: step changed, focus did not move
    next: transition was not announced
    back: step changed, focus did not move
    back: transition was not announced
    back: value of the record field was lost
    back: value of the member-no field was lost
    next: error present, focus did not move to the summary
    next: transition was not announced
    submit: error present, focus did not move to the summary
    submit: transition was not announced

fixed layout:
  action                step                           focus               announcement
  input record          1/3 Record confirmation        input#record        -
  next                  2/3 Member information         h2#step-2           Step 2 / 3: Member information
  input member-no       2/3 Member information         h2#step-2           Step 2 / 3: Member information
  next                  2/3 Member information         div#error-summary   1 field(s) need correction
  input member-no       2/3 Member information         div#error-summary   1 field(s) need correction
  next                  3/3 Duration and confirmation  h2#step-3           Step 3 / 3: Duration and confirmation
  back                  2/3 Member information         h2#step-2           Step 2 / 3: Member information
  next                  3/3 Duration and confirmation  h2#step-3           Step 3 / 3: Duration and confirmation
  input duration        3/3 Duration and confirmation  h2#step-3           Step 3 / 3: Duration and confirmation
  submit                result                         h2#result           Borrow request recorded
  findings: 0

summary links in the failed transition:
  <a href="#member-no">Membership number: Membership number must be 8 digits.</a>
```

The flawed layout produces fourteen findings, and two of them are cascading defects:
because values are cleared going back, the next two transitions also fail to pass
validation. The only thing visible on screen is "the form going blank when you go back";
the remaining twelve findings never appear on screen.

Two rows in the fixed run's trace need attention. In the fourth row, validation fails and
focus goes to the error summary; the step number does not change. In the seventh row,
going back is also a step change and requires the **same** focus and announcement
handling as going forward; leaving the backward direction silent is a commonly skipped
case.

#### Keyboard Contract

| Key | Behavior |
|---|---|
| `Tab` / `Shift+Tab` | Moves between fields in source order. |
| `Enter` (single-line field) | Runs the form's first submit button. |
| `Enter` (multi-line field) | Inserts a line break; does not submit the form. |
| `Enter` (on a button) | Runs the focused button. |

The contract's constraint is **which button** implicit submission runs. If the form's
first submit button is "Back," a user who presses Enter in a field falls back a step.
Every button that does not carry submission behavior is written with `type="button"`.

#### Saving and Undo

A borrow request produces a record; it is a binding operation on the user's own data.
The 3.3.4 Error Prevention (Legal, Financial, Data) criterion requires **at least one**
of three routes for this kind of submission: submission has to be reversible, entered
data has to be checkable and correctable, or it has to be confirmed before submission.

In the three-step form, the second route is established on its own: the last step shows
all entered values as a summary and carries a link back to the relevant step next to each
row. It is not enough for the summary to be plain text; the user has to be able to reach
the field they want to correct with an action.

Two additional rules are written into the specification. Leaving the page while unsaved changes exist is
met with a warning; when the warning is a modal dialog, it requires focus management, and this is the next
topic's responsibility. If there is a session timeout, 2.2.1 Timing Adjustable comes into play: the user
has to be able to turn off, adjust, or extend the time limit. Preserving the data across re-authentication
is the subject of a separate criterion (2.2.5). A twenty-minute session silently wiping a long form
removes this criterion.

#### Measurable Constraints

**3.3.2 Labels or Instructions** requires a label or instruction on every field; **3.3.1
Error Identification** requires the erroneous field to be identified in text; **3.3.3
Error Suggestion** requires a correction suggestion to be given when known. The
"Membership number must be 8 digits" message in the check is the counterpart of this
third criterion: it does not stop at reporting the error, it states the correct format.

**1.3.5 Identify Input Purpose** requires the purpose to be declared programmatically on
fields that collect the user's own information; the name and notification address
fields in the borrowing form fall within this scope.

**2.4.6 Headings and Labels** requires step headings to actually describe the content:
"Step 2" is not a heading, "Member information" is the heading, and the step number is
given in separate text.

#### Common Mistake

**The step indicator being only visual.** A filling bar or three dots that change color
say nothing to a user who cannot see the screen. Catching it means checking whether the
indicator's text equivalent is present in the tree.

**Leaving the error summary where it was built.** When the summary is built but focus is
not moved, the user cannot learn that submission failed. Catching it is the check's
second rule: whether focus moves to the summary when an error exists is tested.

**Leaving the backward direction silent.** The focus and announcement handling
established for going forward is also required going back. Catching it means running
both directions through the same rule set.

**Building a single-field group.** Wrapping one field in a `fieldset` announces the
field's name twice, together with the group name. Catching it means counting the number
of fields inside the group.

#### Summary

- A form is split by independent decision sets, not by field count; short forms stay in
  a single view.
- The criterion for grouping is that fields answer a shared question; single-field groups
  are not built.
- A step change requires three operations: moving focus to the new step's heading,
  announcing the transition with polite priority, and giving the step indicator in text.
  Going back is also a step change.
- The form's first submit button runs on implicit submission; buttons that do not carry
  submission behavior are written with `type="button"`.
- 3.3.4 requires at least one of the routes of undo, correction, or confirmation for
  binding submissions; the correctable summary on the last step establishes the second
  route.
- Most of the findings the check produces are not visible on screen; focus and
  announcement defects surface only through rule testing.

#### Next Step

Every component in this topic ran an action: running a search, applying a filter,
submitting a form. The record titles in the results table, by contrast, do a different
job — they take the user to another resource. The two can be made to look alike on
screen, but their keyboard contracts, their roles in the tree, and what the user expects
from them differ. The next lesson ties this distinction to a rule and shows which
concrete breakages the confusion produces.

### Link and Button Distinction

Source: https://academia.sh/en/courses/accessible-patterns/link-and-button-distinction

The decision rule based on source change, comparing the two components' keyboard contracts, counting the losses a mismatch produces, and auditing link text against purpose criteria.

Every control in the borrowing form ran an action. The record titles in the results
table, by contrast, do a different job: they take the user to the record detail page.
The two can be made to look alike enough in the presentation layer to substitute for one
another — a link can be drawn boxed and filled, a button can look like underlined text.

In the tree, though, the two are distinct, and this distinction is not merely a naming
preference: the two components' keyboard contracts, the extra capabilities the browser
offers, and what the user expects from them differ. This lesson ties the distinction to
a decision rule and counts what its violation costs.

#### The Decision Rule

The rule is a single sentence: **if the user is going to another resource, it is a link;
if not, it is a button.** The measure is whether the address bar's address changes after
it runs; a download also counts as accessing a resource.

Some cases the rule works through look counterintuitive. A control that writes filter
selections to the address — an arrangement that turns the "Only items on the shelf"
state into a shareable address — is a link, because what it produces is an address that
can be sent to someone else. A control that jumps to a section within the page is also a
link; its target is a document fragment, and it enters browser history. A filter
application that refreshes the results table but does not change the address, by
contrast, is a button.

#### The Native Element First

The link's counterpart is the `a` element carrying the `href` attribute; the button's
counterpart is the `button` element. An `a` element without `href` is a meaningless node
in the tree: it produces no role, takes no focus, and no key operates it.

The link brings four capabilities the button does not provide: copying the address,
opening in a new tab from the context menu, opening with the middle click, and appearing
in the link list assistive technology produces. None of these are things that can be
written in code; they are behaviors the browser ties to the link element.

#### Role, Name, and State

Both components' names are computed from their content. The link carries an additional
constraint: the name has to say **where it goes**.

Having a link that reads "Detail" at the end of every row is a common arrangement in a
results table. In a forty-row table, this produces forty identically named links in the
tree. A user who opens the link list sees forty identical rows and cannot tell where any
of them go. The fix is either to expand the link text to the record's name or to
complete the visible text with a hidden segment so the name becomes "The Language of
Structures detail."

The link has no persistent state declaration. A link showing the current page carries
`aria-current="page"`; this is a state too, but not an interaction state like open/closed — it is a
declaration of position, and it is taken up in navigation components.

#### Two Keyboard Contracts

| Key / action | Link | Button |
|---|---|---|
| `Tab` | A tab stop. | A tab stop. |
| `Enter` | Follows the link. | Runs the button. |
| `Space` | Scrolls the page down. | Runs the button. |
| Context menu / middle click | Opens in a new tab, copies the address. | Has no counterpart. |

The difference in the Space key is the mismatch's most visible consequence. A user who
presses Space on an action control built like a link scrolls the page; on a navigation
control built like a button, the "open in new tab" option never appears in the context
menu.

#### Auditing the Mapping

```js
// link-or-button.mjs — formalizing and auditing the decision between a link and a button

// Decision rule: if the source changes (address or download), link; otherwise button.
const decide = (n) => (n.addressChanges || n.download ? "link" : "button");

// Behavior produced by the markup
function produced(m) {
  const isLink = m.element === "a" && m.href !== undefined;
  const isButton = m.element === "button";
  const role = m.role ?? (isLink ? "link" : isButton ? "button" : "generic");
  return {
    role,
    focusable: isLink || isButton || m.tabindex === 0,
    enter: isLink || isButton || m.keysWork === true,
    space: isButton || m.keysWork === true,
    newTab: isLink,          // context menu and middle-click only work when there is an address
    addressCopies: isLink,
    inLinkList: role === "link",
  };
}

// --- Controls in the catalog interface ---------------------------------------
const EXAMPLES = [
  ['<a href="/record/K-118">The Language of Structures</a>',
   { addressChanges: true }, { element: "a", href: "/record/K-118" }],
  ['<a href="#" tabindex="0">Clear filters</a>',
   { addressChanges: false }, { element: "a", href: "#", keysWork: true }],
  ['<button>Record detail</button> (navigates via code)',
   { addressChanges: true }, { element: "button" }],
  ['<a href="/borrow/K-118" role="button">Borrow</a>',
   { addressChanges: true }, { element: "a", href: "/borrow/K-118", role: "button" }],
  ['<div tabindex="0">Search</div>',
   { addressChanges: false }, { element: "div", tabindex: 0 }],
  ['<button type="submit">Search</button>',
   { addressChanges: false }, { element: "button" }],
  ['<a href="/citation/K-118.pdf" download>Download citation</a>',
   { addressChanges: false, download: true }, { element: "a", href: "/citation/K-118.pdf" }],
  ['<span role="link" tabindex="0">Help</span>',
   { addressChanges: true }, { element: "span", role: "link", tabindex: 0 }],
];

const yn = (b) => (b ? "yes" : "no");

console.log("markup".padEnd(62) + "required".padEnd(10) + "role".padEnd(10) +
  "Enter".padEnd(7) + "Space".padEnd(8) + "new tab".padEnd(12) + "link list");
for (const [markup, intent, m] of EXAMPLES) {
  const u = produced(m);
  console.log(markup.padEnd(62) + decide(intent).padEnd(10) + u.role.padEnd(10) +
    yn(u.enter).padEnd(7) + yn(u.space).padEnd(8) + yn(u.newTab).padEnd(12) +
    yn(u.inLinkList));
}

console.log("\nrule check:");
let findings = 0;
for (const [markup, intent, m] of EXAMPLES) {
  const g = decide(intent);
  const u = produced(m);
  const b = [];
  if (g === "link" && u.role !== "link")
    b.push("navigation declared as a button: address cannot be copied, cannot open in a new tab");
  if (g === "button" && u.role === "link")
    b.push("action declared as a link: enters history, target address is meaningless");
  if (!u.focusable) b.push("cannot be focused with the keyboard");
  if (!u.enter) b.push("Enter key does not work");
  if (u.role === "button" && !u.space) b.push("button role declared, Space key does not work");
  if (u.role === "link" && m.element !== "a") b.push("link role declared, no address");
  for (const x of b) { findings++; console.log("  " + markup.padEnd(62) + x); }
}
console.log(`\n${EXAMPLES.length} examples, ${findings} findings`);

// --- Decision table -----------------------------------------------------------
console.log("\ndecision table:");
console.log("behavior".padEnd(52) + "component");
const BEHAVIORS = [
  ["goes to the record detail page", { addressChanges: true }],
  ["filters the results table, address does not change", { addressChanges: false }],
  ["writes the filter state to the address", { addressChanges: true }],
  ["downloads the citation file", { addressChanges: false, download: true }],
  ["submits the borrow request", { addressChanges: false }],
  ["jumps to a section within the page", { addressChanges: true }],
];
for (const [behavior, n] of BEHAVIORS) console.log(behavior.padEnd(52) + decide(n));
```

```
markup                                                        required  role      Enter  Space   new tab     link list
<a href="/record/K-118">The Language of Structures</a>        link      link      yes    no      yes         yes
<a href="#" tabindex="0">Clear filters</a>                    button    link      yes    yes     yes         yes
<button>Record detail</button> (navigates via code)           link      button    yes    yes     no          no
<a href="/borrow/K-118" role="button">Borrow</a>              link      button    yes    no      yes         no
<div tabindex="0">Search</div>                                button    generic   no     no      no          no
<button type="submit">Search</button>                         button    button    yes    yes     no          no
<a href="/citation/K-118.pdf" download>Download citation</a>  link      link      yes    no      yes         yes
<span role="link" tabindex="0">Help</span>                    link      link      no     no      no          yes

rule check:
  <a href="#" tabindex="0">Clear filters</a>                    action declared as a link: enters history, target address is meaningless
  <button>Record detail</button> (navigates via code)           navigation declared as a button: address cannot be copied, cannot open in a new tab
  <a href="/borrow/K-118" role="button">Borrow</a>              navigation declared as a button: address cannot be copied, cannot open in a new tab
  <a href="/borrow/K-118" role="button">Borrow</a>              button role declared, Space key does not work
  <div tabindex="0">Search</div>                                Enter key does not work
  <span role="link" tabindex="0">Help</span>                    Enter key does not work
  <span role="link" tabindex="0">Help</span>                    link role declared, no address

8 examples, 7 findings

decision table:
behavior                                            component
goes to the record detail page                      link
filters the results table, address does not change  button
writes the filter state to the address              link
downloads the citation file                         link
submits the borrow request                          button
jumps to a section within the page                  link
```

#### Reading the Findings

**A link with an empty address is wrong twice over.** The filter-clearing control in the
second row is an action but has been declared as a link. It has two consequences: a
meaningless entry enters browser history, and nothing happens when the user presses the
back button; a row with no destination appears in the link list. And if the user opens
it in a new tab from the context menu, it brings up a blank page.

**A role declaration does not make a link a button.** The fourth row shows this. When
`role="button"` is written, the role in the tree changes, but the element is still a
link: the Space key does not work, and the ability to open in a new tab remains. What
results is a component that is a button on screen, a button in the tree, and a link in
behavior, and the three contradict one another.

**A role declaration does not add behavior either.** The `span` element carrying
`role="link"` in the last row is focusable, because `tabindex` has been written; but it
does not respond to the Enter key and carries no address to go to. It appears in the
link list, and selecting it goes nowhere.

**Navigation done with a button produces a silent loss.** In the third row, no defect is visible on
screen: the button takes focus, works with the Enter and Space keys, and the page opens. What is lost is
the four capabilities the link brings. The user cannot open the record detail in a new tab, cannot open it
with the middle click, cannot copy its address, and cannot find it in the link list.

#### Measurable Constraints

**2.4.4 Link Purpose (In Context)** requires a link's purpose to be understood from its
own text or the context it sits in; **2.4.9 Link Purpose (Link Only)** requires the same
thing from the text alone and is AAA level. The "Detail" links in the table satisfy the
first thanks to the table's context, but not the second.

**1.4.1 Use of Color** requires links within text not to be distinguished from the
surrounding text by color alone. Underlining satisfies this condition directly. If color
distinction alone is to count, two conditions are required together: the link color has
to carry at least a 3:1 contrast ratio with the surrounding text, and a second visual
distinction has to appear on focus or hover.

**2.5.8 Target Size** excludes links within text through the inline exception; a link in
the middle of a paragraph has a height tied to the line height, and enlarging it would
break the text. Links in the results table, by contrast, are not inline and fall within
the criterion.

**3.2.5 Change on Request** ties opening a new window to the user's request. If a link
opens in a new tab, this behavior has to be declared as part of its name; a small icon
that appears on screen declares nothing if it has no alternative text.

#### Common Mistake

**The addressless link.** Catching it needs no tooling: every `a` element that does not
carry `href`, or whose value is `#`, is a finding. Automated checkers' presence rule
catches this.

**Many links with the same name.** Catching it is a uniqueness check: the link names on
the page are collected, and pairs that share a name but go to different addresses are
listed.

**Appearance determining the decision.** Building a link for the reason "this should
look like a button," or the reverse. Catching it is the decision table: what the
component does is asked, not what it looks like. Visual weight can be given to either
component independently.

#### Summary

- The decision rule is a single sentence: if the user is going to another resource, it
  is a link; if not, a button; a download also counts as accessing a resource.
- A link brings the capabilities of copying the address, opening in a new tab, opening
  with the middle click, and appearing in the link list; these cannot be written in
  code.
- The Enter key works on both; the Space key only runs the button, and on a link it
  scrolls the page.
- A role declaration neither changes nor adds behavior; a link carrying `role="button"`
  produces a component that contradicts itself on screen, in the tree, and in behavior.
- A link's name has to say where it goes; the repeated "Detail" texts in the table do
  not satisfy the 2.4.9 criterion.
- Links within text fall under the inline exception of the 2.5.8 target size criterion;
  links in a table row do not.

#### Next Step

This topic specified controls one at a time; each one's boundary was its own box. In the
results table's card view, though, the boundary blurs: a card carries an image, a title,
an author, a year, and two controls, and the designer wants the card's **entirety** to be
clickable. That request requires putting a second control inside a single link, and the
pattern breaks here. The next lesson specifies the card: how content grouping is
declared, the three solutions to the clickability problem, and what each one does to the
tab stop count.

## Container and Navigation Components

Specifying card, tab, accordion, menu, breadcrumb, pagination, table, and cyclical content patterns by keyboard contract, focus management, and state declaration.

### Cards

Source: https://academia.sh/en/courses/accessible-patterns/cards

That a card is a grouping layout rather than a component; computing the three clickability patterns by tab stop count, accessible name, and nested placement.

The previous topic specified individual controls one at a time, and each control's
boundary was its own box. That boundary blurs in the results list's card view. A card
carries an image, a title, an author, a year, a status badge, and two controls, and the
design decision is that the **entire** card is clickable.

The request is reasonable: when the user clicks anywhere on the card, they should land
on the record detail page. But the card also holds a button, and two controls cannot
nest inside each other. This lesson specifies the card and compares three solutions to
the clickability problem by counting.

#### A Card Is a Layout, Not a Component

What the card solves is presenting **information of different kinds** together as a
single scannable unit: an image, a title, two lines of metadata, and a status. The
measure for the visual grouping is the common region principle from the Fundamentals of
Interface Design course; a surface reads its contents as a single unit.

Where the card is not used follows from this too: when the information is homogeneous
and comparable, the right control is a table, not a card. If forty records need to be
compared by publication year, a card view puts every value at a different vertical
position and makes the comparison impossible.

#### The Native Element First

The card has no element counterpart, because it is not a control. Its markup rests on
two decisions.

The first is that the card list is written as a **list**. The `ul` and `li` elements
report the list role and the item count to the tree; a user entering the list learns how
many records there are. In a grid built from a stack of `div` elements, that count
exists nowhere.

The second is that the card contains a **heading element**. The heading places the
record on the outline and lets a user navigating from the heading list skip through
forty records without reading each one individually. If the card is a unit that can be
distributed on its own, it is wrapped in an `article` element and tied to the heading
with `aria-labelledby`, becoming a named region in the tree.

#### Three Solutions to the Clickability Problem

**A. The whole card is a link.** The image, the title, and the summary are all inside
the link. So is the button inside it.

**B. Only the title is a link, and the surface spreads to match it.** A pseudo-element
covers the whole card with the link; the button is raised above that overlay in the
presentation layer.

**C. Each part is its own link.** The image, the title, and a "Detail" link all go to
the same target.

```js
// card.mjs — tab stop count, accessible name, and nesting in card patterns

const CARD = {
  title: "The Language of Structures",
  author: "T. Ashworth",
  year: "2019",
  status: "On the shelf",
  summary: "An introductory handbook on structural analysis.",
};

// Node: { element, attrs, children } — text nodes stand as plain strings
const interactive = (d) =>
  (d.element === "a" && d.attrs?.href !== undefined) || d.element === "button";

function focusStops(d, path = []) {
  if (typeof d === "string") return [];
  const here = interactive(d) ? [[...path, d.element + (d.attrs?.href ? "[href]" : "")].join(" > ")] : [];
  const below = (d.children ?? []).flatMap((c) => focusStops(c, interactive(d) ? [...path, d.element] : path));
  return [...here, ...below];
}

function text(d) {
  if (typeof d === "string") return d;
  const a = d.attrs ?? {};
  if (a["aria-hidden"]) return "";
  if (d.element === "img") return a.alt ?? "";
  return (d.children ?? []).map(text).join(" ").replace(/\s+/g, " ").trim();
}

// Accessible name of an interactive element: aria-label > content text
function name(d) {
  const a = d.attrs ?? {};
  if (a["aria-label"]) return a["aria-label"];
  return text(d);
}

// Count of nested interactive elements (a button inside a link)
function nested(d, inside = false) {
  if (typeof d === "string") return 0;
  const here = inside && interactive(d) ? 1 : 0;
  return here + (d.children ?? []).reduce(
    (t, c) => t + nested(c, inside || interactive(d)), 0);
}

const body = () => [
  { element: "img", attrs: { alt: "" } },
  { element: "h3", children: [CARD.title] },
  { element: "p", children: [CARD.author + ", " + CARD.year] },
  { element: "p", children: [CARD.summary] },
  { element: "span", children: [CARD.status] },
];

// A: the whole card is one link; a button also sits inside it
const A = {
  element: "article",
  children: [
    { element: "a", attrs: { href: "/record/K-118" },
      children: [...body(), { element: "button", children: ["Borrow"] }] },
  ],
};

// B: only the title is a link; the button is a sibling; the card surface spreads over the link
const B = {
  element: "article",
  children: [
    { element: "img", attrs: { alt: "" } },
    { element: "h3", children: [{ element: "a", attrs: { href: "/record/K-118" }, children: [CARD.title] }] },
    { element: "p", children: [CARD.author + ", " + CARD.year] },
    { element: "p", children: [CARD.summary] },
    { element: "span", children: [CARD.status] },
    { element: "button", children: ["Borrow"] },
  ],
};

// C: the image, the title, and "Detail" are each a separate link
const C = {
  element: "article",
  children: [
    { element: "a", attrs: { href: "/record/K-118" }, children: [{ element: "img", attrs: { alt: "" } }] },
    { element: "h3", children: [{ element: "a", attrs: { href: "/record/K-118" }, children: [CARD.title] }] },
    { element: "p", children: [CARD.author + ", " + CARD.year] },
    { element: "span", children: [CARD.status] },
    { element: "a", attrs: { href: "/record/K-118" }, children: ["Detail"] },
    { element: "button", children: ["Borrow"] },
  ],
};

const PATTERNS = [
  ["A: whole card is a link", A],
  ["B: title link + spreading surface", B],
  ["C: each part a separate link", C],
];

const CARDS_PER_PAGE = 40;

console.log("pattern".padEnd(36) + "stops".padEnd(8) + "in 40 cards".padEnd(13) +
  "nested".padEnd(8) + "first stop's name (chars)");
for (const [title, k] of PATTERNS) {
  const stops = focusStops(k);
  const first = (k.children ?? []).flatMap(function find(d) {
    if (typeof d === "string") return [];
    return interactive(d) ? [d] : (d.children ?? []).flatMap(find);
  })[0];
  const n = name(first);
  console.log(title.padEnd(36) + String(stops.length).padEnd(8) +
    String(stops.length * CARDS_PER_PAGE).padEnd(13) +
    String(nested(k)).padEnd(8) + `"${n.slice(0, 34)}${n.length > 34 ? "…" : ""}" (${n.length})`);
}

console.log("\ntab stops:");
for (const [title, k] of PATTERNS) {
  console.log("  " + title);
  for (const d of focusStops(k)) console.log("    " + d);
}

console.log("\nrule audit:");
let findings = 0;
for (const [title, k] of PATTERNS) {
  const stops = focusStops(k);
  const elements = (k.children ?? []).flatMap(function find(d) {
    if (typeof d === "string") return [];
    return interactive(d) ? [d, ...(d.children ?? []).flatMap(find)] : (d.children ?? []).flatMap(find);
  });
  const names = elements.map(name);
  const b = [];
  if (nested(k) > 0) b.push(`interactive element inside another interactive element (${nested(k)})`);
  for (const n of names) {
    if (n === "") b.push("nameless stop: image with empty alternative text as its only content");
    else if (n.length > 60) b.push(`accessible name is ${n.length} characters: unreadable in a link list`);
  }
  const targets = new Map();
  for (const o of elements) {
    const h = o.attrs?.href;
    if (!h) continue;
    if (!targets.has(h)) targets.set(h, new Set());
    targets.get(h).add(name(o));
  }
  for (const [h, set] of targets) {
    if (set.size > 1) b.push(`${set.size} links to the same target (${h}) with different names`);
  }
  if (stops.length * CARDS_PER_PAGE > 100)
    b.push(`${stops.length * CARDS_PER_PAGE} stops in 40 cards: the list cannot be crossed with the tab key`);
  for (const x of b) { findings++; console.log("  " + title.padEnd(36) + x); }
}
console.log(`\n${PATTERNS.length} patterns, ${findings} findings`);
```

```
pattern                             stops   in 40 cards  nested  first stop's name (chars)
A: whole card is a link             2       80           1       "The Language of Structures T. Ashw…" (113)
B: title link + spreading surface   2       80           0       "The Language of Structures" (26)
C: each part a separate link        4       160          0       "" (0)

tab stops:
  A: whole card is a link
    a[href]
    a > button
  B: title link + spreading surface
    a[href]
    button
  C: each part a separate link
    a[href]
    a[href]
    a[href]
    button

rule audit:
  A: whole card is a link             interactive element inside another interactive element (1)
  A: whole card is a link             accessible name is 113 characters: unreadable in a link list
  C: each part a separate link        nameless stop: image with empty alternative text as its only content
  C: each part a separate link        3 links to the same target (/record/K-118) with different names
  C: each part a separate link        160 stops in 40 cards: the list cannot be crossed with the tab key

3 patterns, 5 findings
```

#### Reading the Findings

**Pattern A breaks two separate rules.** The button inside the link sits in the tree as a second
interactive element inside a first one; this placement is explicitly forbidden by the HTML content model —
a browser still parses it, but how assistive technology treats it is not guaranteed. The second finding is
measurable: the link's name runs to a hundred and thirteen characters. When a user opens the link list,
every row is a paragraph long and the list becomes unscannable.

**Pattern C falls on the count.** Four stops per card, times forty cards, comes to a
hundred and sixty stops. A keyboard user crossing the list needs a hundred and sixty tab
presses. The same pattern produces two more findings: the link made of an image with
empty alternative text ends up nameless, and three links to the same target carry three
different names.

**Pattern B comes out clean on the audit.** Two stops per card, one short link name, no
nested placement. Its cost sits in the presentation layer and has to be written into the
specification: the spreading surface makes the card's text unselectable with the mouse.
The fix is having the overlay capture pointer events only over the empty area.

The tab stop list shows one more detail. In patterns B and C, the button sits at the
card's own level; in pattern A, it sits on the `a > button` path — inside the link. The
path is how nesting is detected without looking at the markup.

#### Keyboard Contract

The card carries **no** keyboard contract of its own. A card does not take focus, does
not listen for keys, and is invisible in the tab order. The contracts that apply are the
ones for the link and the button inside it.

This is a decision of the specification. Writing `tabindex` on the card and attaching a
click listener raises the tab stop count by one per card and produces a focusable shell.
That shell has no role, its name is unclear, and its response to the Enter key has to be
hand-written.

If the card list is long, the tab stop count is lowered by a separate decision: a skip
link is placed before the list, giving the user a way past it. Hiding the card's
secondary controls and showing them only on hover does not lower this count, because a
hidden control becomes unreachable from the keyboard.

#### Measurable Constraints

**2.4.7 Focus Visible.** A presentation decision that clips overflowing content at the
card's edge also clips the focus ring of a control near that edge. The indicator still
exists; it is just invisible. Catching it takes a keyboard pass.

**1.4.11 Non-text Contrast.** The boundary separating a card from the page surface has to
meet the 3:1 measure if it carries information. If cards are separated only by a shadow,
and the shadow is low-contrast, the group boundary cannot be seen.

**1.4.1 Use of Color.** The "On the shelf," "On loan," and "Reserved" status badges
cannot be distinguished by color alone; every badge carries its own text.

**1.4.10 Reflow.** The card grid must not require two-directional scrolling once reduced
to a 320 CSS pixel width. Fixed-width card boxes remove this criterion outright.

**2.5.8 Target Size.** The card's secondary controls — the flag and share buttons — have
to meet the 24-pixel measure or the spacing exception.

#### Common Mistake

**Leaving the card without a heading.** Bold-styled text is not a heading. Catching it
means counting the heading levels in the card list: if there are forty cards, the
outline must hold forty headings.

**Naming the image after the record.** If the card's image carries alternative text
while the title already sits right next to it, the same information is announced twice.
The decision: the cover image inside a card is presentational and its alternative text
is empty.

**Not writing the card list as a list.** Catching it means comparing the tree's item
count against the number of cards on screen; if there is no count, there is no
structure.

#### Summary

- A card is not a control but a grouping layout that presents information of different
  kinds as a single unit; homogeneous, comparable data uses a table instead.
- The card list is written with list elements, and every card carries a heading element;
  the two report the item count and the outline to the tree.
- Making the whole card a link produces a nested interactive element and stretches the
  link's name past a readable length.
- Making each part its own link raises the tab stop count to four per card; in a list of
  forty cards, that comes to a hundred and sixty stops.
- The pattern where only the title is a link and the surface spreads to match it comes
  out clean on all three measures; its cost is text selectability, and that is solved by
  the overlay's event capture area.
- The card carries no keyboard contract of its own; giving the card focus produces a
  roleless, nameless shell.

#### Next Step

The card was the sum of the controls inside it; it carried no behavior on its own. On the
record detail page, that changes: the citation, the available copies, and the borrowing
history are three separate sections, and only one is shown at a time. This is the first
pattern where the container itself carries a behavior — it decides which section is
visible. The next lesson specifies the tab pattern: how tab headings and panels are tied
together, why the roving tabindex is mandatory here, and the decision between automatic
and manual activation of the selection.

### Tabs

Source: https://academia.sh/en/courses/accessible-patterns/tabs

Tying tab headings to panels, the roving tabindex requirement, the decision between automatic and manual activation, and testing the selection invariant.

A card was the sum of the controls inside it, and it carried no behavior on its own. The
record detail page is different: the citation, the available copies, and the borrowing
history are three separate sections, only one is shown at a time, and the container
itself decides which one.

This pattern has two requirements, and neither is met by default. The user has to know
that three headings exist and which one is active, and has to be able to move between
the three with the keyboard. This lesson specifies the tab pattern.

#### The Problem the Pattern Solves

Tabs let **equivalent, mutually exclusive** sections of content share the same space. All
three conditions are required: the sections sit at the same level, only one is meaningful
at a time, and the user switches between them often.

If the conditions are not met, the pattern is wrong. Sections that must be read in order
— the three steps of the borrowing form — are not tabs; the user has to complete the
first step before moving to the second. Sections that must be read at the same time are
not tabs either: if reading the citation requires checking the number of copies
available, the two cannot be placed in separate tabs.

Content length is a measure too. More than five tabs causes the headings to overflow
into a scrolling heading strip, at which point the pattern has turned into a navigation
list and should not be built as tabs.

#### The Native Element First

The tab pattern has **no** native counterpart. It is built from three roles: the tab
strip `role="tablist"`, each heading `role="tab"`, and each section `role="tabpanel"`.

The headings are buttons; the role is written on top of the `button` element. The native
element brings everything except the role: focusability, operability by the Enter and
Space keys, and notifying the tree of the disabled state.

```html
<div role="tablist" aria-label="Record sections">
  <button role="tab" id="tab-citation" aria-selected="true"
          aria-controls="panel-citation" tabindex="0">Citation</button>
  <button role="tab" id="tab-copies" aria-selected="false"
          aria-controls="panel-copies" tabindex="-1">Available copies</button>
  <button role="tab" id="tab-history" aria-selected="false"
          aria-controls="panel-history" tabindex="-1">Borrowing history</button>
</div>

<div role="tabpanel" id="panel-citation" aria-labelledby="tab-citation" tabindex="0">…</div>
<div role="tabpanel" id="panel-copies" aria-labelledby="tab-copies" tabindex="0" hidden>…</div>
<div role="tabpanel" id="panel-history" aria-labelledby="tab-history" tabindex="0" hidden>…</div>
```

The bond is **two-way**: the tab points to the panel with `aria-controls`, the panel
points to the tab with `aria-labelledby`. The second direction is where the name comes
from — the panel's accessible name is the tab's text, and it is not written separately.
The strip's `aria-label` declaration is also required, because a page can hold more than
one tab strip and the reader has to be able to tell them apart.

Panels carrying `tabindex="0"` exists so that a panel with no focusable content inside it can still be
reached by keyboard. If the panel holds a focusable element — a link or a control — this value is
unnecessary; the user is already going to enter it.

#### The Roving Tabindex Is Mandatory Here

Only one heading in the tab strip sits in the tab order; the rest carry a `tabindex`
value of minus one. When the selection changes, these values are updated together.

The reason is numeric. In a three-tab strip, laying out the pattern saves two stops. But
because the pattern's contract reserves the arrow keys for moving between headings, if
`Tab` also advanced within the strip, the two navigation paths would overlap and the user
could not learn what each key does. The roving tabindex leaves the `Tab` key exactly one
meaning: leave the strip.

#### Automatic or Manual?

When an arrow key moves focus, should the selection move with it? Both decisions are
defined.

In **automatic activation**, whichever heading focus lands on, that panel opens. As the
user moves through the three tabs with the arrow keys, they see all three panels; the
key count is low.

In **manual activation**, the arrow key only moves focus; the panel opens with the Enter
or Space key. This costs two key presses.

The deciding measure is whether the panel content is **ready instantly**. If the
borrowing history is fetched from the server, automatic activation makes the user start
three separate requests while moving around; the screen changes on every arrow press, and
for a user who is not watching the screen, the panel content changes while focus is still
in the strip. If the panel content is already in the document, automatic activation is
preferred.

```js
// tab.test.mjs — the tab pattern's keyboard contract and panel relationship
// Run: node --test tab.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";

const TABS = ["Citation", "Available Copies", "Borrowing History"];

// mode: "automatic" | "manual"
const initial = (mode) => ({ mode, selected: 0, focus: { location: "list", i: 0 } });

function key(d, t) {
  const n = TABS.length;
  if (d.focus.location !== "list") {
    // The list's keys do not run while inside the panel
    return t === "Shift+Tab" ? { ...d, focus: { location: "list", i: d.selected } } : d;
  }
  const i = d.focus.i;
  const goTo = (y) => ({
    ...d,
    focus: { location: "list", i: y },
    selected: d.mode === "automatic" ? y : d.selected,
  });
  if (t === "ArrowRight") return goTo((i + 1) % n);
  if (t === "ArrowLeft") return goTo((i + n - 1) % n);
  if (t === "Home") return goTo(0);
  if (t === "End") return goTo(n - 1);
  if (t === "Enter" || t === "Space") return { ...d, selected: i };
  if (t === "Tab") return { ...d, focus: { location: "panel", i: d.selected } };
  return d;
}

const apply = (d, seq) => seq.reduce(key, d);

// The tab stop count the tab list occupies (roving tabindex)
const listStop = () => 1;

// The attributes the markup produces
function attributes(d) {
  return TABS.map((name, i) => ({
    name,
    role: "tab",
    "aria-selected": d.selected === i,
    "aria-controls": "panel-" + i,
    tabindex: d.focus.location === "list" && d.focus.i === i ? 0 : -1,
    panelHidden: d.selected !== i,
  }));
}

test("the tab list holds a single stop via the roving tabindex", () => {
  assert.equal(listStop(), 1);
  const attrs = attributes(initial("manual"));
  assert.equal(attrs.filter((o) => o.tabindex === 0).length, 1);
});

test("in automatic mode the arrow key moves focus and selection together", () => {
  const d = apply(initial("automatic"), ["ArrowRight"]);
  assert.equal(d.focus.i, 1);
  assert.equal(d.selected, 1);
});

test("in manual mode the arrow key only moves focus, Enter selects", () => {
  const a = apply(initial("manual"), ["ArrowRight", "ArrowRight"]);
  assert.equal(a.focus.i, 2);
  assert.equal(a.selected, 0);
  const b = apply(a, ["Enter"]);
  assert.equal(b.selected, 2);
});

test("arrow keys wrap to the start at the ends", () => {
  const right = apply(initial("manual"), ["ArrowLeft"]);
  assert.equal(right.focus.i, 2);
  const left = apply(initial("manual"), ["ArrowRight", "ArrowRight", "ArrowRight"]);
  assert.equal(left.focus.i, 0);
});

test("Home and End go to the first and last tab", () => {
  const d = apply(initial("manual"), ["ArrowRight", "Home"]);
  assert.equal(d.focus.i, 0);
  assert.equal(apply(initial("manual"), ["End"]).focus.i, 2);
});

test("Tab moves from the tab list to the selected panel, arrow keys do not run in the panel", () => {
  const d = apply(initial("manual"), ["ArrowRight", "Enter", "Tab"]);
  assert.deepEqual(d.focus, { location: "panel", i: 1 });
  const e = apply(d, ["ArrowRight", "Home"]);
  assert.deepEqual(e.focus, { location: "panel", i: 1 });
  assert.equal(e.selected, 1);
});

test("in every state exactly one tab is selected and only its panel is visible", () => {
  const KEYS = ["ArrowRight", "ArrowLeft", "Home", "End", "Enter", "Tab", "Shift+Tab"];
  const key2 = (d) => [d.mode, d.selected, d.focus.location, d.focus.i].join("|");
  let count = 0;
  for (const mode of ["automatic", "manual"]) {
    const b = initial(mode);
    const seen = new Map([[key2(b), b]]);
    const queue = [b];
    while (queue.length) {
      const d = queue.shift();
      for (const t of KEYS) {
        const y = key(d, t);
        if (seen.has(key2(y))) continue;
        seen.set(key2(y), y);
        queue.push(y);
      }
    }
    for (const d of seen.values()) {
      const attrs = attributes(d);
      assert.equal(attrs.filter((o) => o["aria-selected"]).length, 1, "more than one tab selected");
      assert.equal(attrs.filter((o) => !o.panelHidden).length, 1, "more than one visible panel");
      const visible = attrs.find((o) => !o.panelHidden);
      const selected = attrs.find((o) => o["aria-selected"]);
      assert.equal(visible.name, selected.name, "visible panel does not belong to the selected tab");
    }
    count += seen.size;
  }
  console.log(`  states scanned: ${count}`);
});
```

```
  states scanned: 18
✔ the tab list holds a single stop via the roving tabindex (0.310542ms)
✔ in automatic mode the arrow key moves focus and selection together (0.072ms)
✔ in manual mode the arrow key only moves focus, Enter selects (0.044625ms)
✔ arrow keys wrap to the start at the ends (0.044833ms)
✔ Home and End go to the first and last tab (0.293208ms)
✔ Tab moves from the tab list to the selected panel, arrow keys do not run in the panel (0.311167ms)
✔ in every state exactly one tab is selected and only its panel is visible (0.386709ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 32.783084
```

Duration values change from run to run.

The final test verifies three invariants across all eighteen states: exactly one tab
selected, exactly one panel visible, and the visible panel belongs to the selected tab.
The third is the condition most often broken; when the selection declaration and the
visibility are updated in separate code paths, the tree can report one tab as selected
while the screen keeps a different panel open.

The sixth test checks that arrow keys do not affect the strip while inside a panel. This
is the single-character-shortcut rule's counterpart in this pattern: the tab contract's
keys are active only while the strip has focus. If a panel holds a table, the arrow keys
belong to that table.

#### Keyboard Contract

| Key | Behavior |
|---|---|
| `Tab` | Brings focus to the strip; the strip is a single stop. Moves to the selected panel while in the strip. |
| `Right arrow` / `Left arrow` | Moves focus to the neighboring tab; wraps at the ends. |
| `Home` / `End` | Moves focus to the first and last tab. |
| `Enter` / `Space` | Selects the tab under focus (in manual activation mode). |

When the strip is drawn vertically, the up and down arrow keys do the same job, and the
strip carries `aria-orientation="vertical"`.

#### Measurable Constraints

**4.1.2 Name, Role, Value.** Every tab's name, role, and whether it is selected must be
readable programmatically; the `aria-selected` declaration is updated every time the
selection changes.

**1.4.1 Use of Color.** The active tab cannot be distinguished by text color alone; an
underline, a fill, or a weight difference is the second channel.

**1.4.11 Non-text Contrast.** The line marking the active tab must be separated from the
neighboring surface by at least 3:1.

**2.4.7 Focus Visible.** The focus indicator must not be clipped at the strip's
overflowing edge; if the strip carries horizontal scrolling, the focused tab must be
brought into the visible area.

**2.5.8 Target Size.** Tab headings meet the 24-pixel measure; in narrow strips the
spacing between headings must stay open enough to satisfy the spacing exception.

**1.4.10 Reflow.** If the strip is left to scroll horizontally on a narrow screen, the
scrolling has to carry an indicator the user can notice; stacking the headings is also a
solution for a strip that does not overflow.

#### Common Mistake

**Leaving a panel unhidden.** When panels that are not selected are hidden only in the
presentation layer — moved off-screen or made transparent — they keep standing in the
tree. The user reads all three panels' content one after another. Catching it: comparing
the number of visible panels against the number of selected tabs.

**Building tabs as links.** A tab is a button if it does not change an address. If it
does — if every section has a shareable address — the pattern is not tabs but a
navigation list, and it is declared with `aria-current`.

**Leaving the strip nameless.** Catching it is an existence rule: every element carrying
`role="tablist"` must have a name.

**Two navigation paths overlapping.** In an implementation where `Tab` advances within
the strip, the arrow keys do the same job. Catching it: counting the `tabindex` values in
the strip — if more than one element carries the value zero, the roving tabindex has not
been set up.

#### Summary

- The tab pattern is used for equivalent, mutually exclusive sections; it is not used for
  sections that must be read in order or at the same time.
- The pattern has no native counterpart; the strip, heading, and panel roles are written,
  and the bond is two-way — the panel's name comes from the tab.
- The roving tabindex is mandatory: it leaves the `Tab` key a single meaning and reserves
  the arrow keys for moving within the strip.
- Automatic activation is preferred when the panel content is ready in the document;
  manual activation is chosen for content fetched from the server.
- Three invariants must hold in every state: exactly one tab selected, exactly one panel
  visible, and the visible panel belongs to the selected tab.
- The tab contract's keys are active only while the strip has focus; a table or list
  inside a panel keeps its own arrow-key behavior.

#### Next Step

The tab pattern carried a constraint: only one section is visible at a time. The
"Editions," "Subject headings," and "Related records" sections at the bottom of the
record detail page do not need that constraint — the user might want to keep all three
open at once, or read the page without opening any of them. This is the unconstrained
solution to the same grouping problem, and it rests on a single state declaration: is the
section expanded right now? The next lesson specifies the accordion and shows why that
one declaration going stale is the most commonly produced defect.

### Accordion

Source: https://academia.sh/en/courses/accessible-patterns/accordion

The pattern for progressive disclosure, comparing the native disclosure element against a hand-built accordion, tracking the expansion state, and the structural rule audit.

The tab pattern carried a constraint: only one section is visible at a time. The
"Editions," "Subject headings," and "Related records" sections at the bottom of the
record detail page do not need that constraint. The user might want to keep all three
open at once, or might read the page and leave without opening any of them.

The unconstrained solution is the accordion, and it rests on a single state declaration:
is the section expanded right now? This lesson specifies that one declaration and shows
why it going stale is the most commonly produced defect.

#### The Problem the Pattern Solves

The accordion keeps **secondary content** from stretching the page. The measure is
secondariness: if most users complete their task without opening a section, that section
can be collapsed.

When the measure is not met, the pattern does harm. A record's shelf position, whether it
can be borrowed, and its due date are required information; folding them forces the user
to make one more key press and hides the fact that the information exists at all.

Two further limits apply. A closed section's content is hidden in the document; content
the user needs to find by searching the page does not belong inside an accordion. Second,
folding two or three short paragraphs gains nothing — the cost of the opening action
outweighs the space it saves.

#### The Native Element First

This pattern **has** a native counterpart: the `details` and `summary` elements. The
`summary` element is the disclosure control, `details` is the container, and whether it
is open is read from the `open` attribute.

What they bring is: the open and close behavior, the control's focusability, operability
by the Enter and Space keys, and reporting the expansion state to the tree. None of it is
written separately. Giving `details` elements a shared name also defines that only one
stays open at a time; if this behavior is needed, it is tested by feature detection and
built by hand when unsupported.

The cost sits in two places: controlling the disclosure marker's appearance from the
presentation layer is limited, and applying a transition to the opening motion is not
easy. On the catalog's record detail page, this cost is one worth paying.

#### The Hand-Built Accordion

If the native element does not meet a requirement, the pattern is built with a button.

```html
<h3>
  <button aria-expanded="true" aria-controls="panel-editions" id="heading-editions">
    Editions
  </button>
</h3>
<div id="panel-editions" role="region" aria-labelledby="heading-editions">…</div>

<h3>
  <button aria-expanded="false" aria-controls="panel-subjects" id="heading-subjects">
    Subject headings
  </button>
</h3>
<div id="panel-subjects" role="region" aria-labelledby="heading-subjects" hidden>…</div>
```

Three decisions are binding. The button sits **inside a heading element**; the heading is
not written inside the button, the button is written inside the heading. This puts the
section name on the outline and lets the user move between sections from the heading
list. The heading level is chosen against the surrounding outline; the accordion itself
does not impose a level.

The `aria-expanded` state is written on the button, not the panel: the thing doing the
expanding is the button. The panel's `role="region"` declaration and its
`aria-labelledby` bond let the section be found in a bookmark list; if the section count
is high, that list gets crowded and the declaration may not be worth applying.

The arrow icon used as a disclosure marker is presentational; the state is reported by
`aria-expanded`, not the icon.

#### Tracking the Expansion State

```js
// accordion.mjs — tracking expansion state and the structural rule audit

const SECTIONS = ["Editions", "Subject headings", "Related records"];

// mode: "multiple" (independent) | "single" (one section at a time)
const initial = (mode) => ({ mode, open: new Set() });

function toggle(d, i) {
  const a = new Set(d.open);
  if (a.has(i)) a.delete(i);
  else if (d.mode === "single") { a.clear(); a.add(i); }
  else a.add(i);
  return { ...d, open: a };
}

// The state the markup produces: aria-expanded on the button, hidden on the panel
const generated = (d) =>
  SECTIONS.map((name, i) => ({ name, "aria-expanded": d.open.has(i), panelVisible: d.open.has(i) }));

function trace(mode, seq) {
  let d = initial(mode);
  console.log(`\n${mode} mode:`);
  console.log("  triggered".padEnd(22) + "aria-expanded".padEnd(24) + "visible panel");
  console.log("  " + "(initial)".padEnd(20) +
    generated(d).map((o) => (o["aria-expanded"] ? "1" : "0")).join(" ").padEnd(24) +
    generated(d).filter((o) => o.panelVisible).length);
  for (const i of seq) {
    d = toggle(d, i);
    const g = generated(d);
    console.log("  " + SECTIONS[i].padEnd(20) +
      g.map((o) => (o["aria-expanded"] ? "1" : "0")).join(" ").padEnd(24) +
      g.filter((o) => o.panelVisible).length);
  }
  return d;
}

trace("multiple", [0, 1, 2, 1]);
const single = trace("single", [0, 1, 2, 2]);

// Invariant: the declared state and the visibility always match; at most one panel in single mode
console.log("\ninvariant scan:");
let states = 0, breaks = 0;
for (const mode of ["multiple", "single"]) {
  const seen = new Set();
  const queue = [initial(mode)];
  const key = (d) => [...d.open].sort().join(",");
  seen.add(key(queue[0]));
  while (queue.length) {
    const d = queue.shift();
    states++;
    const g = generated(d);
    if (g.some((o) => o["aria-expanded"] !== o.panelVisible)) breaks++;
    if (mode === "single" && g.filter((o) => o.panelVisible).length > 1) breaks++;
    for (let i = 0; i < SECTIONS.length; i++) {
      const y = toggle(d, i);
      if (seen.has(key(y))) continue;
      seen.add(key(y));
      queue.push(y);
    }
  }
}
console.log(`  states scanned: ${states}, invariant breaks: ${breaks}`);
console.log(`  states reached in single mode: ${SECTIONS.length + 1}`);

// --- Structural rule audit -------------------------------------------------
// Each record: the element wrapping the heading button, the aria-controls target, panel hiding
const MARKUP = [
  { name: "Editions", wrapper: "h3", controls: "panel-editions",
    expanded: true, panelHidden: false, panels: ["panel-editions"] },
  { name: "Subject headings", wrapper: "div", controls: "panel-subjects",
    expanded: false, panelHidden: true, panels: ["panel-subjects"] },
  { name: "Related records", wrapper: "h3", controls: "panel-related-2",
    expanded: true, panelHidden: true, panels: ["panel-related"] },
];
const HEADING_ELEMENTS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
const EXISTING_IDS = new Set(["panel-editions", "panel-subjects", "panel-related"]);

console.log("\nstructural rule audit:");
let findings = 0;
for (const k of MARKUP) {
  const b = [];
  if (!HEADING_ELEMENTS.has(k.wrapper))
    b.push(`heading button is not inside a heading element (${k.wrapper})`);
  if (!EXISTING_IDS.has(k.controls))
    b.push(`aria-controls points to an id that does not exist (${k.controls})`);
  if (k.expanded === k.panelHidden)
    b.push(`aria-expanded=${k.expanded} but the panel is ${k.panelHidden ? "hidden" : "visible"}`);
  for (const x of b) { findings++; console.log("  " + k.name.padEnd(20) + x); }
}
console.log(`\n${findings} findings across ${MARKUP.length} sections`);
```

```
multiple mode:
  triggered           aria-expanded           visible panel
  (initial)           0 0 0                   0
  Editions            1 0 0                   1
  Subject headings    1 1 0                   2
  Related records     1 1 1                   3
  Subject headings    1 0 1                   2

single mode:
  triggered           aria-expanded           visible panel
  (initial)           0 0 0                   0
  Editions            1 0 0                   1
  Subject headings    0 1 0                   1
  Related records     0 0 1                   1
  Related records     0 0 0                   0

invariant scan:
  states scanned: 12, invariant breaks: 0
  states reached in single mode: 4

structural rule audit:
  Subject headings    heading button is not inside a heading element (div)
  Related records     aria-controls points to an id that does not exist (panel-related-2)
  Related records     aria-expanded=true but the panel is hidden

3 findings across 3 sections
```

#### Reading the Findings

Comparing the two traces shows the **state space** difference between the two modes. In
multiple mode, three sections reach eight states: each section is independently open or
closed. In single mode, the number of states reached is four — three sections, or none.
The scan total, twelve, is the sum of the two.

The third line of the single-mode trace shows the pattern's most commonly overlooked
consequence: when the user opens the second section, the first one **closes on its own**.
On screen, this means the page shifts and the place being read disappears. The closing
produces no announcement; the user only hears what they opened. This is why single mode
is chosen only when the section count is high and the content is long.

The structural audit produces three findings, and none of them is visible on screen.

**Missing heading element.** The second section's disclosure button sits inside a `div`.
On screen there is no difference at all; on the outline, that section does not exist, and
a user navigating from the heading list sees only two of the three sections.

**Broken control reference.** The third section's `aria-controls` value points to an id
that does not exist; the declaration silently falls through.

**The declaration and the visibility diverging.** The same section is declared expanded
but its panel is hidden. This is the pattern's most expensive defect: the user is told
the section is open, finds no content when they open it, and assumes the mistake is
their own.

#### Keyboard Contract

| Key | Behavior |
|---|---|
| `Tab` | Each heading button is its own tab stop. |
| `Enter` / `Space` | Opens or closes the section. |
| Arrow keys | Optional; if built, moves between headings without opening a section. |

This is where the accordion departs from the tab pattern. The tab strip carries a single
selection, so it uses the roving tabindex; the accordion's headings are independent
buttons, and each holds its own stop. If the roving tabindex were applied here, the user
could never reach the second section with the `Tab` key.

When a section opens, focus **stays on the button**. It does not move into the panel: the
user may want to open the next section before reading the one they just opened, and
focus escaping would block that. Entering the panel is done with the `Tab` key.

#### Measurable Constraints

**4.1.2 Name, Role, Value.** The expansion state must be readable programmatically and
updated on every change.

**2.5.8 Target Size.** The whole heading row is the clickable area; an implementation
where only the arrow icon is clickable both drops the target below 24 pixels and fails
the user's expectation of clicking the heading text.

**1.4.11 Non-text Contrast.** The line separating sections carries information and must
meet the 3:1 measure.

**2.4.6 Headings and Labels.** The heading text must describe the section's content;
text like "Detail" or "Other" does not meet this measure.

**2.3.3 Animation from Interactions.** If a transition is applied to the opening motion,
the transition must be removed once reduced motion is signaled; the criterion is AAA
level, but in long lists the motion of sections opening one after another produces
discomfort.

#### Common Mistake

**Writing the state declaration on the panel.** The `aria-expanded` attribute is written
on the control that does the expanding. Written on the panel, the button is left without
state, and the user cannot tell whether the section is open or closed while focused on
the button. Catching it: checking whether every element with a `role` of button carries
the declaration.

**Building the heading button as a link.** Opening a section is an action and does not
change an address; the component is a button.

**Leaving a closed panel in the tree.** A panel hidden only in the presentation layer
stays in the tree; the user reads the content of closed sections and it contradicts the
state declaration. Catching it is the audit's third rule.

**Starting every section open.** Progressive disclosure exists to shorten the page; an
accordion that starts fully open just adds an extra layer of key presses. The decision of
which section is open on page load is written into the specification.

#### Summary

- The accordion folds secondary content; required information, content that must be
  found by searching the page, and very short sections are not folded.
- The pattern has a native counterpart, and it brings the opening behavior,
  focusability, the key contract, and the state declaration together.
- When hand-built, the button is written inside a heading element rather than the heading
  inside the button; this puts the section on the outline.
- The `aria-expanded` declaration is written on the button that expands the panel and
  must match the visibility in every state.
- Every accordion heading is its own tab stop; the roving tabindex belongs only to the
  tab pattern.
- In single mode, opening one section silently closes another and reduces the state space
  from eight to four; this behavior is chosen only for long, multi-section lists.

#### Next Step

In the three patterns so far, opened content stayed inside the page's flow: the panel
took up a place, pushed the content beneath it down, and focus moved on its own. The
"My Account" control in the catalog's top bar behaves differently — the list it opens
sits **above** the page, covers the content beneath it, and closes when the user clicks
outside it. This is the first pattern that requires containing focus and returning it to
a point when it closes. The next lesson specifies menus and dropdown panels: the
distinction between the two and the point at which a focus trap forms.

### Menus and Dropdown Panels

Source: https://academia.sh/en/courses/accessible-patterns/menus-and-dropdown-panels

The distinction between the menu role and the dropdown panel, scanning three setups for focus leaks and closing behavior, and verifying the focus return point and the open-close cycle.

In the tab and accordion patterns, opened content stayed inside the page's flow: it took
up a place, pushed the content beneath it down, and focus moved on its own. The "My
Account" control in the catalog's top bar behaves differently — the list it opens sits
above the page, covers the content beneath it, and closes when the user clicks outside
it.

This is the first pattern where closing has to be specified as a behavior. Closing
happens in more than one way, and where focus goes after each way is a separate
decision. The lesson takes on both patterns together, because although they look alike
on screen, their contracts are separate.

#### Two Patterns, Two Contracts

A **dropdown panel** is ordinary content that a button shows and hides. It can hold a
link, a button, a field, even a whole form. It uses the same declaration as the
accordion — `aria-expanded` — and its only difference is that the content sits above the
page.

A **menu** is an application-like list of commands, and it is built with its own role
set: `role="menu"`, `role="menuitem"`, `role="menuitemcheckbox"`. These roles carry a
contract, and the contract is restrictive: a menu holds only menu items, navigation is
done with the arrow keys, and the whole menu is a single tab stop.

The deciding measure is the content. The list the "My Account" control opens —
"My Loans," "Settings," "Sign Out" — is made of ordinary links and is a navigation list;
its pattern is a **dropdown panel**. The menu role is used for lists whose items are
actions rather than navigation, like the command list the "Export" button opens on the
results table.

The wrong choice produces a silent cost. When the menu role is written on a list of
links, the links are reported to the tree as menu items; reaching them from a link list
becomes impossible, and the "open in new tab" expectation is not met.

#### The Native Element First

Neither has a native counterpart. The opening control is a `button` element; the
dropdown panel writes `aria-expanded` and `aria-controls`, and the menu additionally
declares `aria-haspopup="menu"`.

```html
<button id="account" aria-expanded="false" aria-controls="account-panel">My Account</button>
<div id="account-panel" hidden>
  <ul>
    <li><a href="/account/loans">My Loans</a></li>
    <li><a href="/account/settings">Settings</a></li>
  </ul>
  <form action="/sign-out" method="post"><button type="submit">Sign Out</button></form>
</div>
```

The panel's content is ordinary markup; a list stays a list, a form stays a form. This is
the reason the pattern is preferred over a menu: no structural meaning has to be
reproduced by hand.

#### Closing Paths and the Focus Return

Closing happens four ways: running the opening button again, the `Escape` key, clicking
outside the panel, and selecting an item inside the panel.

Where focus goes depends on the closing path, and the rule is this: **on a closing the
user initiated, focus returns to the opening button.** This holds for closing with
`Escape` and by running the button again. When a link inside the panel is selected, that
is navigation and focus goes to the new page; a return is unnecessary.

Clicking outside is a special case. A user who clicks has already moved focus to where
they clicked; returning focus to the opening button pulls it back from where they
clicked. The rule: on an outside click, focus moves to the opening button only if it has
**settled nowhere else.**

```js
// menu.mjs — focus containment, closing, and the return point in a dropdown panel

const BACKGROUND = ["a#catalog", "button#account", "input#search"];
const CONTENT = ["a#my-loans", "a#settings", "button#sign-out"];
const TRIGGER = "button#account";
const KEYS = ["Tab", "Shift+Tab", "ArrowDown", "ArrowUp", "Escape", "Enter", "ClickOutside"];

// kind: "panel" (dropdown panel) | "menu" (menu role) | "broken"
function machine(kind) {
  const goBackground = (i, dir) => BACKGROUND[(i + dir + BACKGROUND.length) % BACKGROUND.length];
  return (d, t) => {
    if (!d.open) {
      const i = BACKGROUND.indexOf(d.focus);
      if (t === "Tab") return { open: false, focus: goBackground(i, 1) };
      if (t === "Shift+Tab") return { open: false, focus: goBackground(i, -1) };
      if ((t === "Enter" || t === "ArrowDown") && d.focus === TRIGGER)
        return { open: true, focus: CONTENT[0] };
      return d;
    }
    const j = CONTENT.indexOf(d.focus);
    if (kind === "menu") {
      if (t === "ArrowDown") return { open: true, focus: CONTENT[(j + 1) % CONTENT.length] };
      if (t === "ArrowUp")
        return { open: true, focus: CONTENT[(j + CONTENT.length - 1) % CONTENT.length] };
      if (t === "Tab") return { open: false, focus: goBackground(BACKGROUND.indexOf(TRIGGER), 1) };
      if (t === "Shift+Tab") return { open: false, focus: TRIGGER };
      if (t === "Escape") return { open: false, focus: TRIGGER };
      if (t === "ClickOutside") return { open: false, focus: TRIGGER };
      return d;
    }
    if (kind === "panel") {
      if (t === "Tab")
        return j === CONTENT.length - 1
          ? { open: false, focus: goBackground(BACKGROUND.indexOf(TRIGGER), 1) }
          : { open: true, focus: CONTENT[j + 1] };
      if (t === "Shift+Tab")
        return j === 0 ? { open: true, focus: TRIGGER } : { open: true, focus: CONTENT[j - 1] };
      if (t === "Escape") return { open: false, focus: TRIGGER };
      if (t === "ClickOutside") return { open: false, focus: TRIGGER };
      return d;
    }
    // broken: no escape, focus vanishes on outside click, Tab leaks to the background
    if (t === "Tab")
      return j === CONTENT.length - 1
        ? { open: true, focus: BACKGROUND[0] }
        : { open: true, focus: CONTENT[j + 1] };
    if (t === "Shift+Tab") return { open: true, focus: CONTENT[Math.max(j - 1, 0)] };
    if (t === "ClickOutside") return { open: false, focus: "(document root)" };
    return d;
  };
}

const key = (d) => (d.open ? "open" : "closed") + "|" + d.focus;

function audit(kind) {
  const next = machine(kind);
  const start = { open: false, focus: TRIGGER };
  const seen = new Map([[key(start), start]]);
  const queue = [start];
  const closings = [];       // [previousState, key, nextState]
  while (queue.length) {
    const d = queue.shift();
    for (const t of KEYS) {
      const y = next(d, t);
      if (d.open && !y.open) closings.push([d, t, y]);
      if (seen.has(key(y))) continue;
      seen.set(key(y), y);
      queue.push(y);
    }
  }
  const states = [...seen.values()];
  const viaEscape = closings.filter(([, t]) => t === "Escape");
  return {
    states: states.length,
    leak: states.some((d) => d.open && BACKGROUND.includes(d.focus) && d.focus !== TRIGGER),
    escapeExit: viaEscape.length > 0,
    escapeReturn: viaEscape.length > 0 && viaEscape.every(([, , y]) => y.focus === TRIGGER),
    focusLoss: closings.some(([, , y]) => !BACKGROUND.includes(y.focus) && y.focus !== TRIGGER),
    cycle: key(next(next(start, "Enter"), "Escape")) === key(start),
    innerStop: kind === "menu" ? 1 : CONTENT.length,
  };
}

const yn = (b) => (b ? "yes" : "no");
console.log("setup".padEnd(10) + "states".padEnd(9) + "inner stop".padEnd(12) +
  "leaks to background".padEnd(21) + "Escape exit".padEnd(13) + "returns to trigger".padEnd(20) +
  "focus loss".padEnd(12) + "open-close cycle");
for (const kind of ["panel", "menu", "broken"]) {
  const s = audit(kind);
  console.log(kind.padEnd(10) + String(s.states).padStart(6).padEnd(9) +
    String(s.innerStop).padStart(5).padEnd(12) +
    yn(s.leak).padEnd(21) + yn(s.escapeExit).padEnd(13) +
    yn(s.escapeReturn).padEnd(20) + yn(s.focusLoss).padEnd(12) +
    (s.cycle ? "closed" : "OPEN"));
}

// Focus trace for the correct setup
console.log("\nfocus trace in the dropdown panel setup:");
let d = { open: false, focus: TRIGGER };
const next = machine("panel");
console.log("  " + "start".padEnd(12) + "closed".padEnd(8) + "focus: " + d.focus);
for (const t of ["Enter", "Tab", "Tab", "Escape", "Tab"]) {
  d = next(d, t);
  console.log("  " + t.padEnd(12) + (d.open ? "open" : "closed").padEnd(8) + "focus: " + d.focus);
}
```

```
setup     states   inner stop  leaks to background  Escape exit  returns to trigger  focus loss  open-close cycle
panel          8       3       no                   yes          yes                 no          closed
menu           6       1       no                   yes          yes                 no          closed
broken         8       3       yes                  no           no                  yes         OPEN

focus trace in the dropdown panel setup:
  start       closed  focus: button#account
  Enter       open    focus: a#my-loans
  Tab         open    focus: a#settings
  Tab         open    focus: button#sign-out
  Escape      closed  focus: button#account
  Tab         closed  focus: input#search
```

#### Reading the Findings

**The inner stop column separates the two patterns.** In the dropdown panel, the content
sits in the natural tab order and holds three stops; the menu uses the roving tabindex
and the whole thing counts as one stop. This is the measure that names which pattern is
built without looking at the markup.

**The open-close cycle has to be closed.** The last column tests whether a user focused
on the button, who opens the panel and closes it with `Escape`, returns to **exactly the
same state**. In both correct setups the cycle is closed; in the broken setup it is open,
because the `Escape` key does nothing. A closed cycle means the user loses nothing by
opening the panel by accident.

**The broken setup carries three defects at once.** While the panel is open, the `Tab`
key leaks into the background controls: the user ends up focused on elements they cannot
see, hidden beneath the panel. There is no exit with `Escape` — this is the subject of
criterion 2.1.2, though it is not a full trap here, because `Tab` provides an escape.
Clicking outside drops focus to the document root, and the user starts navigating from
the very beginning.

**The menu reaches six states, the panel eight.** The difference comes from the panel's
ability to return to the opening button while open: `Shift+Tab` from the first item exits
to the button and the panel stays open. In the menu, the same key closes the menu. Both
are defined; which one is chosen is written into the specification.

#### Keyboard Contract

Dropdown panel:

| Key | Behavior |
|---|---|
| `Enter` / `Space` (on the button) | Opens the panel; focus moves to the first item. |
| `Down arrow` (on the button) | Opens the panel and moves focus to the first item. |
| `Tab` | Advances through the panel's items; closes the panel after the last item. |
| `Escape` | Closes the panel; focus returns to the opening button. |

Menu:

| Key | Behavior |
|---|---|
| `Enter` / `Space` / `Down arrow` (on the button) | Opens the menu; focus moves to the first item. |
| `Down arrow` / `Up arrow` | Moves between items; wraps at the ends. |
| `Home` / `End` | Goes to the first and last item. |
| `Escape` | Closes the menu; focus returns to the opening button. |
| `Tab` | Closes the menu and moves focus to the next page stop. |

In the menu pattern, the `Tab` key closing the menu is not a limitation but part of the
definition: because the menu is a single stop, there is no tab order inside it to advance
through.

#### Measurable Constraints

**2.1.2 No Keyboard Trap.** While the panel is open, it must be possible to exit with the
keyboard. `Escape` is the shortest path out and is required in both patterns.

**1.4.13 Content on Hover or Focus.** If the panel opens on pointer hover, three conditions are required:
it must be dismissible through some mechanism (`Escape` in most patterns) without moving the pointer or
focus, the pointer must be able to move over the panel, and it must not disappear without the user taking
an action. Menus that open on hover and close when the pointer moves away remove this criterion; a pattern
also has to open with a click, because a hover state never occurs on touch input.

**2.4.7 Focus Visible.** The focus indicator on items inside the panel must also carry
sufficient contrast against the panel's own surface; a single-color ring tuned for the
page surface can disappear on the panel surface.

**1.4.11 Non-text Contrast.** The panel's border must meet 3:1 against the content
beneath it so it does not blend in; a panel separated only by a shadow has no visible
border.

**2.5.8 Target Size.** Panel items meet the 24-pixel measure; in narrow dropdown lists,
the row height determines this measure.

#### Common Mistake

**Writing the menu role onto a link list.** Catching it is a structural check: if an
element carrying `role="menu"` has a child that is not a menu item, the pattern is
built wrong.

**Leaving focus behind on close.** If the panel closes while focus sits on an element
that has been removed, focus drops to the document root. Catching it is the audit's
cycle test: if the open-close sequence does not return to the starting state, focus
management is missing.

**Tying `Escape` only to the panel.** If `Escape` stops working once the user has moved
focus back to the opening button, one of the closing paths is lost. The key is bound to
the shared ancestor of the button and the panel.

**Not hiding the panel from the tree.** A panel hidden only in the presentation layer
stays in the tree, and the user reads the items of a list they believe is closed.

#### Summary

- A dropdown panel shows and hides ordinary content; a menu is for lists whose items are
  commands and carries a restrictive role contract.
- Writing the menu role on a list of links turns the links into menu items in the tree
  and blocks reaching them from a link list.
- The dropdown panel's content sits in the natural tab order; the menu uses the roving
  tabindex and holds a single stop. This measure tells the two patterns apart.
- On every closing the user initiates, focus returns to the opening button; closing by
  navigating from inside the panel needs no return.
- The open-close cycle must be closed: an open-close sequence starting at the button must
  return to exactly the same state.
- While the panel is open, focus must not leak to the background controls, and `Escape`
  must leave an exit in every case.

#### Next Step

The containers covered so far showed and hid content; none of them told the user
**where** they are. In the catalog, position is information: the user is three levels
down a subject branch and on the fourth page of the results list. Two components carry
this information, and both rest on the same declaration — separating the current
location from the rest. The next lesson specifies the breadcrumb and pagination: how
position is declared, and why a page change requires a focus decision.

### Breadcrumb and Pagination

Source: https://academia.sh/en/courses/accessible-patterns/breadcrumbs-and-pagination

Declaring hierarchical and sequential position in markup, the link name carrying its target, keeping the current page as a link, and the focus decision on a page change.

The containers in the previous lesson showed and hid content; none of them told the user
where they are. In the catalog, position is information: the user is three levels down a
subject branch and on the fourth page of the results list. The two components that carry
this information are the breadcrumb and pagination, and both do the same job —
separating the current location from the rest.

Both share the same difficulty. Visually, the current location is marked by a color or
weight difference; that difference tells a screen-reader user nothing. When the
distinction is not written in markup, the component turns into a list of look-alike
links.

#### The Problem the Pattern Solves

The breadcrumb declares position in the **hierarchy**, pagination declares position in
the **sequence**. The breadcrumb is also a path upward: the user returns three levels up
in a single step. Pagination says a set has been split into parts and states which part
is being viewed.

Neither is the only way to navigate. The breadcrumb does not replace primary navigation;
pagination does not replace infinite scroll. Both are an **additional** layer of
information, and that is why both sit in their own navigation region.

#### The Native Element First

Both components are built from native elements: a navigation region, a list inside it,
links inside the list. In the breadcrumb the list is **ordered**, because the order of
the items carries meaning. In pagination the order is meaningful too, but the items sit
at an equal level.

If a page holds more than one navigation region, each needs a name; two unnamed regions
cannot be told apart. The name states what the region is for: "Breadcrumb," "Result
pages." The current location is declared with `aria-current` — the last item in the
breadcrumb, the current page number in pagination.

The computation below builds both components on a small document model and computes
their accessible names.

```js
// position.mjs — breadcrumb and pagination: accessible name, current-location marker, and focus

// A small document model: a node tree and accessible name computation.
const node = (tag, attrs = {}, children = []) => ({ tag, attrs, children });
const text = (d) =>
  d.tag === "#text" ? d.attrs.value : d.children.map(text).join("").replace(/\s+/g, " ").trim();
const txt = (s) => node("#text", { value: s });

// Accessible name priority: aria-label > aria-labelledby > content text.
function accessibleName(d, doc) {
  if (d.attrs["aria-label"]) return d.attrs["aria-label"];
  if (d.attrs["aria-labelledby"]) {
    const target = find(doc, d.attrs["aria-labelledby"]);
    return target ? text(target) : "";
  }
  return text(d);
}
function find(d, id) {
  if (d.attrs.id === id) return d;
  for (const c of d.children) { const s = find(c, id); if (s) return s; }
  return null;
}
const walk = function* (d) { yield d; for (const c of d.children) yield* walk(c); };

// 1. Breadcrumb: navigation region + ordered list + current-page marker.
const breadcrumb = node("nav", { "aria-label": "Breadcrumb" }, [
  node("ol", {}, [
    node("li", {}, [node("a", { href: "/catalog" }, [txt("Catalog")])]),
    node("li", {}, [node("a", { href: "/catalog/science" }, [txt("Science")])]),
    node("li", {}, [node("a", { href: "/catalog/science/astronomy" }, [txt("Astronomy")])]),
    node("li", {}, [node("span", { "aria-current": "page" }, [txt("Northern Slope Observation Records")])]),
  ]),
]);

// 2. Pagination: navigation region + links + current page.
const pagination = node("nav", { "aria-label": "Result pages" }, [
  node("ul", {}, [
    node("li", {}, [node("a", { href: "?page=3", "aria-label": "Previous page" }, [txt("‹")])]),
    node("li", {}, [node("a", { href: "?page=1" }, [txt("1")])]),
    node("li", {}, [node("span", { "aria-hidden": "true" }, [txt("…")])]),
    node("li", {}, [node("a", { href: "?page=3" }, [txt("3")])]),
    node("li", {}, [node("a", { href: "?page=4", "aria-current": "page" }, [txt("4")])]),
    node("li", {}, [node("a", { href: "?page=5" }, [txt("5")])]),
    node("li", {}, [node("a", { href: "?page=5", "aria-label": "Next page" }, [txt("›")])]),
  ]),
]);

function audit(name, root) {
  console.log(`\n--- ${name} ---`);
  console.log(`region name        : ${accessibleName(root, root) || "(none)"}`);
  const current = [...walk(root)].filter((d) => d.attrs["aria-current"] === "page");
  console.log(`current-page marker: ${current.length} element` +
    (current.length === 1 ? ` ("${text(current[0])}")` : ""));
  const links = [...walk(root)].filter((d) => d.tag === "a");
  console.log("link names         :");
  for (const l of links) {
    const n = accessibleName(l, root);
    const warning = n.length <= 2 && !l.attrs["aria-label"] ? "  <- name is a single character, does not state the target" : "";
    console.log(`  ${(l.attrs.href || "").padEnd(24)} name: ${JSON.stringify(n).padEnd(22)}${warning}`);
  }
  const hidden = [...walk(root)].filter((d) => d.attrs["aria-hidden"] === "true").length;
  console.log(`decorative elements hidden from the tree: ${hidden}`);
  return { current: current.length, links: links.length };
}

audit("breadcrumb", breadcrumb);
audit("pagination", pagination);

// 3. Focus on page change: three options and their outcomes.
// Model: a keyboard user presses the link to page 4, the list re-renders.
const OPTIONS = [
  { name: "focus untouched", focus: "the pressed link", loss: false, announcement: "none" },
  { name: "focus to top of list", focus: "results list container", loss: false, announcement: "list heading" },
  { name: "focus to top of page", focus: "document root", loss: true, announcement: "page heading" },
];
console.log("\n--- focus decision on page change ---");
console.log("option                   where focus goes          lost place  announcement");
for (const o of OPTIONS) {
  console.log(
    o.name.padEnd(24) + o.focus.padEnd(26) + (o.loss ? "yes" : "no").padEnd(12) + o.announcement
  );
}

// 4. What happens if the current page is left as a link?
// How many stops are in the tab order, and how many target the same page?
function tabOrder(root) {
  return [...walk(root)].filter((d) => d.tag === "a" && d.attrs.href !== undefined);
}
const s1 = tabOrder(pagination);
const sameTarget = s1.filter((d) => d.attrs["aria-current"] === "page").length;
console.log(`\ntab stops in pagination: ${s1.length}`);
console.log(`stops that also leave the current page as a link: ${sameTarget}`);
console.log(`  -> leaving the current page as a link sends the user to the same page; ` +
  `the marker is preserved, the target does not change.`);

// 5. Breadcrumb length: name length as depth grows, and the truncation rule.
const TRUNCATE = 18;
console.log("\n--- truncating breadcrumb elements ---");
for (const d of [...walk(breadcrumb)].filter((x) => ["a", "span"].includes(x.tag))) {
  const full = text(d);
  const shown = full.length > TRUNCATE ? full.slice(0, TRUNCATE - 1) + "…" : full;
  console.log(
    `  shown: ${JSON.stringify(shown).padEnd(32)} accessible name: ${JSON.stringify(full)}` +
      (shown !== full ? "  <- shown text truncated, accessible name must stay full" : "")
  );
}
```

```
--- breadcrumb ---
region name        : Breadcrumb
current-page marker: 1 element ("Northern Slope Observation Records")
link names         :
  /catalog                 name: "Catalog"             
  /catalog/science         name: "Science"             
  /catalog/science/astronomy name: "Astronomy"           
decorative elements hidden from the tree: 0

--- pagination ---
region name        : Result pages
current-page marker: 1 element ("4")
link names         :
  ?page=3                  name: "Previous page"       
  ?page=1                  name: "1"                     <- name is a single character, does not state the target
  ?page=3                  name: "3"                     <- name is a single character, does not state the target
  ?page=4                  name: "4"                     <- name is a single character, does not state the target
  ?page=5                  name: "5"                     <- name is a single character, does not state the target
  ?page=5                  name: "Next page"           
decorative elements hidden from the tree: 1

--- focus decision on page change ---
option                   where focus goes          lost place  announcement
focus untouched         the pressed link          no          none
focus to top of list    results list container    no          list heading
focus to top of page    document root             yes         page heading

tab stops in pagination: 6
stops that also leave the current page as a link: 1
  -> leaving the current page as a link sends the user to the same page; the marker is preserved, the target does not change.

--- truncating breadcrumb elements ---
  shown: "Catalog"                        accessible name: "Catalog"
  shown: "Science"                        accessible name: "Science"
  shown: "Astronomy"                      accessible name: "Astronomy"
  shown: "Northern Slope Ob…"             accessible name: "Northern Slope Observation Records"  <- shown text truncated, accessible name must stay full
```

#### A Name Has to State the Target

The second block of the output shows pagination's most common defect: the links'
accessible name is a single character. A link named "3" tells a user browsing the link
list nothing. `aria-label` was written for the arrow marks, but not for the number
links, though the problem is the same in both.

The fix is completing the accessible name without changing the shown text: the link's
name becomes not "3" but "Page 3." The visual presentation shows the number, the name
states what the number is. The same rule works in reverse for decorative elements: the
ellipsis in pagination carries no information and is hidden from the tree; the count of
hidden elements in the second block of output confirms this.

#### Should the Current Page Stay a Link

The third and fourth blocks measure a design decision. If the current page is left as a
link, the tab order gains one more stop, and that stop sends the user to the same page.
If it is not, the stop count drops, but the current page's position becomes invisible in
the keyboard order.

The decision can be tied to a measure: the current page **stays a link** and is marked
with `aria-current`. This way, the marker declaration is preserved and no gap opens in
the sequence; the target being the same page is not a loss, because the user is already
there and the marker says so.

#### Where Focus Goes on a Page Change

The table in the third block compares three options. Not touching focus at all is the
least intrusive, but nothing announces that new content has arrived. Moving focus to the
top of the page causes a loss of place: the user has to scroll back down to the
pagination control.

The second option in the table — moving focus to the results list's container — solves both problems: the
container becomes focusable, its heading is read, and the user starts at the beginning of the new content.
Because the pagination control sits right below the list, moving to the next page is still a short path.

#### A Long Breadcrumb Name

The last block shows the rule for visual truncation. A long record name can be shortened
in the breadcrumb; truncation is a **visual** decision and must not affect the accessible
name. When the shown text is truncated, the full name is preserved as the accessible
name; otherwise the user never learns the record's actual name.

#### Keyboard Contract

| Key | Behavior |
|---|---|
| `Tab` | Moves to the next link; both components are link lists, no extra key rule applies |
| `Shift+Tab` | Returns to the previous link |
| `Enter` | Follows the link |

Neither component has an arrow-key rule. Tabs and menus needed the arrow keys because
they were each a single composite control; the breadcrumb and pagination are lists of
independent links, and the expected behavior in a link list is `Tab`.

#### Measurable Constraints

- Pagination links' target size must be at least 24×24 pixels for pointer inputs (mouse,
  pen, touch) (WCAG 2.5.8); the spacing between them is the second way to meet this measure.
- The current page cannot be distinguished by color alone (WCAG 1.4.1): a weight, a
  border, or another channel accompanies the color, and the `aria-current` declaration
  already provides the textual channel.
- The breadcrumb separator (`/`, `>`) is decorative and is hidden from the accessibility
  tree; if the separator becomes part of the name text, the name is polluted.
- Every navigation region must have an accessible name (structural information under
  WCAG 1.3.1).

#### Common Mistake

The most common mistake is marking the current page only visually. The second is
building pagination with buttons and never updating the address bar: the user cannot
share the fourth page, and the back button does not return to the previous page. A page
number is **position information**, and position is carried in the address — this is the
interface-side counterpart of the rule established in the Route Parameters and Query
lesson.

#### Summary

- The breadcrumb declares position in the hierarchy, pagination in the sequence; both sit
  in their own named navigation region and mark the current location with
  `aria-current`.
- The accessible name has to state the target: "Page 3," not "3"; the decorative
  separator and ellipsis are hidden from the tree.
- The current page stays a link and stays marked; taking it out of the sequence makes
  position invisible to a keyboard user.
- On a page change, focus moves to the results list's container: there is no loss of
  place, and the arrival of new content is announced.
- Visual truncation does not shorten the accessible name; the full name is preserved in
  the tree.

#### Next Step

With this lesson, the results list can declare its position, but the list itself is still
a plain sequence of records. When the same data turns into a table in the catalog —
columns, sorting, filters, and hundreds of rows — position information grows to two axes:
the user is in a row **and** a column at once. The next lesson specifies the table: the
relationship between header cells and rows and columns, how sort state is announced, and
which shortcuts dense data requires.

### Tables

Source: https://academia.sh/en/courses/accessible-patterns/tables

Building two-axis position in markup, the row-header and column-header relationship, declaring and announcing sort state, and keeping the tab order under control in dense data.

The previous lesson made the results list able to declare its position. In the catalog,
the same data is often more than a list: shelf code, title, year, and status split into
columns, the columns can be sorted, and the row count runs into the hundreds. This
structure is called a table, and in a table, position is two-axis — the user is in a row
**and** a column at once.

A sighted reader joins the two axes with a glance: they look at a cell, see the column
header going up and the row header going left. A user who cannot see the screen cannot
make that join; which headers a cell is bound to has to be written in **markup**.

#### The Problem the Pattern Solves, and Where It Is Not Used

A table presents two-dimensional relational data: each row is an entity, each column is
one of that entity's attributes. Data belongs inside a table only if this relationship
genuinely holds.

Using a table to build a layout fabricates this relationship: a row-column structure that
does not exist is reported to a user who cannot see the screen, and the content turns
into a meaningless grid. Layout is the job of the grid models in the Layout Systems and
Responsive Design course; the table element is only for data tables.

#### The Native Element First, and the Header Relationship

The table's native counterpart is complete: the table element, a caption, header cells,
and data cells. A header cell states whether it is a column or a row header through its
scope declaration. Once this declaration is written, the cell-to-header relationship is
established on its own; no ARIA is needed.

The computation below builds this relationship on a model and counts what is lost when
the row header is not marked.

```js
// table.mjs — the header-cell relationship, sort state, and navigation in dense data

// Table model: headers, rows, and each cell's value.
const TABLE = {
  caption: "Northern Slope measurement records",
  columns: [
    { key: "code", name: "Shelf code", type: "text", sortable: true },
    { key: "title", name: "Title", type: "text", sortable: true, rowHeader: true },
    { key: "year", name: "Year", type: "number", sortable: true },
    { key: "status", name: "Status", type: "text", sortable: false },
  ],
  rows: [
    { code: "QB-141", title: "Astronomy Handbook", year: 1998, status: "on the shelf" },
    { code: "QB-212", title: "Northern Sky Atlas", year: 2004, status: "on loan" },
    { code: "QC-018", title: "Measurement Methods", year: 2011, status: "on the shelf" },
  ],
};

// 1. Header-cell relationship: which headers each data cell is bound to.
// Rule: the column header comes from the table's top row, the row header comes
// from that row's column marked "rowHeader".
const rowHeaderColumn = TABLE.columns.find((c) => c.rowHeader);
console.log("--- header-cell relationship ---");
console.log("cell                      bound headers");
let unmatched = 0;
for (const row of TABLE.rows) {
  for (const col of TABLE.columns) {
    if (col.rowHeader) continue;
    const headers = [col.name, rowHeaderColumn ? row[rowHeaderColumn.key] : null].filter(Boolean);
    if (headers.length < 2) unmatched++;
    console.log(`  ${String(row[col.key]).padEnd(24)} ${headers.join(" / ")}`);
  }
}
console.log(`cells matching fewer than two headers: ${unmatched}`);

// 2. The same table built without a marked row header.
const withoutRowHeader = { ...TABLE, columns: TABLE.columns.map((c) => ({ ...c, rowHeader: false })) };
const missingMatches = withoutRowHeader.rows.length * (withoutRowHeader.columns.length - 0);
console.log(`\nwithout a marked row header: every cell matches only its column name`);
console.log(`  matches: 1 per cell (row identity is lost)`);
console.log(`  affected cells: ${missingMatches}`);

// 3. Sort state: at most one column can be sorted.
function sortState(columnKey, direction) {
  return TABLE.columns.map((c) => ({
    name: c.name,
    sortable: c.sortable,
    state: !c.sortable ? "-" : c.key === columnKey ? direction : "none",
  }));
}
const state = sortState("year", "ascending");
console.log("\n--- sort state ---");
console.log("column       sortable       aria-sort");
for (const s of state) console.log(`  ${s.name.padEnd(11)} ${(s.sortable ? "yes" : "no").padEnd(14)} ${s.state}`);
const marked = state.filter((s) => s.state !== "none" && s.state !== "-").length;
console.log(`marked columns: ${marked} (rule: at most 1)`);

// 4. The announcement issued after sorting.
function announce(columnName, direction, rowCount) {
  return `sorted by ${columnName}, ${direction}, ${rowCount} rows`;
}
console.log(`\nannouncement: "${announce("Year", "ascending", TABLE.rows.length)}"`);
console.log(`announcement: "${announce("Title", "descending", TABLE.rows.length)}"`);

// 5. The sort itself: the data type determines the comparison.
const compare = (type) =>
  type === "number" ? (a, b) => a - b : (a, b) => String(a).localeCompare(String(b), "en-US");
console.log("\n--- sort result (year, ascending) ---");
for (const s of [...TABLE.rows].sort((a, b) => compare("number")(a.year, b.year))) {
  console.log(`  ${String(s.year).padEnd(6)} ${s.title}`);
}
console.log("--- sort result (title, ascending; locale-aware comparison) ---");
for (const s of [...TABLE.rows].sort((a, b) => compare("text")(a.title, b.title))) {
  console.log(`  ${s.title}`);
}

// 6. Tab stop count in dense data: what happens if every cell is made focusable?
const ROWS = 200, COLUMNS = 4;
const allFocusable = ROWS * COLUMNS;
const actionsOnly = ROWS * 1 + COLUMNS;      // one action per row + column headers
console.log("\n--- tab stop count (200 rows, 4 columns) ---");
console.log(`  every cell focusable      : ${allFocusable}`);
console.log(`  actions + headers only    : ${actionsOnly}`);
console.log(`  ratio: ${(allFocusable / actionsOnly).toFixed(1)}x`);

// 7. Column count consistency: checking for missing cells.
const brokenRow = { code: "QC-100", title: "Incomplete Record" }; // year and status missing
const checked = [...TABLE.rows, brokenRow];
console.log("\n--- column count consistency ---");
for (const s of checked) {
  const missing = TABLE.columns.filter((c) => s[c.key] === undefined).map((c) => c.name);
  console.log(`  ${(s.code || "-").padEnd(8)} missing fields: ${missing.length ? missing.join(", ") : "none"}`);
}
```

```
--- header-cell relationship ---
cell                      bound headers
  QB-141                   Shelf code / Astronomy Handbook
  1998                     Year / Astronomy Handbook
  on the shelf             Status / Astronomy Handbook
  QB-212                   Shelf code / Northern Sky Atlas
  2004                     Year / Northern Sky Atlas
  on loan                  Status / Northern Sky Atlas
  QC-018                   Shelf code / Measurement Methods
  2011                     Year / Measurement Methods
  on the shelf             Status / Measurement Methods
cells matching fewer than two headers: 0

without a marked row header: every cell matches only its column name
  matches: 1 per cell (row identity is lost)
  affected cells: 12

--- sort state ---
column       sortable       aria-sort
  Shelf code  yes            none
  Title       yes            none
  Year        yes            ascending
  Status      no             -
marked columns: 1 (rule: at most 1)

announcement: "sorted by Year, ascending, 3 rows"
announcement: "sorted by Title, descending, 3 rows"

--- sort result (year, ascending) ---
  1998   Astronomy Handbook
  2004   Northern Sky Atlas
  2011   Measurement Methods
--- sort result (title, ascending; locale-aware comparison) ---
  Astronomy Handbook
  Measurement Methods
  Northern Sky Atlas

--- tab stop count (200 rows, 4 columns) ---
  every cell focusable      : 800
  actions + headers only    : 204
  ratio: 3.9x

--- column count consistency ---
  QB-141   missing fields: none
  QB-212   missing fields: none
  QC-018   missing fields: none
  QC-100   missing fields: Year, Status
```

#### The Row Header Carries Identity

The first block of output shows that every data cell binds to two headers: the column
name and the row's identity. The value "1998" alone is a number; "Year / Astronomy
Handbook" turns it into a fact.

The second block counts what happens when the row header is not marked: every one of the
twelve cells matches only its column name, and row identity is lost. A user moving
between cells hears "1998" without learning which record's year it is. In every row, one
cell — usually the one carrying the title or the identifier — is marked as the row
header.

#### Sort State Is a Declaration

The third block audits sort state. The rule has two parts. First, every sortable column
header must declare its sort state; a column that is not sortable carries no such
declaration at all. Second, at most **one** column can be sorted at a time; two columns
marked at once reports a contradictory state to the user.

The sort control itself is a button inside the header cell. The reason for using a button
rather than making the whole header cell clickable was established in the Link and Button
Distinction lesson: an action is a button, a button is reachable from the keyboard, and
it is declared by its role.

#### What Gets Announced After a Sort

The fourth block produces the announcement string. Sorting does not change the page; it
reorders the table's content. That change is visible on screen the instant it happens,
but if it is not announced, it is silent for a user who cannot see the screen.

The announcement carries three pieces of information: which column, which direction, how
many rows. Row count matters because when sorting runs together with a filter, the row
count can change too. The announcement is issued from a polite live region; sorting is
the user's own action and does not need to interrupt.

The fifth block shows the sort itself: the comparison depends on the data type. The
number column sorts numerically; the text column sorts with a locale-aware comparison.
String comparison that is not given a locale falls back to code-point order, which can
break the reader's expected letter order — this is the table-side counterpart of the
rule established in the Internationalization API lesson.

#### Navigating Dense Data

The sixth block puts a number on the problem: in a two-hundred-row table, making every
cell focusable adds eight hundred stops to the tab order. When only the actions and the
column headers are focusable, that number drops to two hundred and four — roughly a
quarter.

Arrow-key navigation between table cells is needed only when the table is built as a
**grid control**: when the cells are editable, or selecting a cell is itself an action.
In a read-only data table, cells are not made focusable; the user moves through the
content in reading mode, and the tab order carries only the actions.

The last block audits column count consistency. A row that leaves a cell out shifts the
header mapping, and values line up with the wrong header while reading. A cell can be
deliberately left blank; a **missing** cell is a defect.

#### Keyboard Contract

| Key | Behavior |
|---|---|
| `Tab` | Moves to the next action: sort button, row action, pagination |
| `Enter` / `Space` | Runs the sort button under focus, reverses the direction |
| Arrow keys | Not a table shortcut in a read-only table; reading mode's own navigation runs |

#### Measurable Constraints

- Every sortable header cell declares its sort state, and at most one is sorted at a
  time.
- Each row has one cell marked as the row header; column headers declare their scope
  (WCAG 1.3.1).
- The sort button's target size cannot be smaller than 24×24 pixels (WCAG 2.5.8); this
  measure is already met once the whole header cell is made clickable.
- Sort direction cannot be declared by an arrow icon alone; the state declaration is a
  textual channel (WCAG 4.1.2).
- Wide tables that need horizontal scrolling must have a scrollable container reachable
  from the keyboard.

#### Common Mistake

The most common mistake is using a table as a layout tool. The second is writing header
cells as a styled data cell: a bold, centered data cell looks like a header visually but
is bound to no cell at all. The third is declaring the sort only with an icon and
skipping the state declaration; in that case which column is sorted never reaches a
keyboard user at all.

#### Summary

- Position in a table is two-axis; which row and column header a cell is bound to is
  written in markup, and the relationship a glance builds cannot be assumed.
- When a row header is not marked, every cell matches only its column name and row
  identity is lost; in the model, all twelve cells are affected.
- A sortable column declares its sort state, at most one column is marked at a time, and
  the post-sort announcement carries the column, the direction, and the row count.
- The comparison depends on the data type; text columns sort with a locale-aware
  comparison.
- In a read-only table, cells are not made focusable: in a two-hundred-row table, this
  decision brings the tab stop count from eight hundred down to two hundred and four.

#### Next Step

In every pattern so far, content stayed in place and the user reached it at their own
pace. The featured-records strip on the catalog's home page does the opposite: content
changes on its own, and the user moves on to the next card before finishing reading. The
next lesson takes on this pattern and puts a number on the cost of automatic transitions:
that the motion has to be stoppable, the relationship between reading time and the
transition interval, and why announcing content that changes on its own is usually the
wrong choice.

### Cyclical Content Navigation

Source: https://academia.sh/en/courses/accessible-patterns/cyclical-content-navigation

How the automatic transition relates to reading time, the unconditional requirement for a stop control, removing hidden cards from the tab order, and why automatically changing content is not announced.

In every pattern so far, content stayed in place and the user reached it at their own
pace. The featured-records strip on the catalog's home page does the opposite: five
cards are shown one at a time in a single area, and the transition happens without any
action from the user.

The pattern is usually justified by saving space — five cards occupy the room of one.
Its cost, though, is measurable: the relationship between the user's reading speed and
the transition interval, whether the motion can be stopped, and what hidden cards do in
the keyboard order. This lesson turns that cost into numbers.

#### The Problem the Pattern Solves, and Where It Is Not Used

Cyclical navigation presents an ordered, equivalent set of content in a bounded space.
The condition for using it has two parts: the cards must be **alternatives** to one
another (missing one must not lose information), and the user must not be required to
see all of them.

If this condition is not met, the pattern is wrong. A sequence of steps, a form flow, or
a warning that must not be missed does not belong in a strip. The comparison in the last
section gives the reason as a number: at any given moment, eighty percent of the strip's
content is not visible.

#### Reading Time versus the Transition Interval

The computation below estimates each card's reading time from its word count and
evaluates transition-interval options against that time.

```js
// cycle.mjs — an auto-advancing content strip: reading time, stopping, and tab order

// The "featured records" strip on the home page: five cards, one text per card.
const CARDS = [
  { title: "Astronomy Handbook", text: "Setting up the rig and keeping a measurement log.", actions: 2 },
  { title: "Northern Sky Atlas", text: "Seasonal maps of northern hemisphere constellations.", actions: 2 },
  { title: "Measurement Methods", text: "Calculating margin of error in temperature and humidity.", actions: 1 },
  { title: "Slope Meteorology", text: "Long-term records of temperature change by elevation.", actions: 2 },
  { title: "History of the Logbook", text: "Old archive formats and reading problems.", actions: 1 },
];

// 1. Reading time: estimated from word count.
// Measure: 200 words per minute (adult, familiar content), about 6 characters per word.
const WORDS_PER_MINUTE = 200;
const readingTime = (s) => {
  const words = s.trim().split(/\s+/).length;
  return (60 * words) / WORDS_PER_MINUTE;
};
console.log("card                      words  reading time (s)");
let longest = 0;
for (const c of CARDS) {
  const t = readingTime(c.title + " " + c.text);
  longest = Math.max(longest, t);
  console.log(`  ${c.title.padEnd(24)} ${String((c.title + " " + c.text).split(/\s+/).length).padStart(4)} ${t.toFixed(1).padStart(14)}`);
}
console.log(`longest card's reading time: ${longest.toFixed(1)} s`);

// 2. Transition-interval decisions and their outcomes.
const INTERVALS = [3, 5, 8, 12];
console.log("\ninterval (s)  is it enough to read the longest card  does WCAG 2.2.2 require it");
for (const a of INTERVALS) {
  console.log(
    `${String(a).padStart(9)} ${(a >= longest ? "yes" : "NO").padStart(37)} ` +
      `${(a > 5 ? "yes (motion over 5 s)" : "yes (auto-starting motion)").padStart(30)}`
  );
}
console.log("rule: whatever the interval, a stop control is mandatory; the interval only");
console.log("      decides whether reading catches up.");

// 3. Tab order: what happens if hidden cards stay in the order?
const totalActions = CARDS.reduce((t, c) => t + c.actions, 0);
const visible = CARDS[0];
console.log("\n--- tab stop count ---");
console.log(`  every card in the order : ${totalActions + 4}  (card actions + prev/next + stop + dot group)`);
console.log(`  only the visible card   : ${visible.actions + 4}`);
console.log(`  difference              : ${totalActions - visible.actions} stops go to content that is not visible`);

// 4. What happens when the transition runs while focus is inside the strip.
const SCENARIOS = [
  { name: "transition keeps running, focus is inside", focusLoss: true, outcome: "focus falls to the document root" },
  { name: "pauses while focus is inside", focusLoss: false, outcome: "user finishes reading" },
  { name: "pauses only on pointer hover", focusLoss: true, outcome: "keyboard user is not protected" },
];
console.log("\n--- auto-advance while focus is inside the strip ---");
console.log("policy                        focus loss  outcome");
for (const s of SCENARIOS) {
  console.log(`  ${s.name.padEnd(30)} ${(s.focusLoss ? "yes" : "no").padEnd(11)} ${s.outcome}`);
}

// 5. Announcement policy: is the automatic transition announced?
function announcementPolicy(automatic) {
  return automatic
    ? { liveRegion: "off", reason: "a change the user did not start is not announced" }
    : { liveRegion: "polite", reason: "content that arrives from the user's own action is announced" };
}
console.log("\n--- announcement policy ---");
for (const automatic of [true, false]) {
  const p = announcementPolicy(automatic);
  console.log(`  transition ${(automatic ? "automatic" : "manual").padEnd(9)} live region: ${p.liveRegion.padEnd(7)} ${p.reason}`);
}

// 6. Position announcement: which card out of how many?
const position = (i, n) => `${i + 1} / ${n}`;
console.log("\nposition announcement examples: " + CARDS.map((_, i) => position(i, CARDS.length)).join("  "));

// 7. Strip versus a grid: what if the same content were shown without scrolling?
console.log("\n--- strip versus grid comparison ---");
console.log(`  cards visible at once in the strip: 1 / ${CARDS.length}`);
console.log(`  cards visible at once in the grid : ${CARDS.length} / ${CARDS.length}`);
console.log(`  content not visible in the strip  : ${(100 * (CARDS.length - 1) / CARDS.length).toFixed(0)}%`);
```

```
card                      words  reading time (s)
  Astronomy Handbook         11            3.3
  Northern Sky Atlas          9            2.7
  Measurement Methods        10            3.0
  Slope Meteorology           9            2.7
  History of the Logbook     10            3.0
longest card's reading time: 3.3 s

interval (s)  is it enough to read the longest card  does WCAG 2.2.2 require it
        3                                    NO     yes (auto-starting motion)
        5                                   yes     yes (auto-starting motion)
        8                                   yes          yes (motion over 5 s)
       12                                   yes          yes (motion over 5 s)
rule: whatever the interval, a stop control is mandatory; the interval only
      decides whether reading catches up.

--- tab stop count ---
  every card in the order : 12  (card actions + prev/next + stop + dot group)
  only the visible card   : 6
  difference              : 6 stops go to content that is not visible

--- auto-advance while focus is inside the strip ---
policy                        focus loss  outcome
  transition keeps running, focus is inside yes         focus falls to the document root
  pauses while focus is inside   no          user finishes reading
  pauses only on pointer hover   yes         keyboard user is not protected

--- announcement policy ---
  transition automatic live region: off     a change the user did not start is not announced
  transition manual    live region: polite  content that arrives from the user's own action is announced

position announcement examples: 1 / 5  2 / 5  3 / 5  4 / 5  5 / 5

--- strip versus grid comparison ---
  cards visible at once in the strip: 1 / 5
  cards visible at once in the grid : 5 / 5
  content not visible in the strip  : 80%
```

The first block of output shows that the longest card reads in a little over three
seconds. This estimate assumes familiar content and fluent reading; for a user listening
through a screen reader, reading with low visual acuity, or unfamiliar with the content,
the time runs many times longer. The estimate is therefore a lower bound, not a target.

The second block evaluates interval options against two measures. A three-second interval
is not enough to read the longest card. Any interval above five seconds puts the motion
into the timed-motion category. What the table actually shows is this: **whatever
interval is chosen, a stop control is mandatory.** The interval only decides whether
reading catches up; the user keeping control of the motion is a separate, unconditional
requirement (WCAG 2.2.2).

#### What Hidden Cards Do in the Tab Order

The third block compares tab stop counts. If all five cards are left in the order, twelve
stops are created, and six of them go to content that is **not visible** at that moment.
A keyboard user crossing the strip focuses on links belonging to cards they cannot see;
the focus indicator sits somewhere off screen.

The rule is clear: a card that is not visible is removed from both the tab order and the
accessibility tree. Access to all the cards is provided through the strip's own
navigation controls — previous, next, and the position dots.

#### A Transition While Focus Is Inside

The fourth block compares three policies. If the transition keeps running, the focused
element disappears; focus falls to the document root and the user loses their place.
This is a violation of the focus-return rule from the Modal Dialogs lesson: if the
element carrying focus is being removed, where focus goes next has to be decided in
advance.

Pausing only on pointer hover does not protect a keyboard user. The correct policy is
that **the transition stops while focus is inside the strip**; once the user finishes
reading and moves focus out, the cycle resumes where it left off.

#### Is Automatically Changing Content Announced

The fifth block separates announcement policy. A change the user did not start is not
announced: announcing the new card on every transition would keep interrupting whatever a
screen-reader user is reading at that moment. The strip stays silent during an automatic
transition.

The situation reverses when the user presses the previous or next button: the source of
the change is the user's own action, and its result has to be reported. The position
announcement in the sixth block is the content of that report — which card, out of how
many, is being shown. The position dots carry the same information and are therefore not
purely decorative.

#### Keyboard Contract

| Key | Behavior |
|---|---|
| `Tab` | Reaches the strip's controls and the actions of **only the visible** card |
| `Enter` / `Space` | Runs the control under focus: previous, next, stop, position dot |
| Arrow keys | Moves between cards within the position-dot group (roving tabindex) |

When the strip itself is built like a set of tabs, the arrow-key rule is the same
contract as the Tabs lesson; if the card content is a link list, there is no arrow-key
rule.

#### Measurable Constraints

- Auto-starting motion that runs longer than five seconds requires a pause, stop, or
  hide control (WCAG 2.2.2).
- When reduced motion is signaled, the automatic transition **does not start**; the rule
  from the User Preferences lesson applies here too.
- Cards that are not visible are absent from both the tab order and the accessibility
  tree.
- Position dots' target size cannot be smaller than 24×24 pixels (WCAG 2.5.8); if a dot
  is visually small, its hit area is enlarged.
- The live region is off during an automatic transition; a transition from a user action
  is announced from a polite region.

#### Common Mistake

The most common mistake is not adding a stop control at all, or tying it only to pointer
hover. The second is treating the position dots as decoration and leaving them unnamed:
if the "1 / 5" information exists on no channel, the user cannot tell how many cards
there are. The third is using the strip for content that must not be missed — a campaign
announcement, a warning, or one-time information placed in a strip drops to a one-in-five
chance of being seen.

#### Summary

- Cyclical navigation is used only for content that is an alternative to itself, whose
  omission causes no loss of information; at any moment, eighty percent of the strip's
  content is not visible.
- The transition interval is chosen against reading time (the longest card in the model
  reads in 3.3 seconds), but whatever the interval, a stop control is unconditionally
  mandatory.
- Cards that are not visible are removed from the tab order: in a five-card strip, this
  decision takes six stops away from content the user cannot see.
- The automatic transition stops while focus is inside the strip; a pause tied only to
  the pointer does not protect a keyboard user.
- The automatic transition is not announced; a transition from a user action is announced
  with a "which one out of how many" position.

#### Next Step

Every component so far stayed inside the page's flow: the card, the tabs, the table, and
the strip each held their own place, and the user went to them. The components ahead rise
**above** that flow and often take over focus: a borrowing confirmation is asked inside a
modal dialog, a result is delivered through a notification banner, a wait is communicated
with an indicator. The next topic takes on this layer and opens with modal dialogs — how
focus is contained, how the window is closed, and where focus returns to when it closes.

## Layer and Feedback Components

The specification of components that rise above the page flow: modal dialogs, tooltips, notifications, loading indicators, skeleton screens, empty and error states, and badges.

### Modal Dialogs

Source: https://academia.sh/en/courses/accessible-patterns/modal-windows

The specification of the modal dialog; the distinction between the native dialog element and a manual ARIA setup, how focus containment differs from a keyboard trap, testing the return point with a state machine, and measurable constraints.

Every component up to this point has stayed inside the page's flow. Buttons, inputs,
cards, tabs, tables: each holds a place in document order, each is reached by the tab key
in its turn, and none blocks the reading of any other. A user can move through the whole
page without stopping at any of them.

The components in this topic rise **above** that flow. They open a temporary layer on
screen, sometimes take over focus, sometimes appear without the user asking for them at
all. Standing above the flow raises two new questions: what happens to the page underneath
while this layer is open, and where does the user return to when the layer closes? The
first lesson takes on the sharpest form of both questions.

#### What It Solves, When Not to Use It

A **modal dialog** is a layer that forces focus onto a single decision by disabling the
content beneath it. It solves exactly one problem: **obtaining confirmation for an action
that cannot be undone**, or collecting information that is required before the flow can
continue. In the catalog interface, borrow confirmation fits this definition — the record
moves to the user's account, a return date is set, and access for other patrons closes.

The situations where a modal dialog should not be used are more numerous, and each belongs
to a separate pattern:

- **Informing.** Opening a window to announce a result imposes an unnecessary dismiss
  action on the user. The notification pattern does this work without taking focus.
- **Long content.** Content that does not fit inside a window belongs on its own page;
  scrolling inside a window conflicts with scrolling outside it.
- **Multi-step work.** Steps need an address; a flow opened inside a modal dialog cannot
  be managed with the back button and cannot be shared.
- **Help text.** A field's description is written next to the field; putting it in a
  window pulls the user away from filling in the field.

The distinction also has a non-modal counterpart. A **non-modal dialog** stays on screen
but does not disable what is behind it; the user can move back and forth between the
window and the page. A filter panel can be non-modal; borrow confirmation cannot.

#### Native Element First, ARIA Second

Markup has an element set aside for a dialog window: `dialog`. Opened as modal, it hands
three jobs to the user agent — disabling the content behind it, placement in the top
layer, and drawing the **backdrop**. A window built with this element carries the
`role="dialog"` role and the modal state implicitly; no ARIA needs to be written on top of
it.

Where the native element cannot be used, the setup is built by hand and requires four
bindings:

| Part | Source |
|---|---|
| Role | `role="dialog"`, `role="alertdialog"` for warnings that defer a decision |
| Modal state | `aria-modal="true"` |
| Name | bound to the window's heading with `aria-labelledby` |
| Description | bound to the summary sentence in the body with `aria-describedby` |

The source of the name is a design decision. A heading of "Confirm" is not a name; the
window's name must state what it does: "Confirm borrowing". The rule from the Screen
Reader Experience lesson applies here too — the role name is not written into the name,
so the heading does not become "Confirm borrowing dialog window".

The `aria-modal` attribute alone is not enough. This attribute tells the accessibility
tree that "what is behind is closed", but it does not determine where the tab key goes and
it does not stop a mouse click on a button behind the window. The content behind is also
made **inert**: it is made unable to receive focus and is removed from the accessibility
tree. The point where a two-source setup collapses into one source is opening the native
element as modal.

#### Keyboard Contract

| Key | Behavior |
|---|---|
| Tab | Moves focus to the next element inside the window; wraps from the last to the first |
| Shift+Tab | Moves to the previous element; wraps from the first to the last |
| Escape | Closes the window and sends focus to the return point |
| Enter | Runs the primary action; while a text field has focus, the field's own rule applies |

Where focus lands on opening depends on the content. If the window has a single primary
action, focus is placed on the window itself or its heading, so the user can read the
content from the start. Placing focus directly on the confirm button skips past the text
that is being confirmed. If the window contains a form, focus goes to the first field.
Placing focus on a destructive action — "Delete", "Cancel" — produces an accident when a
user trying to close the window with Escape instead presses Enter.

Where focus goes on closing is the most critical field in the component's specification.
The **return point** is the element that opened the window, and it is recorded when the
window opens. On closing, focus returns there; if it does not, focus falls to the start of
the document and the user loses their place in the list.

#### Testing Containment and the Return Point

This behavior is testable, because it is entirely a sequential state machine: the page's
focus order, the window's focus order, a stack, and a return point. The test suite below
verifies the pattern's contract item by item.

```js
// modal.test.mjs — the modal dialog's focus containment and return point state machine
import { test } from "node:test";
import assert from "node:assert/strict";

// The page's focusable elements, in document order.
const PAGE = ["search", "filter", "row-1-borrow", "row-2-borrow", "footer-help"];

// Modal dialog definitions: id -> focusable elements inside it
const WINDOWS = {
  "borrow-confirm": ["confirm-heading", "confirm-duration", "confirm-cancel", "confirm-borrow"],
  "late-warning": ["warning-ok"],
};

function machine(page = PAGE) {
  return {
    focus: null,
    stack: [],              // { window, returnTo } — nested open windows
    remaining: [...page],   // page elements that can be removed
    log: [],                // return log
  };
}

const topWindow = (d) => (d.stack.length ? d.stack[d.stack.length - 1] : null);

// Scope of focus cycling: only that window's elements while a window is open.
function scope(d) {
  const top = topWindow(d);
  return top ? WINDOWS[top.window] : d.remaining;
}

function focusOn(d, id) {
  assert.ok(scope(d).includes(id), `${id} outside scope`);
  d.focus = id;
}

function tab(d, back = false) {
  const s = scope(d);
  const i = s.indexOf(d.focus);
  // If arriving from a focus outside the scope, start at the beginning.
  const next = i < 0 ? 0 : (i + (back ? -1 : 1) + s.length) % s.length;
  d.focus = s[next];
  return d.focus;
}

function openWindow(d, window) {
  d.stack.push({ window, returnTo: d.focus });
  d.focus = WINDOWS[window][0];
  d.log.push(`opened: ${window}, return point: ${d.stack[d.stack.length - 1].returnTo}`);
}

// If the return point is gone: the nearest preceding element still on the page, else the first element.
function returnTarget(d, wanted) {
  if (wanted && d.remaining.includes(wanted)) return wanted;
  const original = PAGE.indexOf(wanted);
  for (let i = original - 1; i >= 0; i--) if (d.remaining.includes(PAGE[i])) return PAGE[i];
  return d.remaining[0] ?? null;
}

function closeWindow(d) {
  const top = d.stack.pop();
  assert.ok(top, "no open window");
  if (d.stack.length) { d.focus = top.returnTo; }   // inner window to outer window
  else d.focus = returnTarget(d, top.returnTo);
  d.log.push(`closed: ${top.window}, focus: ${d.focus}`);
  return d.focus;
}

const escape = (d) => (d.stack.length ? closeWindow(d) : d.focus);

test("opening the modal dialog moves focus inside and records the return point", () => {
  const d = machine();
  focusOn(d, "row-2-borrow");
  openWindow(d, "borrow-confirm");
  assert.equal(d.focus, "confirm-heading");
  assert.equal(topWindow(d).returnTo, "row-2-borrow");
});

test("tab wraps from the window's last element to its first", () => {
  const d = machine();
  focusOn(d, "row-2-borrow");
  openWindow(d, "borrow-confirm");
  const path = [d.focus];
  for (let i = 0; i < 4; i++) path.push(tab(d));
  assert.deepEqual(path, ["confirm-heading", "confirm-duration", "confirm-cancel", "confirm-borrow", "confirm-heading"]);
});

test("shift+tab wraps from the first element to the last", () => {
  const d = machine();
  focusOn(d, "row-2-borrow");
  openWindow(d, "borrow-confirm");
  assert.equal(tab(d, true), "confirm-borrow");
});

test("page elements do not enter the tab cycle while the window is open", () => {
  const d = machine();
  focusOn(d, "search");
  openWindow(d, "borrow-confirm");
  const seen = new Set();
  for (let i = 0; i < 12; i++) seen.add(tab(d));
  assert.deepEqual([...seen].sort(), ["confirm-borrow", "confirm-cancel", "confirm-duration", "confirm-heading"]);
  assert.equal([...seen].some((x) => PAGE.includes(x)), false);
});

test("escape closes the window and returns focus to the triggering element", () => {
  const d = machine();
  focusOn(d, "row-2-borrow");
  openWindow(d, "borrow-confirm");
  tab(d); tab(d);
  assert.equal(escape(d), "row-2-borrow");
  assert.equal(d.stack.length, 0);
});

test("nested windows resolve in stack order", () => {
  const d = machine();
  focusOn(d, "row-1-borrow");
  openWindow(d, "borrow-confirm");
  focusOn(d, "confirm-borrow");
  openWindow(d, "late-warning");
  assert.equal(d.focus, "warning-ok");
  assert.equal(escape(d), "confirm-borrow");     // returns to outer window, not the page
  assert.equal(escape(d), "row-1-borrow");
});

test("if the return point was removed, focus does not vanish and falls to the previous element", () => {
  const d = machine();
  focusOn(d, "row-2-borrow");
  openWindow(d, "borrow-confirm");
  d.remaining = d.remaining.filter((x) => x !== "row-2-borrow");   // row left the list
  assert.equal(closeWindow(d), "row-1-borrow");
  assert.notEqual(d.focus, null);
});

test("the return log preserves the order of opening and closing", () => {
  const d = machine();
  focusOn(d, "filter");
  openWindow(d, "borrow-confirm");
  openWindow(d, "late-warning");
  closeWindow(d);
  closeWindow(d);
  assert.deepEqual(d.log, [
    "opened: borrow-confirm, return point: filter",
    "opened: late-warning, return point: confirm-heading",
    "closed: late-warning, focus: confirm-heading",
    "closed: borrow-confirm, focus: filter",
  ]);
});
```

```
✔ opening the modal dialog moves focus inside and records the return point (0.368291ms)
✔ tab wraps from the window's last element to its first (0.290208ms)
✔ shift+tab wraps from the first element to the last (0.046375ms)
✔ page elements do not enter the tab cycle while the window is open (0.07125ms)
✔ escape closes the window and returns focus to the triggering element (0.072875ms)
✔ nested windows resolve in stack order (0.044875ms)
✔ if the return point was removed, focus does not vanish and falls to the previous element (0.065125ms)
✔ the return log preserves the order of opening and closing (0.047292ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 35.956291
```

The duration fields vary on every run; what matters is the counters.

The seventh test catches the most often skipped aspect of this pattern. Once borrowing is confirmed, the
row can drop out of the "available to borrow" list — meaning the return point no longer exists. If focus
is not placed anywhere in this case, it falls to the start of the document and the keyboard user loses
their place in the list. The specification also has to state where focus falls when the return point is
gone: the nearest preceding element, or the start of the list if none remains.

The sixth test verifies the stack behavior of nested windows. Opening a second window from
inside a modal dialog is a setup that should be avoided, but when it happens, Escape must
close **only the topmost** window and leave focus on the one beneath. Code that closes
both windows at once also closes the user's unfinished work.

#### Measurable Constraints

The pattern's conformance is tested against four criteria, and each has a concrete
threshold.

**2.1.2 No Keyboard Trap.** The difference between focus containment and a keyboard trap
was defined in the Keyboard Access lesson and rests on a single condition: if containment
can be exited with a **standard key**, it is containment; if it cannot, it is a trap. A
modal dialog that does not close with Escape fails this criterion even if tab cycling
works correctly.

**2.4.3 Focus Order.** Focus entering the window when it opens and returning to the
return point when it closes is what this criterion requires. The first and fifth items in
the test suite correspond directly to this criterion.

**2.4.11 Focus Not Obscured (Minimum).** A focused element cannot be entirely covered by a
layer the author added. In a modal dialog this criterion can be violated in two
directions: the window itself can cover a focused element on the page, or a sticky footer
bar inside the window can cover the action button beneath it. The criterion rests on a
"not visible at all" threshold; partial obscuring is addressed separately at the AAA
level.

**2.5.8 Target Size (Minimum).** The close button is the modal dialog's smallest target
and must cover an area of at least 24 x 24 pixels. Even if the icon itself is drawn at 16
pixels, the hit area is expanded to this threshold; the target size decision from the
Screen Size and Input Type lesson becomes a conformance requirement here.

A fifth constraint is not tied to a criterion number but is still measurable: there must
be **more than one** way to close the window. Escape, a close button, and — in
non-destructive windows — clicking the backdrop. Closing by clicking the backdrop produces
accidental closes in windows where data is entered, so it is disabled in windows that
contain a form.

#### Common Mistakes and How to Recognize Them

**Background scrolling continues.** If wheel movement scrolls the list behind the window
while it is open, disabling is incomplete. How to recognize it: trying to scroll the page
while the window is open is enough.

**Containment is wired only to the tab key.** Tab cycling is kept inside the window, but
focus enters the page behind it when the user leaves for the browser's address bar and
returns. How to recognize it: seeing in the code that containment is bound only to a key
event and not a focus event, or counting the unhidden links in the document while the
window is open.

**The return point was never recorded.** If focus falls to the start of the page when the
user closes the borrowing window, the return point was never held. How to recognize it:
pressing the tab key once after opening and closing the window — if focus continues from
the page's first element instead of the middle of the list, the defect is there.

**The heading is not bound to the name.** If the window has a visual heading but no
`aria-labelledby` binding, it opens without a name. How to recognize it: the window's name
field is empty in the accessibility tree; this is a defect automated checkers catch with
an existence rule.

#### Summary

- A modal dialog is used only when an irreversible confirmation or information required
  for the flow to continue is needed; informing, long content, multi-step work, and help
  text belong to other patterns.
- The native dialog element, opened as modal, hands the role, the modal state, disabling,
  and the backdrop to the user agent; in a manual setup, the role, modal state, name, and
  description are bound separately.
- Focus containment is containment as long as it can be exited with a standard key; when
  it cannot, it turns into a keyboard trap and violates the 2.1.2 criterion.
- The return point is recorded when the window opens; where focus falls when the return
  point is gone is also part of the specification.
- In nested windows, Escape closes only the topmost window and leaves focus on the one
  beneath.
- The pattern's conformance is tested against the 2.1.2, 2.4.3, 2.4.11, and 2.5.8
  criteria; the close button is expanded to a hit area of at least 24 x 24 pixels.

#### Next Step

The modal dialog took over focus deliberately, and the user opened it deliberately. The
next layer is the opposite: it appears on its own when the user hovers over or focuses an
element, does not take focus, and disappears quickly. This spontaneity makes it the most
often misbuilt component — a setup that works with the mouse never reaches keyboard or
touch input at all, and the text inside a layer that disappears goes unread. The next
lesson ties the conditions under which a tooltip should appear, how long it should stay,
and how it can be dismissed to a criterion.

### Tooltips

Source: https://academia.sh/en/courses/accessible-patterns/tooltips

The specification of the tooltip; the distinction between the tooltip and the toggletip, a rule check of the 1.4.13 criterion's three conditions, computing whether the pointer can reach the tooltip by geometry, and the solution for touch access.

The modal dialog took over focus deliberately; the user opened it by pressing a button and
closed it with Escape. The tooltip is the opposite kind of component: it appears without
the user asking for it, does not take focus, lasts briefly, and in most setups works only
with a pointer.

This spontaneity makes it the most often misbuilt component. A setup that works with the
mouse never opens with the keyboard at all; on a touchscreen there is no such thing as
hovering, so the tooltip never appears; and text inside a layer that vanishes on its own
goes unread. This lesson ties the tooltip to three conditions and computes one of those
conditions by geometry.

#### What It Solves, When Not to Use It

A **tooltip** is a layer that adds a **supplementary description** to a control that
already has a name. The icon button next to the "return date" heading in the borrowing
row of the catalog interface is an example: the heading is understandable on its own, and
the tooltip adds how the date is calculated.

Two rules follow from this definition. First, **the information in a tooltip is never
required**; the task must be completable without it. Second, **a tooltip cannot be a
control's name**; a button whose name exists only in its tooltip is nameless whenever the
tooltip is not visible.

Situations where it should not be used:

- **Interactive content.** A layer containing a link, button, or input is not a tooltip;
  focus needs to be able to reach that layer, and the moment it does, the tooltip's
  contract breaks.
- **Long text.** A description of several sentences belongs to a persistent help text
  standing next to the field; it cannot be read inside a layer that can disappear.
- **Error message.** An error must be persistent and associated with the field; an error
  message that appears on hover disappears before it is seen.

The counterpart pattern is the **toggletip**: it opens with a real button, works by
clicking, is announced because its content is written into a live region, and stays until
the user closes it. The distinction is in the trigger — a layer opened by hover and focus
is a tooltip, a layer opened by clicking is a toggletip. Because there is no hovering on a
touchscreen, the toggletip is the correct pattern everywhere the information is also
required by touch.

#### Native Element First, ARIA Second

Markup has an attribute set aside for this purpose: `title`. By definition it carries
**advisory** information, and how it is presented is left to the user agent. Because
presentation is not left to the author, exactly the things the criterion demands of the
author — how long it stays visible, whether it can be dismissed, whether it can be
hovered onto — become impossible to control. For this reason the tooltip is one of the
rare patterns where the native counterpart does not satisfy the specification, and it is
built by hand.

The manual setup requires four bindings:

| Part | Source |
|---|---|
| The layer's role | `role="tooltip"` |
| Binding to the control | `aria-describedby` on the trigger, pointing to the tooltip box's id |
| The control's name | independent of the tooltip; given on the icon button with `aria-label` |
| Visibility | absent from the accessibility tree while the layer is hidden |

The distinction between name and description is critical here. The tooltip of a button
carrying only an icon most often shows the same text as the button's name. In this case
the text is a name, not a description, and binding it with `aria-describedby` produces a
second copy that is not added to the name; the correct setup binds the text to the name
with `aria-labelledby` and gives no separate description.

Whether the visible text is contained in the name is bound to a separate criterion:
**2.5.3 Label in Name**. Someone using voice control speaks the word they read on screen;
if the name does not contain that word, the control cannot be invoked. A button whose
tooltip reads "Extension terms" while its name is "Help" fails this criterion.

#### Keyboard and Touch Contract

| Input | Behavior |
|---|---|
| Focus | The tooltip appears when the trigger gains focus, and hides when focus leaves |
| Pointer | Appears on hover; the pointer can move onto the tooltip itself |
| Escape | Hides the tooltip; focus stays on the trigger |
| Touch | The tooltip does not appear; the toggletip pattern is used if the information is required |

The tooltip is not reached with the tab key and focus is never placed inside it. The focus
order stops at the trigger; the tooltip only becomes visible.

The delay runs in two directions. The opening delay keeps the pointer from opening a
series of tooltips one after another while it passes over a row of elements. The closing
delay keeps the tooltip from vanishing while the pointer sits in the gap between the
trigger and the tooltip on its way from one to the other. The second delay is not a
preference; it is what the criterion requires.

#### Checking the Three Conditions and the Geometry

The **1.4.13 Content on Hover or Focus** criterion sets three conditions: the content must
be **dismissible** (able to be hidden without moving the pointer or focus), must be
**hoverable** (the pointer must be able to move onto the content without it disappearing),
and must be **persistent** (it must stay until the hover, focus, or dismissal ends). The
three are checked separately; if one is missing, the criterion is not met.

The hoverable condition depends on geometry: if the pointer falls into a "not hovering" state in the gap
while moving from the trigger to the tooltip, the tooltip disappears. The script below first checks the
three conditions as rules, then scans two paths the pointer might follow and measures how many pixels of
each fall outside the layers.

```js
// tooltip.mjs — the tooltip's three conditions and whether the pointer can reach it

// --- (a) rule check of 1.4.13's three conditions -----------------------------
// dismissible: the escape key hides the content
// persistent: no automatic hiding (autoHideMs === null)
// hoverable: content does not hide while the pointer moves from the trigger to the tooltip
const STRUCTURES = [
  { name: "hover only", escapeCloses: false, autoHideMs: 2000, transitArea: false },
  { name: "focus + hover", escapeCloses: false, autoHideMs: null, transitArea: false },
  { name: "closes with escape", escapeCloses: true, autoHideMs: 5000, transitArea: true },
  { name: "full specification", escapeCloses: true, autoHideMs: null, transitArea: true },
];

console.log("structure            dismissible  persistent  hoverable  1.4.13");
for (const y of STRUCTURES) {
  const dismissible = y.escapeCloses;
  const persistent = y.autoHideMs === null;
  const hoverable = y.transitArea;
  console.log(
    `${y.name.padEnd(19)} ${(dismissible ? "yes" : "no").padStart(13)} ` +
      `${(persistent ? "yes" : "no").padStart(9)} ${(hoverable ? "yes" : "no").padStart(11)}  ` +
      `${dismissible && persistent && hoverable ? "passes" : "FAILS"}`,
  );
}

// --- (b) Whether the pointer can reach the tooltip ---------------------------
// Trigger: a 24 x 24 pixel icon button (the "what does this mean" button at the end of the row).
// Tooltip box: 180 x 36.
const TRIGGER = { x: 620, y: 300, w: 24, h: 24 };
const TOOLTIP = { w: 180, h: 36 };

const inside = (n, k) => n.x >= k.x && n.x <= k.x + k.w && n.y >= k.y && n.y <= k.y + k.h;
const overlap = (a, b) => Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));

// Scan a broken path in 0.5 px steps; return the length that falls outside the union of the boxes.
function brokenLength(points, boxes) {
  let broken = 0;
  for (let p = 0; p < points.length - 1; p++) {
    const [a, b] = [points[p], points[p + 1]];
    const length = Math.hypot(b.x - a.x, b.y - a.y);
    if (length === 0) continue;
    const steps = Math.ceil(length / 0.5);
    let outside = 0;
    for (let i = 0; i <= steps; i++) {
      const n = { x: a.x + ((b.x - a.x) * i) / steps, y: a.y + ((b.y - a.y) * i) / steps };
      if (!boxes.some((k) => inside(n, k))) outside++;
    }
    broken += (outside * length) / steps;
  }
  return broken;
}

function placement({ side, align, gap, bridge }) {
  const y = side === "bottom" ? TRIGGER.y + TRIGGER.h + gap : TRIGGER.y - gap - TOOLTIP.h;
  const x =
    align === "start" ? TRIGGER.x
    : align === "center" ? TRIGGER.x + TRIGGER.w / 2 - TOOLTIP.w / 2
    : TRIGGER.x + TRIGGER.w - TOOLTIP.w;
  const tooltip = { x, y, w: TOOLTIP.w, h: TOOLTIP.h };
  const boxes = [{ ...TRIGGER }];
  const shared = overlap(TRIGGER, tooltip);
  if (bridge && gap > 0 && shared > 0) {
    boxes.push({
      x: Math.max(TRIGGER.x, tooltip.x),
      y: side === "bottom" ? TRIGGER.y + TRIGGER.h : TRIGGER.y - gap,
      w: shared,
      h: gap,
    });
  }
  boxes.push(tooltip);
  return { tooltip, boxes, shared };
}

const center = (k) => ({ x: k.x + k.w / 2, y: k.y + k.h / 2 });

const SCENARIOS = [
  { name: "bottom / centered / 0 px",               side: "bottom", align: "center", gap: 0,  bridge: false },
  { name: "bottom / centered / 12 px",              side: "bottom", align: "center", gap: 12, bridge: false },
  { name: "bottom / centered / 12 px + bridge",     side: "bottom", align: "center", gap: 12, bridge: true  },
  { name: "bottom / start-aligned / 8 px + bridge", side: "bottom", align: "start",  gap: 8,  bridge: true },
  { name: "top / end-aligned / 8 px + bridge",      side: "top",    align: "end",    gap: 8,  bridge: true },
  { name: "bottom / shifted / 8 px + bridge",       side: "bottom", align: "shifted", gap: 8, bridge: true },
];

console.log("\nplacement                               horizontal overlap  elbow path  direct path  hoverable");
for (const s of SCENARIOS) {
  // "shifted": tooltip has overflowed to the right of the trigger (a viewport-edge correction bug)
  const adjusted = s.align === "shifted" ? { ...s, align: "start" } : s;
  let { tooltip, boxes, shared } = placement(adjusted);
  if (s.align === "shifted") {
    tooltip = { ...tooltip, x: TRIGGER.x + TRIGGER.w + 4 };
    shared = overlap(TRIGGER, tooltip);
    boxes = [{ ...TRIGGER }, tooltip];
  }
  const m = center(tooltip);
  const b = center(TRIGGER);
  const elbow = brokenLength([b, { x: b.x, y: m.y }, m], boxes);
  const direct = brokenLength([b, m], boxes);
  console.log(
    `${s.name.padEnd(39)} ${(shared.toFixed(0) + " px").padStart(17)} ` +
      `${(elbow.toFixed(1) + " px").padStart(11)} ${(direct.toFixed(1) + " px").padStart(13)}  ` +
      `${elbow === 0 ? "yes" : "NO"}`,
  );
}

// --- (c) Trigger target size --------------------------------------------------
console.log(`\ntrigger target: ${TRIGGER.w} x ${TRIGGER.h} px, 2.5.8 threshold 24 x 24: ` +
  `${TRIGGER.w >= 24 && TRIGGER.h >= 24 ? "passes" : "fails"}`);
```

```
structure            dismissible  persistent  hoverable  1.4.13
hover only                     no        no          no  FAILS
focus + hover                  no       yes          no  FAILS
closes with escape            yes        no         yes  FAILS
full specification            yes       yes         yes  passes

placement                               horizontal overlap  elbow path  direct path  hoverable
bottom / centered / 0 px                            24 px      0.0 px        0.0 px  yes
bottom / centered / 12 px                           24 px     11.5 px       11.5 px  NO
bottom / centered / 12 px + bridge                  24 px      0.0 px        0.0 px  yes
bottom / start-aligned / 8 px + bridge              24 px      0.0 px       32.4 px  yes
top / end-aligned / 8 px + bridge                   24 px      0.0 px       32.4 px  yes
bottom / shifted / 8 px + bridge                     0 px     42.0 px       46.3 px  NO

trigger target: 24 x 24 px, 2.5.8 threshold 24 x 24: passes
```

The first table shows that the conditions are independent. A tooltip that closes with
Escape but hides itself after five seconds is not **persistent**, and the criterion is
still not met. For a slow reader, five seconds is not a guarantee; it is a limit.

The second table gives the hoverable condition in pixels. Because the path scan is done in 0.5-pixel
steps, results round down by about half a step; a 12-pixel gap shows up as 11.5 pixels of breakage.

Three results follow. **Leaving a gap is a sufficient defect on its own:** a twelve-pixel
visual gap means the pointer falls into a "not hovering" state for twelve pixels. **An
invisible transit area closes the gap:** an area as wide as the horizontal overlap of the
two boxes, filling the gap, makes the elbow path unbroken. **When the horizontal overlap
drops to zero, no area helps:** in the last row the tooltip did not fit the edge of the
screen, so it was shifted to the right of the trigger, and the two boxes no longer share
a column; there is no elbow path left for the pointer to reach the tooltip.

The fourth and fifth rows pass the criterion but show thirty-two pixels of breakage on
the direct path. This is not a conformance defect but a usability cost: a user aiming
straight at the center of the tooltip strays outside the trigger. **Centering** the
tooltip on the trigger reduces this cost to zero; the criterion only requires that some
path exist, while the design also wants the natural path to work.

#### Measurable Constraints

**1.4.13** requires all three conditions together; the table checks the three as separate
columns.

**2.5.8 Target Size (Minimum).** The icon trigger must meet the 24 x 24 pixel threshold; a
16-pixel icon is expanded to this size with a surrounding hit area.

**1.4.3 Contrast (Minimum)** and **1.4.11 Non-Text Contrast.** The tooltip box's text
must meet a 4.5:1 ratio against its background, and the box's border, along with the
arrow's surroundings if there is one, must meet a 3:1 ratio against the surface. Because
the layer falls on top of the content beneath it, the tooltip's background cannot be
transparent.

**1.4.4 Resize Text.** When text is doubled in size, the tooltip box must grow without
clipping its content; a tooltip with a fixed height cuts off the text at this threshold.

**1.4.12 Text Spacing.** When line height and letter spacing are increased, the box still
must not overflow. Together, the two criteria require the tooltip box to be a box that
grows with its content.

#### Common Mistakes and How to Recognize Them

**The tooltip only opens on hover.** A keyboard user can never reach the information. How
to recognize it: tab to the trigger and check whether the tooltip appears.

**The tooltip is the button's only name.** If the icon button has no `aria-label` value
and the name exists only in the tooltip, the button is nameless in the accessibility tree.
How to recognize it: read the button's name field without ever opening the tooltip;
automated checkers catch this defect with an existence rule.

**The tooltip contains a link.** The user has to move the pointer onto the tooltip to
reach the link, and a keyboard user can never reach it at all. How to recognize it: look
for a focusable element inside the tooltip content; if one is found, the wrong pattern was
chosen.

**Timed hiding.** If the tooltip vanishes on its own after a few seconds, the criterion's
persistence condition is not met. How to recognize it: open the tooltip and wait; a
tooltip that disappears is defective.

#### Summary

- A tooltip adds a supplementary description to a control that already has a name; the
  information inside it can never be required, and a tooltip cannot stand in for a
  control's name.
- Interactive content, long text, and error messages do not belong in a tooltip; the
  toggletip, which opens by clicking and stays until the user closes it, is a separate
  pattern for this work.
- Because the presentation of the `title` attribute is left to the user agent, the
  control the criterion demands does not stay with the author; the tooltip is built by
  hand and carries a `role="tooltip"` role along with an `aria-describedby` binding.
- 1.4.13 requires three independent conditions: dismissible, hoverable, persistent. A
  tooltip that closes with Escape but hides itself after a timer does not meet the
  criterion.
- Hoverability is computed by geometry: the gap between the trigger and the tooltip must
  be closed with an invisible transit area, and horizontal overlap must not drop to zero.
- Centering the tooltip on the trigger is an extra decision the criterion does not require
  but the natural pointer path calls for.

#### Next Step

The tooltip appeared because the user turned toward an element; the user decided when it
would be visible. The next layer severs even that link: the system sends its own
notification whenever something happens, no matter where on the screen the user is
looking. Three new questions arise here — how long should a notification stay on screen,
how should two notifications that arrive at the same time queue up, and how should a
notification reach beyond the visual channel? The next lesson computes how long content
stays visible from the text's reading speed and tests the announcement queue against
notifications that arrive one after another.

### Notification Banners and Toast Notifications

Source: https://academia.sh/en/courses/accessible-patterns/banners-and-toast-notifications

The distinction between the notification banner and the toast notification, computing how long a notification stays visible from the text's reading speed, coalescing events that arrive one after another, and measuring loss in the polite–assertive queue.

The user opened the tooltip: they turned toward an element and the layer appeared. A
notification has no such link. The system sends word of something on its own; the user is
looking wherever they happen to be looking on the screen, and might not be looking at the
screen at all.

This raises three new questions. How long should a notification stay on screen — text
that disappears counts as unread. What should happen with two notifications that arrive
at the same time — if one suppresses the other, what is lost? And how does a user outside
the visual channel receive the notification at all? This lesson ties all three to a
computable constraint.

#### What It Solves, When Not to Use It

There are two separate patterns, and confusing them is the most common defect.

A **notification banner** is a section that stays inside the page's flow and remains
until the user or the system removes it. It stays as long as its content remains valid:
"Three of your records are overdue", "The catalog is read-only for maintenance". Because
it is persistent, it can hold an action and focus can reach inside it.

A **toast** is a layer that appears above the flow and disappears on its own once its time
runs out. It announces only a **completed** and **reversible** action: "Record borrowed".

Situations where it should not be used all derive from a single rule: **a layer that
disappears cannot carry anything the user is required to do.**

- **An error requiring recovery** does not belong in a toast; if the user is not looking
  at the screen, they never see the error at all, and the system fails silently.
- **A question requiring confirmation** does not belong in a toast; once time runs out,
  the question goes unanswered.
- **Information that must be kept on record** does not belong in a toast; the
  notification also needs a counterpart in a persistent list.

Can a toast hold an action? It can, under exactly one condition: the action must be
**optional**. "Undo" fits this — if the user does nothing, the result is still
consistent. "Retry" does not — if nothing is done, the request is lost.

#### Native Element First, ARIA Second

There is no native element set aside for notifications; the setup is built with roles.

| Part | Source |
|---|---|
| Polite notification | `role="status"` (implicit `aria-live="polite"`) |
| Assertive notification | `role="alert"` (implicit `aria-live="assertive"`) |
| Accumulating record | `role="log"` — an ordered notification list; old records may disappear |
| The banner's name | if the banner is a section, bound to its heading with `aria-labelledby` |

The rule set out in the Screen Reader Experience lesson is a setup requirement here: **the
live region's container must already exist in the document.** Adding the container
together with its content when the notification arrives can result in the notification
never being announced. The correct setup places an empty container when the page is built
and writes only the text into it.

The second rule is choosing a priority. Polite priority joins a queue; assertive priority
drops whatever is waiting. Borrow confirmation is polite; an error that stops the user's
work is assertive. Making every notification assertive strips the priority of its meaning
and produces the loss the measurement below shows.

#### Duration Is Computed from the Text

A toast's duration is not a design preference; it is a quantity derived from the length of
the text. The model sums three allowances: attention turning toward the notification,
reading the text, and — if there is an action — reaching the action and deciding. The
result is clamped to a lower and an upper bound.

```js
// notification.mjs — computing visible duration from text, coalescing, and the announcement queue

// Model values (chosen parameters): reaction allowance, reading speed, action allowance.
const REACTION = 700;       // ms — attention turning toward the notification
const READING_SPEED = 200;  // words/minute — the value chosen for reading from a screen
const ACTION = 1500;        // ms — allowance for reaching and deciding on the notification's action
const MIN_MS = 4000, MAX_MS = 10000;

const wordCount = (m) => m.trim().split(/\s+/).length;
function duration(m, hasAction) {
  const raw = REACTION + (wordCount(m) / READING_SPEED) * 60000 + (hasAction ? ACTION : 0);
  return { raw, visible: Math.min(MAX_MS, Math.max(MIN_MS, Math.round(raw / 100) * 100)) };
}

const EXAMPLES = [
  { m: "Record borrowed.", action: false },
  { m: "Record borrowed. Return date is August 11.", action: false },
  { m: "Record borrowed. Return date is August 11.", action: true },
  { m: "Filter updated; 128 records in the list.", action: false },
  { m: "The borrow request could not be sent. The connection dropped; use the retry button.", action: true },
];

console.log("notification text                                          words  action  raw duration  visible");
for (const o of EXAMPLES) {
  const s = duration(o.m, o.action);
  const short = o.m.length > 55 ? o.m.slice(0, 52) + "..." : o.m;
  console.log(
    `${short.padEnd(58)} ${String(wordCount(o.m)).padStart(5)}  ${(o.action ? "yes" : "no").padStart(6)}  ` +
      `${(s.raw.toFixed(0) + " ms").padStart(12)}  ${(s.visible + " ms").padStart(7)}`,
  );
}

// --- Consecutive events: coalesced and uncoalesced ----------------------------
const EVENTS = [
  { at: 0,    type: "borrow", subject: "Light and Shadow" },
  { at: 350,  type: "borrow", subject: "Handbook of Network Protocols" },
  { at: 900,  type: "borrow", subject: "North Slope Measurements" },
  { at: 4200, type: "filter", subject: "128" },
  { at: 4600, type: "filter", subject: "42" },
  { at: 5100, type: "filter", subject: "7" },
  { at: 7000, type: "error",  subject: "North Slope Measurements" },
];

const COALESCE_WINDOW = 1500;   // ms — the interval in which events of the same type are gathered
const PRIORITY = { borrow: "polite", filter: "polite", error: "assertive" };
const HAS_ACTION = { borrow: true, filter: false, error: true };

const writeText = (type, subjects) => {
  if (type === "borrow")
    return subjects.length === 1 ? `${subjects[0]} borrowed.` : `${subjects.length} records borrowed.`;
  if (type === "filter") return `Filter updated; ${subjects[subjects.length - 1]} records in the list.`;
  return `${subjects[0]} could not be borrowed. The request was declined because no copy remains.`;
};

function notifications(events, coalesce) {
  if (!coalesce)
    return events.map((o) => ({ at: o.at, type: o.type, text: writeText(o.type, [o.subject]) }));
  const groups = [];
  for (const o of events) {
    const last = groups[groups.length - 1];
    if (last && last.type === o.type && o.at - last.lastAt <= COALESCE_WINDOW) {
      last.subjects.push(o.subject);
      last.lastAt = o.at;
    } else groups.push({ type: o.type, subjects: [o.subject], lastAt: o.at });
  }
  // A coalesced group is written once its window closes.
  return groups.map((g) => ({ at: g.lastAt, type: g.type, text: writeText(g.type, g.subjects) }));
}

function queue(list) {
  const incoming = [...list].sort((a, b) => a.at - b.at);
  let i = 0, active = null, waiting = [];
  let dropped = 0, cutShort = 0, complete = 0;
  const log = [];

  const start = (b, at) => {
    const s = duration(b.text, HAS_ACTION[b.type]).visible;
    active = { ...b, start: at, end: at + s, duration: s };
    log.push({ at, priority: PRIORITY[b.type], wait: at - b.at, text: b.text, duration: s });
  };

  while (i < incoming.length || waiting.length || active) {
    const nextEvent = i < incoming.length ? incoming[i].at : Infinity;
    const end = active ? active.end : Infinity;
    if (end <= nextEvent) {
      complete++;
      active = null;
      if (waiting.length) start(waiting.shift(), end);
      continue;
    }
    if (nextEvent === Infinity) break;
    const b = incoming[i++];
    if (PRIORITY[b.type] === "assertive") {
      dropped += waiting.length;
      waiting = [];
      if (active) { cutShort++; log[log.length - 1].cut = b.at - active.start; }
      start(b, b.at);
    } else if (!active) start(b, b.at);
    else waiting.push(b);
  }
  return { dropped, cutShort, complete, log };
}

for (const coalesce of [false, true]) {
  const list = notifications(EVENTS, coalesce);
  const s = queue(list);
  console.log(`\n${coalesce ? "coalesced" : "each event its own notification"}: ` +
    `${EVENTS.length} events -> ${list.length} notifications -> ${s.log.length} displays`);
  console.log("    at  priority  wait    visible  text");
  for (const k of s.log) {
    const visible = k.cut === undefined ? `${k.duration} ms` : `${k.cut}/${k.duration}`;
    console.log(`  ${String(k.at).padStart(4)}  ${k.priority.padEnd(9)} ${(k.wait ? k.wait + " ms" : "—").padStart(8)} ` +
      `${visible.padStart(10)}  ${k.text}`);
  }
  console.log(`  never displayed: ${s.dropped}, cut short: ${s.cutShort}, fully displayed: ${s.complete}`);
}
```

```
notification text                                          words  action  raw duration  visible
Record borrowed.                                               2      no       1300 ms  4000 ms
Record borrowed. Return date is August 11.                     7      no       2800 ms  4000 ms
Record borrowed. Return date is August 11.                     7     yes       4300 ms  4300 ms
Filter updated; 128 records in the list.                       7      no       2800 ms  4000 ms
The borrow request could not be sent. The connection...       14     yes       6400 ms  6400 ms

each event its own notification: 7 events -> 7 notifications -> 3 displays
    at  priority  wait    visible  text
     0  polite           —    4000 ms  Light and Shadow borrowed.
  4000  polite     3650 ms  3000/4000  Handbook of Network Protocols borrowed.
  7000  assertive        —    6700 ms  North Slope Measurements could not be borrowed. The request was declined because no copy remains.
  never displayed: 4, cut short: 1, fully displayed: 2

coalesced: 7 events -> 3 notifications -> 3 displays
    at  priority  wait    visible  text
   900  polite           —    4000 ms  3 records borrowed.
  5100  polite           —  1900/4000  Filter updated; 7 records in the list.
  7000  assertive        —    6700 ms  North Slope Measurements could not be borrowed. The request was declined because no copy remains.
  never displayed: 0, cut short: 1, fully displayed: 2
```

The first table ties duration to the text. A two-word notification's raw duration is 1.3
seconds and gets pulled up to the floor; the fourteen-word error message calls for 6.4
seconds. The floor keeps short notifications from flashing past unread, and the ceiling
keeps long text from becoming permanent on screen. The third row shows the action
allowance: the same text rises to 4.3 seconds when it carries an "Undo" button, because
reading is not enough — the user also has to decide.

A notification that leans on the ceiling is a signal: the text is too long for a
notification and belongs in a banner or a persistent list.

The second and third blocks process the same seven events with two different setups. **In
the uncoalesced setup, only three of the seven events reach the screen**; four are never
displayed, and one is cut short. Worse is the second row: the user sees the notification
for the second record 3.65 seconds after borrowing it. When a notification arrives this
long after the action it reports, the user mistakes it for the result of a third action.

**In the coalesced setup, the seven events come down to three notifications and nothing
is lost.** Two rules achieve this. Events of the same type are gathered across a window
and turn into a single, counted text: "3 records borrowed" instead of three separate
"borrowed" notifications. For consecutive filter changes, **only the final state** is
written; intermediate values are not announced, because the final number is what matters
to the user.

In both setups, the assertive error notification cuts off the active one. This follows
directly from the definition of priority and is not a defect to fix; what needs fixing is
making sure the information that was cut short exists somewhere else. If the cut-short
notification's counterpart stands in a persistent record list, the loss stops being real.

#### Measurable Constraints

**2.2.1 Timing Adjustable.** A notification that disappears on its own is a time limit.
The criterion requires that the limit can be turned off, adjusted, or extended. In
practice this comes down to three rules: the timer stops when the notification is hovered
over or gains focus; the notification can be dismissed by hand; and its content also
exists in a persistent list.

**4.1.3 Status Messages.** The notification must be announced without moving focus. Code
that moves focus to the notification violates the criterion from the opposite direction:
the user is pulled away from what they were doing.

**2.2.2 Pause, Stop, Hide.** If the notification slides in from somewhere, that motion
must last less than five seconds or be able to be stopped. The same criterion also covers
a notification log accumulating on screen that advances on its own.

**1.4.13.** If a notification banner is positioned above the page and covers the content
beneath it, the three conditions from the previous lesson apply here too, dismissibility
above all.

**Position and scrolling.** A toast appears in a corner of the screen and covers the
content in that corner. If the covered content is a focused element, the 2.4.11 criterion
is violated. Space set aside beneath the notification area is not a design flourish; it is
what this criterion requires.

#### Common Mistakes and How to Recognize Them

**One notification per event.** In a bulk operation, notifications pile up on top of each
other. How to recognize it: borrow several records quickly and count how many
notifications appear on screen; if the count equals the number of actions, there is no
coalescing.

**An error inside a toast.** If the user is not looking at the screen when the request
fails, no trace remains at all. How to recognize it: produce the error and wait; if no
trace of it remains on screen once the time runs out, the wrong pattern was chosen.

**The notification container is added afterward.** The announcement works sometimes and
not other times. How to recognize it: check whether the live region container is in the
document when the page is first built.

**Fixed duration.** If every notification stays for the same length of time, long text
goes unread. How to recognize it: measure the longest notification text by word count and
compare it against the duration the model gives.

#### Summary

- A notification banner stays inside the flow and is persistent; a toast appears above the
  flow and disappears on its own.
- A layer that disappears cannot carry anything the user is required to do; a toast can
  hold only an optional action.
- The live region container exists in the document beforehand; polite priority joins a
  queue and assertive priority drops what is waiting, and this distinction loses its
  meaning in a setup that makes every notification assertive.
- Visible duration is computed from the text's word count, the reaction allowance, and the
  action allowance; it is clamped to a floor and a ceiling, and text that leans on the
  ceiling belongs to a banner rather than a notification.
- Consecutive events of the same type are coalesced within a window; countable events
  reduce to a single counted text, and consecutive state changes reduce to only the final
  state.
- Disappearing on its own is a time limit: it must stop on hover, must be dismissible by
  hand, and its content must also exist in a persistent list.

#### Next Step

The notification said that a task **finished**. What appears on screen during the
interval that the same task runs is a separate pattern. A waiting indicator splits into
two basic forms: an indicator that reports a task of unknown duration is running, and an
indicator that shows the progress of a task whose completion ratio can be computed. The
two differ in role, announcement, and update frequency — and for the second, how the
percentage is computed and how the remaining time is estimated determines the user's
decision to wait or not. The next lesson writes both indicators as a specification.

### Loading Indicators

Source: https://academia.sh/en/courses/accessible-patterns/loading-indicators

The specification of the indeterminate and determinate indicator; computing the progress value and remaining-time estimate, measuring the instability of the estimate, and the frequency policy for progress announcements.

The previous lesson built the layer that reports a task has **finished**. What appears on
screen during the interval the same task runs is a separate pattern, and it splits into
two basic forms.

An **indeterminate indicator** says only one thing: a task is running. It is used when
the completion ratio is unknown. A **determinate indicator** also states that ratio; it
reports how much of a computable whole is finished. The difference between them is not a
visual difference: their roles, the values they carry, their announcements, and their
update frequencies differ. This lesson writes both as a specification and measures the
most often misbuilt part of the determinate indicator — the remaining-time estimate.

#### What It Solves, When Not to Use It

An indicator answers two questions: is the system working, and how much is left? The
first question exists in every wait; the second can be answered only for tasks whose
whole is known.

Situations where it should not be used depend on measured durations. The Loading and
Empty States lesson measured catalog search response times and found that most requests
complete in under a second; opening an indicator in that interval produces flicker. That
decision is bound to the delay and minimum-visible-time rules from that lesson; this
lesson writes what comes **after** that decision.

Two more rules apply. **A determinate indicator is not built if the whole is unknown**;
making up a percentage promises the user a false finish. **An indicator is not enough if
the space for coming content needs to be held**; a small indicator does not prevent
layout shift when the content arrives, and that work belongs to the next lesson's
pattern.

#### Native Element First, ARIA Second

Markup has an element set aside for this purpose: `progress`. It becomes a determinate
indicator when given a value and an indeterminate one when not; it carries its own role
and value fields.

The manual setup lists its fields one by one:

| Part | Determinate indicator | Indeterminate indicator |
|---|---|---|
| Role | `role="progressbar"` | `role="progressbar"` |
| Value | `aria-valuenow` | not given |
| Bounds | `aria-valuemin`, `aria-valuemax` | not given |
| Readable value | `aria-valuetext` (for example "8 MB of 24 MB") | not given |
| Name | `aria-labelledby` or `aria-label` | same |
| Region state | `aria-busy="true"` on the region being refreshed | same |

Two distinctions are critical. First, **the absence of the `aria-valuenow` field is how
indeterminacy is announced**; writing zero means "made no progress at all" and is wrong.
Second, `aria-valuetext` is written only when the raw number is meaningless on its own:
writing an additional "40 percent" text when the percentage is already understandable
doubles the announcement.

The indicator itself is **not** a live region. Announcing every change of the progress
value produces the flood of announcements the measurement below shows. The announcement
is made sparingly, from a separate polite region next to the indicator.

#### Keyboard and Focus Contract

The indicator is not interactive; it does not take focus and holds no place in the tab
order. The keyboard contract lives not in the indicator itself but around it.

**Cancel button.** For tasks that exceed ten seconds, cancellation is required, and the
cancel button must be focusable. When cancelled, focus has to move somewhere, because the
indicator disappears: to the button that started the task.

**Displaced focus.** If the indicator is placed in the spot of a section that holds a
focused element, focus is lost and falls to the start of the document. The correct setup
either places the indicator inside the section without replacing it, or moves focus
deliberately. The return-point rule from the Modal Dialogs lesson applies here too.

**Disabling controls.** Disabling a button while a task runs can drop the focus of a user
whose focus is on that button. Marking it with `aria-disabled` instead, and ignoring
clicks, preserves focus.

#### Value, Remaining Time, and Announcement Frequency

Computing the percentage is simple and reliable: the accumulated amount over the whole.
The remaining time, on the other hand, is an **estimate**, and the quality of the estimate
can be measured. The script below produces a twenty-four-megabyte catalog export through
a four-phase flow, compares two estimation methods, and tests announcement frequency
policies.

```js
// progress.mjs — determinate progress percentage, remaining-time estimation, and announcement frequency

// Catalog export: 24 MB, arriving in chunks on 200 ms steps.
const TOTAL = 24 * 1024 * 1024;
const STEP = 200;                    // ms

// Reproducible example: linear congruential generator.
function generator(seed) {
  let s = seed >>> 0;
  return () => ((s = (1664525 * s + 1013904223) >>> 0) / 4294967296);
}
const random = generator(20260728);

// Four phases: slow start, fast flow, stall, fast finish.
const PHASE = [
  { steps: 10, base: 180_000, jitter: 0.5 },
  { steps: 20, base: 900_000, jitter: 0.3 },
  { steps: 8,  base: 40_000,  jitter: 0.8 },
  { steps: 40, base: 700_000, jitter: 0.3 },
];

const increments = [];
for (const e of PHASE)
  for (let i = 0; i < e.steps; i++)
    increments.push(Math.max(0, Math.round(e.base * (1 + e.jitter * (random() * 2 - 1)))));

// Cut / complete until the total fills exactly.
let total = 0;
const incoming = [];
for (const a of increments) {
  if (total >= TOTAL) break;
  const chunk = Math.min(a, TOTAL - total);
  total += chunk;
  incoming.push(chunk);
}
if (total < TOTAL) incoming.push(TOTAL - total);

// --- Percentage and remaining-time estimate -----------------------------------
const ALPHA = 0.25;                  // weight of the exponentially weighted average
const BUCKET = 5000;                 // ms — rounding step for the displayed estimate
let accumulated = 0, speed = null, previousShown = Infinity;
const rows = [];
for (let i = 0; i < incoming.length; i++) {
  accumulated += incoming[i];
  const elapsed = (i + 1) * STEP;
  const percent = (accumulated / TOTAL) * 100;
  const instantSpeed = incoming[i] / STEP;                // bytes/ms
  speed = speed === null ? instantSpeed : ALPHA * instantSpeed + (1 - ALPHA) * speed;
  const naive = percent > 0 ? (elapsed * (100 - percent)) / percent : Infinity;
  const weighted = speed > 0 ? (TOTAL - accumulated) / speed : Infinity;
  // Displayed estimate: rounded to the bucket and never increases.
  const shown = Math.min(previousShown, Math.ceil(weighted / BUCKET) * BUCKET);
  previousShown = shown;
  rows.push({ i: i + 1, elapsed, percent, naive, weighted, shown });
}

const actualDuration = incoming.length * STEP;
console.log(`total ${(TOTAL / 1024 / 1024).toFixed(0)} MB, ${incoming.length} steps, actual duration ${actualDuration} ms`);
console.log("\nstep  elapsed(ms)  percent   naive left  weighted left  shown  actual left");
for (const s of rows) {
  if (![1, 5, 10, 15, 25, 30, 34, 38, rows.length].includes(s.i)) continue;
  const write = (v) => (v === Infinity ? "—" : Math.round(v) + " ms");
  console.log(
    `${String(s.i).padStart(4)} ${String(s.elapsed).padStart(12)} ${s.percent.toFixed(1).padStart(7)} ` +
      `${write(s.naive).padStart(12)} ${write(s.weighted).padStart(15)} ${write(s.shown).padStart(9)} ` +
      `${(actualDuration - s.elapsed + " ms").padStart(12)}`,
  );
}

// Stability of the estimates: increases in remaining time, largest jump, deviation from actual
function stability(field) {
  let increasing = 0, largest = 0, deviationTotal = 0, changes = 0;
  for (let i = 1; i < rows.length; i++) {
    const diff = rows[i][field] - rows[i - 1][field];
    if (diff > 0) { increasing++; largest = Math.max(largest, diff); }
    if (diff !== 0) changes++;
    deviationTotal += Math.abs(rows[i][field] - (actualDuration - rows[i].elapsed));
  }
  return { increasing, largest, changes, avgDeviation: deviationTotal / (rows.length - 1) };
}
console.log("\nestimate      increasing steps  largest jump  screen changes  average absolute deviation");
for (const [name, field] of [["naive", "naive"], ["weighted", "weighted"], ["shown", "shown"]]) {
  const k = stability(field);
  console.log(`${name.padEnd(10)} ${String(k.increasing).padStart(15)} ${(Math.round(k.largest) + " ms").padStart(14)} ` +
    `${String(k.changes).padStart(15)} ${(Math.round(k.avgDeviation) + " ms").padStart(27)}`);
}

// --- Announcement frequency ---------------------------------------------------
// The minimum interval between two announcements is the announcement text's reading duration (the previous lesson's model).
const REACTION = 700, READING_SPEED = 200;
const ANNOUNCEMENT = "40 percent complete";
const MIN_INTERVAL = Math.round(REACTION + (ANNOUNCEMENT.trim().split(/\s+/).length / READING_SPEED) * 60000);
console.log(`\nannouncement text: "${ANNOUNCEMENT}" -> minimum interval ${MIN_INTERVAL} ms`);
const POLICY = [
  { name: "every step", threshold: null, interval: null },
  { name: "every 1%", threshold: 1, interval: null },
  { name: "every 10%", threshold: 10, interval: null },
  { name: "every 25%", threshold: 25, interval: null },
  { name: "at least 2 s apart", threshold: null, interval: 2000 },
  { name: "every 10% + at least 2 s", threshold: 10, interval: 2000 },
];

console.log("policy                    announcements  shortest interval  unreadable announcements");
for (const p of POLICY) {
  const moments = [];
  let lastPercent = -Infinity, lastAt = -Infinity;
  for (const s of rows) {
    const percentOk = p.threshold === null ? true : Math.floor(s.percent / p.threshold) > Math.floor(lastPercent / p.threshold);
    const intervalOk = p.interval === null ? true : s.elapsed - lastAt >= p.interval;
    if (p.threshold === null && p.interval === null) { moments.push(s.elapsed); lastPercent = s.percent; lastAt = s.elapsed; continue; }
    if (percentOk && intervalOk) { moments.push(s.elapsed); lastPercent = s.percent; lastAt = s.elapsed; }
  }
  let shortest = Infinity, unreadable = 0;
  for (let i = 1; i < moments.length; i++) {
    const gap = moments[i] - moments[i - 1];
    shortest = Math.min(shortest, gap);
    if (gap < MIN_INTERVAL) unreadable++;
  }
  console.log(`${p.name.padEnd(25)} ${String(moments.length).padStart(14)} ` +
    `${((shortest === Infinity ? "—" : shortest + " ms")).padStart(18)} ${String(unreadable).padStart(24)}`);
}
```

```
total 24 MB, 45 steps, actual duration 9000 ms

step  elapsed(ms)  percent   naive left  weighted left  shown  actual left
   1          200     0.6     33474 ms        33474 ms  35000 ms      8800 ms
   5         1000     3.9     24647 ms        25221 ms  30000 ms      8000 ms
  10         2000     7.5     24808 ms        23986 ms  25000 ms      7000 ms
  15         3000    27.8      7806 ms         4395 ms   5000 ms      6000 ms
  25         5000    65.0      2698 ms         1971 ms   5000 ms      4000 ms
  30         6000    81.8      1336 ms         1047 ms   5000 ms      3000 ms
  34         6800    82.6      1436 ms         2813 ms   5000 ms      2200 ms
  38         7600    82.8      1574 ms         7764 ms   5000 ms      1400 ms
  45         9000   100.0         0 ms            0 ms      0 ms         0 ms

estimate      increasing steps  largest jump  screen changes  average absolute deviation
naive                   11        2424 ms              44                     4839 ms
weighted                13        2795 ms              44                     5365 ms
shown                    0           0 ms               6                     6405 ms

announcement text: "40 percent complete" -> minimum interval 1600 ms
policy                    announcements  shortest interval  unreadable announcements
every step                            45             200 ms                       44
every 1%                              36             200 ms                       35
every 10%                             11             400 ms                        8
every 25%                              5            1200 ms                        1
at least 2 s apart                     5            2000 ms                        0
every 10% + at least 2 s               5            2000 ms                        0
```

The first table shows how poor the estimate is. At the first step, both methods say thirty-three seconds;
the actual remaining time is eight point eight seconds. At the thirty-fourth and thirty-eighth steps —
during the phase where the stream stalls — the weighted estimate climbs from two point eight to seven
point eight seconds while the actual remaining time drops from two point two to one point four seconds.
Through the stall, the estimate **rises**, meaning the user sees "remaining time increasing" on screen.

The second table sums this up in three numbers. The naive estimate increases on eleven
of the forty-five steps, the weighted estimate on thirteen. The weighted method's average
absolute deviation turns out **worse** than the naive method's: weighting the most recent
measurement spreads the stall forward into the future. The conclusion that follows is not
to look for a better estimation method, but to change how the estimate is presented.

The third column does this. The displayed value rounds to five-second buckets and **never
increases**. The result: zero increases, only six screen changes across forty-five steps.
The cost appears in the last field of the third column — the average absolute deviation
climbs to 6405 milliseconds, higher than both methods. This is a deliberate trade-off: the
remaining time is not reliable to begin with, so a presentation that **never moves the
wrong way and changes rarely** is better than one that is closer to accurate but jumps
around.

The percentage itself is exempt from this problem: because the accumulated amount does
not decrease, the percentage does not decrease either. The only case where the percentage
goes backward is the whole changing afterward; in that case the indicator stops being
determinate and must fall back to indeterminate.

The last table ties down the announcement frequency. Announcing on every step produces
forty-five announcements, and forty-four of them arrive before the previous one's reading
time has elapsed — meaning none of them get read. Policies that watch only the percentage
threshold are not enough either: in the fast phase, the ten-percent threshold is crossed
twice within four hundred milliseconds. What is sufficient is a **time threshold**; a
two-second interval brings the announcement count down to five and pulls the
unreadable-announcement count to zero. Applied together with the percentage threshold, the
result stays the same, because the binding condition is time.

#### Measurable Constraints

**4.1.3 Status Messages.** A progress notification must be announced without moving
focus; the announcement is made from a polite region next to the indicator, not the
indicator itself, and its frequency is bound by the policy above.

**2.2.2 Pause, Stop, Hide.** An indicator that moves on its own for longer than five
seconds must be able to be paused or hidden. The indeterminate indicator's spinning motion
falls under this criterion; reducing the motion with a reduced-motion query is the
cheapest way to meet the criterion — a pulsing or entirely still indicator is used in
place of motion.

**2.3.1 Three Flashes.** The indicator must not flash more than three times per second. A
fast-blinking loading animation can exceed this threshold.

**1.4.11 Non-Text Contrast.** The ratio between the progress bar's filled portion and its
empty channel must be at least 3:1; the bar itself must also be distinguishable against
the surrounding surface. A thin bar distinguished only by a light and a dark tone misses
this threshold.

**2.5.8 Target Size (Minimum).** The cancel button must meet the 24 x 24 pixel threshold.

#### Common Mistakes and How to Recognize Them

**Writing zero on an indeterminate indicator.** An indeterminate indicator carrying
`aria-valuenow="0"` broadcasts "made no progress at all". How to recognize it: the value
staying fixed at zero while progress continues.

**Making the indicator itself a live region.** An announcement is produced on every value
change. How to recognize it: compare the announcement count against the step count; if
they are equal, there is no policy.

**Showing remaining time second by second.** The number jumps back and forth and erodes
trust. How to recognize it: watch whether the remaining-time field ever increases; if it
increases even once, the presentation is defective.

**Manufacturing a percentage from time.** Producing a percentage from elapsed time when
the whole is unknown produces a bar that sticks at ninety percent. How to recognize it:
check whether the indicator's value is fed by an actual measurement or by a timer.

#### Summary

- An indeterminate indicator reports only that a task is running; a determinate indicator
  reports its completion ratio; indeterminacy is expressed by the **absence** of the
  `aria-valuenow` field, not by writing zero.
- The indicator is not interactive and does not take focus; the keyboard contract lives in
  the cancel button and in preserving focus in the section the indicator replaces.
- The percentage is reliable and never decreases; remaining time is an estimate, and both
  methods in the measurement produce deviation on the order of seconds, with the weighted
  method growing even worse during a stall.
- When the estimate is presented rounded to buckets that never increase, screen changes
  drop from forty-four to six; the cost is a larger deviation, and it is paid
  deliberately.
- Progress announcements are made from a separate polite region, not the indicator, and
  are bound to a time threshold; a percentage threshold alone produces unreadable
  announcements during fast phases.
- The pattern is tested against the 4.1.3, 2.2.2, 2.3.1, 1.4.11, and 2.5.8 criteria; an
  indicator that honors the reduced-motion preference is the cheapest path to
  conformance.

#### Next Step

This lesson's indicator said "I am working" but did not hold the **place** of the content
to come. When the content arrives and everything beneath it gets pushed down, the user
loses the point they were looking at; measured, this push turns into a number. The next
lesson writes the space-holding loading form — the skeleton screen — as a specification:
how the skeleton must appear in the accessibility tree, how shift accumulates when several
regions refresh instead of one, and what cost the skeleton's promised structure being
wrong produces.

### Skeleton Screens

Source: https://academia.sh/en/courses/accessible-patterns/skeleton-screens

The specification of the skeleton screen; its place in the accessibility tree, computing cumulative layout shift with a session window for multi-region loading, and the cost of the skeleton's promised structure turning out wrong.

The previous lesson's indicator said "I am working" but did not hold the place of the
content to come. What appears on screen when the catalog page opens is not a single
section: the top bar, the filter panel, the result list, and the recommendations arrive
at separate times. Each time one arrives, everything beneath it is pushed down.

A **skeleton screen** is a loading view made of gray boxes that hold the place of coming
content in advance. The Loading and Empty States lesson measured it through a single
unsteady element, and the skeleton's row count was determined by the visible area. This
lesson writes the same pattern as a specification and deepens the measurement in two
directions: what the skeleton is in the accessibility tree, and how shift **accumulates**
when several regions arrive at separate times.

#### What It Solves, When Not to Use It

The skeleton does two jobs. It reserves space and so prevents layout shift, and it shows
the structure of coming content in advance, making the wait feel shorter. The second job
is a perceptual effect and is hard to measure; the first job can be turned into a number,
and it is what the decision rests on.

There are three situations where it should not be used:

- **When the chance of no results is high.** If the skeleton draws nine rows and three
  results arrive, the user briefly believes there are nine results; if no results arrive
  at all, the skeleton has plainly lied.
- **When the structure is unknown.** A skeleton commits to the place of the boxes it
  draws. If the row height or field count of the coming content varies, the commitment
  does not hold, and shift occurs again.
- **When the wait is below the perception threshold.** A skeleton drawn for content that
  arrives in a hundred milliseconds is itself a source of flicker.

#### Native Element First, ARIA Second

The skeleton has no counterpart in markup; the boxes are purely visual placeholders. This
is also the critical side of the specification: **skeleton elements must not exist in the
accessibility tree.**

| Part | Source |
|---|---|
| Skeleton boxes | `aria-hidden="true"` — decorative elements carrying no meaning |
| Refreshed region | `aria-busy="true"`, removed once content arrives |
| Status announcement | A separate polite region: "Loading results" -> "12 records listed" |
| Focus | The region itself is not replaced; the focus inside it is preserved |

The reasoning is direct. When skeleton boxes appear in the tree, they announce that
something readable exists on screen, when in fact none of them carry text. The
decorative-element rule from the Icon Usage lesson applies exactly here: visuals that
carry no meaning are hidden.

The wait information therefore comes not from the skeleton but from the **status
region**. The region is placed when the page is built, receives a short sentence when
loading begins, and is updated with the result count when content arrives. The two rules
from the Notification Banners lesson apply here too: the region must exist beforehand and
must announce the final state.

#### Shift Is Not a Single Event, It Is a Sum

The layout shift score was defined in the Loading and Empty States lesson: the product of
the ratio of the affected area to the viewport area and the ratio of the distance
traveled to the largest dimension. The Frontend Quality course added that this score is
summed within a **session window**: a window begins with the first shift and closes on a
gap longer than one second or once five seconds have elapsed; **cumulative layout shift**
is the largest of the windows.

The script below loads a four-region catalog page through four separate skeleton setups
and splits each setup's shifts into windows.

```js
// skeleton.mjs — cumulative layout shift and session windows in multi-region loading

const VIEWPORT = { w: 1280, h: 800 };
const FOOTER = 80;

// The page is made of regions in visual order. Each region switches to its real height at its own moment.
const LAYOUT = [
  { name: "warning banner",  at: 6500, real: 64,  skeleton: 0   },
  { name: "top bar",         at: 400,  real: 96,  skeleton: 96  },
  { name: "filter panel",    at: 700,  real: 120, skeleton: 120 },
  { name: "result list",     at: 1500, real: 648, skeleton: 648 },  // 9 rows x 72 px
  { name: "recommendations", at: 2200, real: 240, skeleton: 240 },
];

// Scenarios: each region's starting (placeholder) height and its real height.
const SCENARIO = {
  "no skeleton": (b) => ({ start: b.name === "result list" ? 120 : 0, end: b.real }),
  "partial skeleton": (b) => ({ start: b.name === "result list" ? b.skeleton : 0, end: b.real }),
  "full skeleton": (b) => ({ start: b.skeleton, end: b.real }),
  "skeleton 9 rows, 3 results": (b) => ({
    start: b.skeleton,
    end: b.name === "result list" ? 3 * 72 : b.real,
  }),
};

function shifts(scenario) {
  const build = SCENARIO[scenario];
  const height = LAYOUT.map((b) => build(b).start);
  const events = LAYOUT.map((b, i) => ({ i, at: b.at, name: b.name, end: build(b).end }))
    .sort((a, b) => a.at - b.at);

  const result = [];
  for (const o of events) {
    const delta = o.end - height[o.i];
    if (delta === 0) { height[o.i] = o.end; continue; }

    // Top edge and total height of the content below the region that changed
    const topEdge = height.slice(0, o.i).reduce((a, b) => a + b, 0);
    const beforeBottomEdge = topEdge + height[o.i];
    const belowTotal = height.slice(o.i + 1).reduce((a, b) => a + b, 0) + FOOTER;
    const afterBottomEdge = beforeBottomEdge + delta;

    // Impact fraction: the union of the shifted content's visible area before and after
    const visible = (top) => [Math.max(0, Math.min(VIEWPORT.h, top)),
                               Math.max(0, Math.min(VIEWPORT.h, top + belowTotal))];
    const [o1, o2] = visible(beforeBottomEdge);
    const [s1, s2] = visible(afterBottomEdge);
    const impactFraction = (Math.max(o2, s2) - Math.min(o1, s1)) / VIEWPORT.h;
    const distanceFraction = Math.abs(delta) / Math.max(VIEWPORT.w, VIEWPORT.h);

    result.push({ at: o.at, name: o.name, delta, impactFraction, distanceFraction, score: impactFraction * distanceFraction });
    height[o.i] = o.end;
  }
  return result;
}

// Session window: begins with the first shift, closes on a gap longer than 1 s or once 5 s have elapsed.
function windows(shiftList) {
  const w = [];
  for (const k of shiftList) {
    const last = w[w.length - 1];
    if (last && k.at - last.lastAt <= 1000 && k.at - last.startAt <= 5000) {
      last.lastAt = k.at; last.total += k.score; last.count++;
    } else w.push({ startAt: k.at, lastAt: k.at, total: k.score, count: 1 });
  }
  return w;
}

let first = true;
for (const name of Object.keys(SCENARIO)) {
  const k = shifts(name);
  if (!first) console.log("");
  first = false;
  console.log(name);
  console.log("    at  region             delta  impact  distance  shift score");
  for (const s of k)
    console.log(
      `  ${String(s.at).padStart(4)}  ${s.name.padEnd(18)} ${((s.delta > 0 ? "+" : "") + s.delta + " px").padStart(7)} ` +
        `${s.impactFraction.toFixed(4).padStart(8)} ${s.distanceFraction.toFixed(4).padStart(10)} ${s.score.toFixed(4).padStart(13)}`,
    );
  const w = windows(k);
  for (const win of w)
    console.log(`  session window ${win.startAt}-${win.lastAt} ms: ${win.count} shift${win.count === 1 ? "" : "s"}, total ${win.total.toFixed(4)}`);
  const cls = w.length ? Math.max(...w.map((win) => win.total)) : 0;
  console.log(`  cumulative layout shift: ${cls.toFixed(4)}  (accepted good ceiling 0.100) -> ${cls <= 0.1 ? "passes" : "FAILS"}`);
}
```

```
no skeleton
    at  region             delta  impact  distance  shift score
   400  top bar             +96 px   0.3700     0.0750        0.0278
   700  filter panel       +120 px   0.4000     0.0938        0.0375
  1500  result list        +528 px   0.5800     0.4125        0.2392
  2200  recommendations    +240 px   0.0000     0.1875        0.0000
  6500  warning banner      +64 px   1.0000     0.0500        0.0500
  session window 400-2200 ms: 4 shifts, total 0.3045
  session window 6500-6500 ms: 1 shift, total 0.0500
  cumulative layout shift: 0.3045  (accepted good ceiling 0.100) -> FAILS

partial skeleton
    at  region             delta  impact  distance  shift score
   400  top bar             +96 px   1.0000     0.0750        0.0750
   700  filter panel       +120 px   0.8800     0.0938        0.0825
  2200  recommendations    +240 px   0.0000     0.1875        0.0000
  6500  warning banner      +64 px   1.0000     0.0500        0.0500
  session window 400-700 ms: 2 shifts, total 0.1575
  session window 2200-2200 ms: 1 shift, total 0.0000
  session window 6500-6500 ms: 1 shift, total 0.0500
  cumulative layout shift: 0.1575  (accepted good ceiling 0.100) -> FAILS

full skeleton
    at  region             delta  impact  distance  shift score
  6500  warning banner      +64 px   1.0000     0.0500        0.0500
  session window 6500-6500 ms: 1 shift, total 0.0500
  cumulative layout shift: 0.0500  (accepted good ceiling 0.100) -> passes

skeleton 9 rows, 3 results
    at  region             delta  impact  distance  shift score
  1500  result list        -432 px   0.4600     0.3375        0.1553
  6500  warning banner      +64 px   1.0000     0.0500        0.0500
  session window 1500-1500 ms: 1 shift, total 0.1553
  session window 6500-6500 ms: 1 shift, total 0.0500
  cumulative layout shift: 0.1553  (accepted good ceiling 0.100) -> FAILS
```

The four setups produce four separate results, and the difference between them does not
reduce to a single number.

**The no-skeleton setup** produces 0.3045, three times the threshold. As expected, the
largest share belongs to the result list: 528 pixels intruding all at once accounts for
0.2392 points on its own.

**The partial skeleton** brings the total down to 0.1575, but by less than expected. The
reason is in the first two rows of the table: once a skeleton is placed only on the list,
the shifts of the top bar and the filter panel **grow** — the impact fraction climbs from
0.3700 to 1.0000, from 0.4000 to 0.8800. The reason is clear: there is now a long skeleton
block beneath them to push. Placing a skeleton on one region does not cheapen the shifts
of the regions above it; it makes them more expensive. When the skeleton is applied
partially, the gain stays partial too.

**The full skeleton** zeroes out every shift in the first window. What remains is only the warning banner
arriving in the sixth second; because it falls into a separate session window, it is not added to the
total, and the cumulative value stays at 0.0500. Counting windows separately is decisive in the
skeleton-free setup: had the five shifts there fallen into a single window, the total would have been
0.3545 rather than 0.3045.

The **wrong commitment** scenario measures the skeleton's own risk. When a nine-row
skeleton is drawn and three results arrive, the list **shortens** by 432 pixels and
produces 0.1553 points. The direction of the shift does not matter; content jumping
upward produces just as many points as being pushed down. A firm rule follows from this:
**the skeleton's row count is set equal to the result count when it is known; when it is
not known, the skeleton is chosen small enough that any shift is bearable.**

The recommendations region produces zero points in every scenario. The reason is that the
content below that region falls outside the viewport; a shift that is not visible on
screen does not enter the metric. This does not mean lower regions can be left without a
skeleton — on a smaller screen the same region enters the visible area and produces
points.

#### Measurable Constraints

**Cumulative layout shift of 0.100.** This is the accepted good ceiling, and the
computation is done with session windows as above. This is not a success criterion but a
performance threshold, though its accessibility consequence is direct: a shifting layout
leads a user about to press a button to press the wrong one.

**2.2.2 Pause, Stop, Hide.** If the shimmer motion sweeping across the skeleton boxes
lasts longer than five seconds, it must be able to be stopped. When reduced motion is
signaled as a preference, the shimmer is removed entirely; the skeleton does its job as
flat gray boxes too.

**2.3.1 Three Flashes.** The shimmer must not repeat more than three times per second.

**1.4.11 Non-Text Contrast does not apply** to skeleton boxes, because the boxes carry no
meaning and are hidden from the tree. Even so, a skeleton faint enough to blend into the
surface leads the user to think the page is empty; this is a design decision, not a
criterion.

**4.1.3 Status Messages.** Loading and completion notifications come from the polite
status region, not from the skeleton.

#### Common Mistakes and How to Recognize Them

**The skeleton appears in the tree.** When the boxes are not hidden, the screen does not
count as empty, but nothing readable is found either. How to recognize it: count the text
elements on the page during loading; if skeleton boxes are counted, hiding is incomplete.

**The skeleton is placed on only one region.** The gain comes out lower than expected and
the shifts of the regions above it grow. How to recognize it: measure shifts region by
region; if the points above the region with a skeleton have grown, this is the reason.

**The skeleton does not mimic the real row height.** When the boxes are drawn shorter
than the real row, shift still occurs once the content arrives. How to recognize it:
compare the skeleton's height against the settled row's height.

**A late-arriving banner is inserted at the top of the page.** When the warning banner
enters at the top in the sixth second, the whole page shifts down. How to recognize it:
check whether space for late-arriving sections was set aside from the start; if not, the
banner should be placed outside the flow rather than on top of the content.

#### Summary

- A skeleton screen holds the place of coming content; its measurable job is preventing
  layout shift, and its effect on perceived speed is a secondary, unmeasurable-in-itself
  gain.
- Skeleton boxes carry no meaning and are hidden from the accessibility tree; wait
  information comes from a separate polite status region, not the skeleton.
- Shift is not a single event but a sum: it is totaled within session windows, and the
  cumulative value is the largest of the windows; a late-arriving shift does not change
  the total if it falls into a separate window.
- A partial skeleton enlarges the shifts of the regions above it; in the measurement, the
  top bar's impact fraction climbs from 0.3700 to 1.0000. The gain is complete only once
  every region reserves space.
- The row count the skeleton draws is a commitment; drawing nine rows and delivering
  three results produces a shift of 0.1553 points, and the direction of the shift does
  not affect the score.
- Shimmer motion is bound to the 2.2.2 and 2.3.1 criteria; with reduced motion preferred,
  the skeleton does its job without shimmer too.

#### Next Step

The skeleton rested on the assumption that content **would arrive**. Once loading ends,
two situations remain where that assumption does not hold: no records arriving at all,
and the request failing. The two look similar on screen — an empty area and a sentence —
but tell the user entirely different things and require entirely different recovery
paths. The next lesson writes these two states as a specification: which role and which
priority to use, where focus goes, how the error summary binds to fields, and it measures
the length of the recovery path by key count.

### Empty and Error States

Source: https://academia.sh/en/courses/accessible-patterns/empty-and-error-states

The role, priority, and focus specification of the empty state and the error state; measuring the recovery path by key count, the effect of the error summary on discovery cost, and a completeness check of the state specification.

The skeleton rested on the assumption that content would arrive. Once loading ends, two
situations remain where that assumption does not hold: no records arriving at all, and
the request failing. The two look similar on screen — an empty area and a sentence — but
tell the user entirely different things.

The Loading and Empty States lesson separated three causes of emptiness, and the Error
and Warning States lesson classified errors by recoverability. This lesson writes the
**specification** counterpart of those distinctions: which role, which announcement
priority, where focus goes, and how many keys the recovery path takes.

#### What It Solves, When Not to Use It

The empty state and the error state are two separate patterns, and confusing them is the
most expensive defect.

An **empty state** is a screen where the system is working correctly but there is no
content to show. It does not belong to the error class; it belongs to the information
class. A warning color, a warning icon, and an assertive announcement are not used.

An **error state** is a screen where something did not go as planned. The user has to do
something, and what to do is written on screen.

The distinction is tested with a single question: **does the system stay in a wrong state
if the user does nothing?** If the answer is no, it is an empty state. A search with no
results is an empty state; a borrow request that could not be sent is an error.

Situations where it should not be used:

- **Presenting an empty state as an error.** Showing a red warning when the filter
  returns no records tells the user the system is broken.
- **Presenting an error as an empty state.** Writing "No records found" when a request
  has failed makes existing records look like they do not exist; the user gives up
  searching.
- **Putting either one in a modal dialog.** The empty state is the page itself; a modal
  dialog turns it into an obstacle the user has to dismiss.

#### Native Element First, ARIA Second

| State | Role | Priority | Focus |
|---|---|---|---|
| Empty state (all three kinds) | `role="status"` | polite | Stays in place |
| Field validation error | Error summary `role="alert"` | assertive | Moves to the summary |
| Request error | `role="alert"` | assertive | Moves to the message container |
| Network disconnection | `role="status"` | polite | Stays in place |

Three rules produce this table.

**The empty state does not steal focus.** The user might still be typing into the filter
field; moving focus to the list when the result count changes interrupts typing. The
announcement is made from a polite region.

**Network disconnection is polite, an error is assertive.** A dropped network is not the
result of something the user did, and it corrects itself once the connection returns; it
is not a notification that stops the user's work.

**The error summary exists in the document beforehand.** The accessible error
presentation rule from the Forms and Identity topic applies here: the summary container
is placed empty, filled in once submission fails, and focus is moved there.

The empty state also needs a **heading**. The heading lets a screen reader user jump to
this section from the heading plane; an empty state without a heading is invisible on
that plane.

#### The Recovery Path Is Measurable

The most concrete side of the specification is the length of the path from the error to
the fix. The focus ring is a known sequence; the key count can be counted. The script
below measures a membership form submission with two invalid fields through three
separate focus strategies, then runs the specification of nine states through a
four-field check.

```js
// recovery.mjs — measuring the recovery path by key count and checking the state specification

// --- (a) Length of the recovery path ------------------------------------------
// Focus ring of the page holding the membership form (in document order).
const PAGE = [
  "skip-link", "logo", "nav-catalog", "nav-account", "search-field",
  "form-member-no", "form-name", "form-email", "form-phone", "form-address",
  "form-return-date", "form-note", "form-submit",
  "footer-help", "footer-contact", "footer-privacy",
];

// Tab and shift+tab cost the same; the distance between two elements is the shortest path in the ring.
function step(ring, start, target) {
  const i = ring.indexOf(start), j = ring.indexOf(target);
  if (i < 0 || j < 0) throw new Error(`not in ring: ${i < 0 ? start : target}`);
  return Math.min((j - i + ring.length) % ring.length, (i - j + ring.length) % ring.length);
}

// A strategy: a starting focus and a sequence of targets to visit.
// A target with link: true is reached with a single key (enter).
function path(name, ring, start, targets) {
  let position = start, total = 0;
  const trace = [];
  for (const h of targets) {
    const n = h.link ? 1 : step(ring, position, h.target);
    total += n;
    trace.push(`${h.target}:${n}${h.link ? "*" : ""}`);
    position = h.target;
  }
  return { name, total, trace: trace.join("  ") };
}

const A = path("no summary, focus stays", PAGE, "form-submit", [
  { target: "form-email" }, { target: "form-return-date" }, { target: "form-submit" },
]);

// The error summary is added to the form; focus moves to the summary, the summary's link leads to the field.
const RING_B = [...PAGE];
RING_B.splice(RING_B.indexOf("form-member-no"), 0, "error-summary", "summary-email", "summary-return-date");
const B = path("error summary + focus to summary", RING_B, "error-summary", [
  { target: "summary-email" }, { target: "form-email", link: true },
  { target: "form-return-date" }, { target: "form-submit" },
]);

const C = path("focus to first invalid field", PAGE, "form-email", [
  { target: "form-email" }, { target: "form-return-date" }, { target: "form-submit" },
]);

// Discovery: how many fields must be visited to learn how many errors there are?
const FIELD_COUNT = PAGE.filter((x) => x.startsWith("form-") && x !== "form-submit").length;
const DISCOVERY = { [A.name]: FIELD_COUNT, [B.name]: 0, [C.name]: FIELD_COUNT - 1 };

console.log(`form field count: ${FIELD_COUNT}, invalid fields: form-email and form-return-date`);
console.log("\nstrategy                           keys  discovery  trace (* = direct via link)");
for (const s of [A, B, C])
  console.log(`${s.name.padEnd(34)} ${String(s.total).padStart(4)} ${String(DISCOVERY[s.name]).padStart(10)}  ${s.trace}`);

// --- (b) Checking the state specification --------------------------------------
// Each state must define four fields: role, announcement priority, focus target, recovery action.
const STATE = [
  { name: "first-use emptiness",     role: "status", priority: "polite",    focus: "in place",      recovery: "description pointing to the search field" },
  { name: "no-result search",        role: "status", priority: "polite",    focus: "in place",      recovery: "clearing filters one by one" },
  { name: "cleared list",            role: "status", priority: "polite",    focus: "in place",      recovery: null },
  { name: "field validation error",  role: "alert",  priority: "assertive", focus: "error summary",  recovery: "link to the field plus a correction suggestion" },
  { name: "server error",            role: "alert",  priority: "assertive", focus: "error message",  recovery: "retry button" },
  { name: "network disconnection",   role: "status", priority: "polite",    focus: "in place",      recovery: "retry once the connection returns" },
  { name: "authorization error",     role: "alert",  priority: "assertive", focus: null,             recovery: "return to the sign-in page" },
  { name: "record not found",        role: null,     priority: null,        focus: "heading",        recovery: "return to catalog search" },
  { name: "rate limit exceeded",     role: "alert",  priority: "assertive", focus: "error message",  recovery: null },
];

const REQUIRED = ["role", "priority", "focus", "recovery"];
// "cleared list" is a success; no recovery action is expected.
const EXEMPT = { "cleared list": ["recovery"] };

console.log("\nstate                       role     priority   focus           recovery  missing");
let missingTotal = 0;
for (const d of STATE) {
  const missing = REQUIRED.filter((a) => d[a] === null && !(EXEMPT[d.name] ?? []).includes(a));
  missingTotal += missing.length;
  console.log(
    `${d.name.padEnd(27)} ${(d.role ?? "—").padEnd(8)} ${(d.priority ?? "—").padEnd(10)} ${(d.focus ?? "—").padEnd(15)} ` +
      `${(d.recovery ? "yes" : "—").padEnd(9)} ${missing.length ? missing.join(",") : "none"}`,
  );
}
console.log(`\ntotal missing fields: ${missingTotal} / ${STATE.length * REQUIRED.length}`);
```

```
form field count: 7, invalid fields: form-email and form-return-date

strategy                           keys  discovery  trace (* = direct via link)
no summary, focus stays              10          7  form-email:5  form-return-date:3  form-submit:2
error summary + focus to summary      7          0  summary-email:1  form-email:1*  form-return-date:3  form-submit:2
focus to first invalid field          5          6  form-email:0  form-return-date:3  form-submit:2

state                       role     priority   focus           recovery  missing
first-use emptiness         status   polite     in place        yes       none
no-result search            status   polite     in place        yes       none
cleared list                status   polite     in place        —         none
field validation error      alert    assertive  error summary   yes       none
server error                alert    assertive  error message   yes       none
network disconnection       status   polite     in place        yes       none
authorization error         alert    assertive  —               yes       focus
record not found            —        —          heading         yes       role,priority
rate limit exceeded         alert    assertive  error message   —         recovery

total missing fields: 4 / 36
```

The first table shows why deciding with a single number is wrong.

**The no-summary setup asks for ten keys**, and the discovery column gives the real cost:
the user has to visit all seven fields to learn how many errors there are. Because focus
stays on the submit button when submission fails, reaching the first invalid field takes
five shift+tab presses.

**Moving focus directly to the first invalid field is the cheapest path, at five keys.**
But the discovery column says six: the user learns that the field they are focused on is
invalid, and does not learn that a second error exists. They find the second error only
by continuing through the form, or do not find it at all and press submit a second time.

**The error summary asks for seven keys** — two more than the direct move — but brings
the discovery cost down to zero. The summary states how many errors there are in a single
sentence and gives a link to each one. The two extra keys are the price of answering "how
many errors are there", and it is cheap.

This measurement justifies a rule: **a summary when there are multiple errors, a direct
move when there is a single error.** With one error, the summary has no discovery gain,
and the two keys go to waste.

The second table checks the specification itself. Four of the nine states' thirty-six
fields are missing, and they are three distinct kinds of defect:

- **The authorization error has no defined focus target.** The message appears on screen
  but focus stays on the form; the user never hears the message.
- **The record-not-found state has no defined role or priority.** There is a heading on
  screen, but no announcement. This state is really a page transition, and focusing the
  heading is the right decision; what is missing is also defining it as a status
  announcement.
- **The rate-limit-exceeded state has no defined recovery action.** The user is not told
  what to do; without a path written such as "try again shortly", the error is a dead
  end.

In the "cleared list" row, the recovery field is empty but is not counted as missing,
because that state is a success and there is nothing to recover from. The check's
exemption list turns this distinction into part of the specification.

#### Measurable Constraints

**3.3.1 Error Identification.** An invalid field must be indicated **with text**; a red
border alone is not enough. This is the form counterpart of the 1.4.1 Use of Color
criterion.

**3.3.3 Error Suggestion.** If it is known how to fix the error, it must be written.
"Invalid date" is an identification, not a suggestion; "The return date must be after
today" does both at once.

**3.3.4 Error Prevention.** For irreversible operations, submission must be reversible,
checkable, or confirmable. The confirmation pattern from the Modal Dialogs lesson is the
counterpart of this criterion.

**4.1.3 Status Messages.** The empty state's result count must be announced without
moving focus.

**2.4.6 Headings and Labels.** The empty state's heading must describe what the content
is; a heading of "No records match the filters" works on the heading plane, where "No
results" does not.

**1.4.11 Non-Text Contrast.** The invalid field's border must meet at least a 3:1 ratio
against the surrounding surface; the error color must be distinguishable at the border,
not only in the text.

#### Common Mistakes and How to Recognize Them

**Focus stays in place after submission.** How to recognize it: submit the form with
errors and press the tab key once; if focus does not go to the start of the form or to
the summary, there is no strategy.

**The error summary is added afterward.** The announcement works sometimes and not other
times. How to recognize it: check whether the summary container is in the document when
the page is first built.

**The empty state is announced with an assertive priority.** The user's reading is
interrupted every time the filter changes. How to recognize it: check the empty-state
region's role; if it is `role="alert"`, this is wrong.

**The error message is not bound to the field.** The message appears on screen but is not
read when the field is focused. How to recognize it: check whether the field's
`aria-describedby` binding points to the message's id.

**The empty state has no heading.** How to recognize it: pull up the heading plane and
check whether the empty state appears on it.

#### Summary

- An empty state is a result of the system working correctly and belongs to the
  information class; an error state requires an action from the user, and that action is
  written on screen.
- The distinction is tested with a single question: does the system stay in a wrong state
  if the user does nothing?
- The empty state is announced from a polite region and does not steal focus; a field
  validation error is announced as assertive and focus moves to the error summary.
- The recovery path is measured by key count: the no-summary setup produces ten keys and
  a seven-field discovery, the direct move produces five keys and a six-field discovery,
  and the error summary produces seven keys and zero discovery.
- A summary is chosen when there are multiple errors, a direct move when there is a
  single error; the summary's two extra keys are the price of answering "how many errors
  are there".
- A state's specification must carry all four fields — role, priority, focus target, and
  recovery action; the check defines the missing field and the exemption together.

#### Next Step

Empty and error states were notifications covering an entire screen. The same information
is often carried on a much smaller surface: a small label next to a row, a colored dot
next to a number, a one-word badge reporting a record's status. These small surfaces
harbor two traps together — carrying information in color alone, and writing only a
number into the accessible name. The next lesson writes the specification for badges and
tags, computes that color alone is not distinguishing enough through contrast and a
color-blindness projection, and shows how to check for a second channel.

### Badges and Tags

Source: https://academia.sh/en/courses/accessible-patterns/badges-and-labels

The specification of the badge and the tag, building the number badge's accessible name, checking the contrast of status colors, and computing distinguishability without color information by relative luminance.

Empty and error states were notifications covering an entire screen. The same information
is often carried on a much smaller surface: a one-word mark next to a row, a number in the
corner of a button, a tag reporting a record's type.

A small surface harbors two traps together. First, information staying **in color
alone**; second, the accessible name consisting **of a number alone**. This lesson ties
both to a measurable check.

#### What It Solves, When Not to Use It

A **badge** is a small status or number mark attached to another element. It has two
kinds: a status badge reports a record's condition ("Overdue"), a number badge reports a
quantity (a notification count).

A **tag** is a mark showing a record's classification, and it is often removable. The
tags showing applied criteria in the filter panel are this kind.

The distinction is functional: a badge **describes the element it is attached to** and is
not interactive on its own; a tag **is an object on its own** and can be removed.

Situations where it should not be used:

- **A badge cannot be the sole carrier of critical information.** If "Overdue" exists
  only in the badge, that information does not exist for a user who cannot see the badge;
  the same information must also exist in the record's detail view.
- **A badge is not made clickable.** A clickable mark is a button and has to follow the
  button's contract; a button styled to look like a badge also misses the target size
  threshold.
- **A tag is not shown as removable without a removal control.** A tag carrying an "x"
  mark that cannot be removed with the keyboard is misleading.

#### Native Element First, ARIA Second

The badge has no counterpart set aside in markup; the setup rests on a name and text.

| Part | Source |
|---|---|
| Status badge | An element carrying text; the meaning lives in the text itself, no extra role needed |
| Number badge | The number is written **into the name** of the control it is attached to |
| The number changing | A polite region next to the control announces it, not the badge |
| Removable tag | The text is one element, the removal button is a separate button |
| The removal button's name | "Remove tag X" — includes the tag's text |

The number badge's setup is where mistakes happen most. A badge reading "3", placed as a
separate node next to the button, sits in the accessibility tree as a number without
context. The correct setup folds the number into the button's name: "Notifications, 3
unread". The visual badge is also hidden from the tree, because its information already
lives in the name.

Abbreviated numbers require a separate rule. A badge reading "99+" on screen must carry
the real number in its name; the visual abbreviation is a space constraint, not an
information constraint.

#### Keyboard Contract

A badge does not take focus and holds no place in the tab order. A tag's removal button,
on the other hand, is a full button:

| Key | Behavior |
|---|---|
| Tab | Reaches the removal button in sequence |
| Enter / space | Removes the tag |
| Focus after removal | Moves to the next tag; to the tag list's container if the last tag was removed |

The last item shows another form of the return-point rule from the Modal Dialogs lesson:
where focus goes when a focused element is removed is part of the specification. When it
is not defined, focus falls to the start of the document and the user loses their place
in the filter panel.

When a tag is removed, the result count changes; this change is announced from a polite
region.

#### Color Is Not a Sufficient Channel

A status badge's color is a shortcut, and the limit of that shortcut can be computed. Two
measurements are required: the badge's own contrast, and the badges' distinguishability
**against each other**. The second shows what is left once color information cannot be
used.

```js
// badge.mjs — badge color contrast, luminance collision, and the second-channel check

const split = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const linear = (k) => (k <= 0.04045 ? k / 12.92 : ((k + 0.055) / 1.055) ** 2.4);
const luminance = (hex) => {
  const [r, g, b] = split(hex).map(linear);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const ratio = (a, b) => {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
};

const SURFACE = "#F4F4F5";   // the record card's surface
const BADGE = [
  { name: "available to borrow", bg: "#2E7D32", text: "#FFFFFF", label: "Available", icon: true  },
  { name: "reserved",            bg: "#B26A00", text: "#FFFFFF", label: "Reserved",  icon: true  },
  { name: "return overdue",      bg: "#C62828", text: "#FFFFFF", label: "Overdue",   icon: true  },
  { name: "lost",                bg: "#616161", text: "#FFFFFF", label: "Lost",      icon: false },
  { name: "new record",          bg: "#1565C0", text: "#FFFFFF", label: null,        icon: false },
];

console.log("badge               background  relative luminance  text/bg  bg/surface  1.4.3  1.4.11");
for (const r of BADGE) {
  const tb = ratio(r.text, r.bg), bs = ratio(r.bg, SURFACE);
  console.log(
    `${r.name.padEnd(19)} ${r.bg}  ${luminance(r.bg).toFixed(4).padStart(15)} ` +
      `${(tb.toFixed(2) + ":1").padStart(9)} ${(bs.toFixed(2) + ":1").padStart(11)} ` +
      `${(tb >= 4.5 ? "passes" : "FAILS").padStart(6)} ${(bs >= 3 ? "passes" : "FAILS").padStart(7)}`,
  );
}

// --- Luminance collision: can the badges be distinguished when color information is unavailable?
// If two badges' contrast ratio against each other is close to 1, they become identical in the colorless channel.
console.log("\ncontrast ratio between badges (distinguishability without color information)");
console.log("                     " + BADGE.map((r) => r.name.slice(0, 8).padStart(9)).join(""));
const colliding = [];
for (const a of BADGE) {
  let row = a.name.padEnd(21);
  for (const b of BADGE) {
    const o = ratio(a.bg, b.bg);
    row += o.toFixed(2).padStart(9);
    if (a !== b && o < 1.5 && BADGE.indexOf(a) < BADGE.indexOf(b)) colliding.push([a.name, b.name, o]);
  }
  console.log(row);
}
console.log("\nindistinguishable pairs in the colorless channel (threshold 1.5:1):");
for (const [a, b, o] of colliding) console.log(`  ${a} / ${b}: ${o.toFixed(2)}:1`);
if (!colliding.length) console.log("  none");

// --- Second-channel check -------------------------------------------------------
// Status must be carried on at least one channel besides color: text or icon.
console.log("\nbadge               text  icon  second channel");
for (const r of BADGE) {
  const channel = [r.label ? "text" : null, r.icon ? "icon" : null].filter(Boolean);
  console.log(`${r.name.padEnd(19)} ${(r.label ? "yes" : "—").padStart(4)} ${(r.icon ? "yes" : "—").padStart(4)}  ` +
    `${channel.length ? channel.join(" + ") : "NONE — status is color-only"}`);
}

// --- The number badge's accessible name -----------------------------------------
const NAME_EXAMPLE = [
  { shown: "3", name: "3" },
  { shown: "3", name: "Notifications" },
  { shown: "3", name: "Notifications, 3 unread" },
  { shown: "99+", name: "Notifications, 128 unread" },
];
console.log("\nshown    accessible name              number  context  sufficient");
for (const a of NAME_EXAMPLE) {
  const hasNumber = /\d/.test(a.name);
  const hasContext = /[A-Za-z]{3,}/.test(a.name);
  console.log(`${a.shown.padEnd(8)} ${a.name.padEnd(29)} ${(hasNumber ? "yes" : "no").padStart(6)} ` +
    `${(hasContext ? "yes" : "no").padStart(7)}  ${hasNumber && hasContext ? "yes" : "NO"}`);
}
```

```
badge               background  relative luminance  text/bg  bg/surface  1.4.3  1.4.11
available to borrow #2E7D32           0.1548    5.13:1      4.66:1 passes  passes
reserved            #B26A00           0.1977    4.24:1      3.86:1  FAILS  passes
return overdue      #C62828           0.1368    5.62:1      5.11:1 passes  passes
lost                #616161           0.1195    6.19:1      5.63:1 passes  passes
new record          #1565C0           0.1327    5.75:1      5.23:1 passes  passes

contrast ratio between badges (distinguishability without color information)
                      availabl reserved return o     lost new reco
available to borrow       1.00     1.21     1.10     1.21     1.12
reserved                  1.21     1.00     1.33     1.46     1.36
return overdue            1.10     1.33     1.00     1.10     1.02
lost                      1.21     1.46     1.10     1.00     1.08
new record                1.12     1.36     1.02     1.08     1.00

indistinguishable pairs in the colorless channel (threshold 1.5:1):
  available to borrow / reserved: 1.21:1
  available to borrow / return overdue: 1.10:1
  available to borrow / lost: 1.21:1
  available to borrow / new record: 1.12:1
  reserved / return overdue: 1.33:1
  reserved / lost: 1.46:1
  reserved / new record: 1.36:1
  return overdue / lost: 1.10:1
  return overdue / new record: 1.02:1
  lost / new record: 1.08:1

badge               text  icon  second channel
available to borrow  yes  yes  text + icon
reserved             yes  yes  text + icon
return overdue       yes  yes  text + icon
lost                 yes    —  text
new record             —    —  NONE — status is color-only

shown    accessible name              number  context  sufficient
3        3                                yes      no  NO
3        Notifications                     no     yes  NO
3        Notifications, 3 unread          yes     yes  yes
99+      Notifications, 128 unread        yes     yes  yes
```

The first table runs the expected check and finds a defect: the orange badge's ratio
against white text is 4.24:1, below the text threshold. The same color meets the non-text
threshold (3:1). This is a trap that shows up often in a color system — orange sits at a
luminance between red and green and is not enough to carry white text. The fix is to
darken the color or to write the text in a dark shade.

The second table is the lesson's main finding. **All ten pairs among the five badge
colors are indistinguishable from each other without color information.** The worst
pair, at 1.02:1, is "return overdue" and "new record"; red and blue fall to the same shade
of gray in the colorless channel because their relative luminances are nearly equal. Even
the best pair stays at 1.46:1 and does not clear the chosen 1.5:1 threshold.

This result is not a design mistake. The color palette was chosen by role, as in the
Semantic Colors lesson, and each color meets the thresholds against its own surface. What
is proven is something more fundamental: **status colors cannot be chosen so that they
are distinguishable from each other.** Separating all five roles with enough of a
luminance gap between them would push the colors to extremes too light and too dark to
use. This is why the 1.4.1 criterion does not forbid using color as an information
carrier; it forbids using it as the **only** carrier.

The third table checks the second channel. Three of the five badges carry both text and
an icon, one carries text only, one carries neither. The "new record" badge is a colored
dot alone, and its information exists in no channel besides color — the check catches
this directly. The "lost" badge, carrying text only, is sufficient; an icon is not
required, **one** second channel is enough.

The last table tests the number badge's name. The name "3" carries the number but does
not say three of what; the name "Notifications" carries context but drops the number. A
name that combines the two passes both checks. The fourth row shows the abbreviation
rule: while the screen reads "99+", the name holds the real number.

#### Measurable Constraints

**1.4.1 Use of Color.** Color cannot be the only visual means of conveying information.
The check's counterpart is the second-channel table above.

**1.4.3 Contrast (Minimum).** Badge text meets 4.5:1 against its background; 3:1 at sizes
counted as large text. Because badge text is small, 4.5:1 is what is required in
practice.

**1.4.11 Non-Text Contrast.** The badge's background must meet 3:1 against the surface it
sits on; if the badge's edge is indistinct, the badge itself cannot be seen.

**2.5.8 Target Size (Minimum).** A tag's removal button is expanded to a 24 x 24 pixel area. Once the
button meets that size the criterion is satisfied; the spacing condition in the criterion is an option
only for targets that stay below 24 x 24 pixels.

**1.4.4 Resize Text.** When badge text doubles in size, the badge must grow, not clip the
text. A fixed-width, circular number badge cuts off the number at this threshold.

**4.1.3 Status Messages.** A change in a badge's number is a status message and is
announced from a polite region, not from the badge itself.

#### Common Mistakes and How to Recognize Them

**The badge is read as a separate node.** The "3" next to the button is announced as a
number without context. How to recognize it: check whether the number is present in the
button's name field.

**Status is shown as a colored dot alone.** How to recognize it: imagining the screen
without color is not enough, it has to be measured — compute the pairwise contrast ratios
of the status colors and count the pairs below 1.5:1.

**The tag removal button's name is "remove".** The same name appears on all five tags,
and which tag will be removed is unclear. How to recognize it: count how many times the
same name repeats on the links-and-buttons plane; this is the button counterpart of the
"same text, different target" defect from the Screen Reader Experience lesson.

**The number badge stays even at zero.** An empty badge keeps information that does not
exist on screen. How to recognize it: check whether the badge is removed when the number
resets to zero.

#### Summary

- A badge describes the element it is attached to and is not interactive; a tag is an
  object on its own, and its removal button is a full button.
- A number badge's information is written into the **name** of the control it is
  attached to; the visual badge is hidden from the tree, and the on-screen abbreviation
  is spelled out in full in the name.
- Badge text must meet 4.5:1, and the badge's background must meet 3:1 against the
  surface; the orange background missed the text threshold at 4.24:1 in the measurement.
- Status colors cannot be chosen to be distinguishable from each other: all ten pairs
  among the five colors fall below the 1.5:1 threshold in the colorless channel, and the
  closest pair is 1.02:1.
- This is why every status badge needs a second channel — text or icon; having one is
  enough, having both is not required.
- Where focus goes when a tag is removed, and how the result count is announced, are
  part of the specification.

#### Next Step

In every one of the seven patterns in this topic, the final decision came down to text. A
notification's duration was computed from the text's word count, an error message's
sufficiency was tied to whether it wrote out a recovery path, and a badge's second channel
was most often a single word. Text is not the last field of a component's specification;
it is the material carrying the specification itself.

The next topic turns this material into a system. It first ties interface text to
checkable rules — tone of voice, length, and terminology consistency become measurable.
It then takes up error message writing, localization constraints, right-to-left layout,
and regional field requirements. The first lesson begins by counting how many separate
words the interface uses for the same concept.

## Text and Localization

Turning interface text into an auditable system, error message writing, localization constraints, right-to-left layout, and regional field requirements.

### Interface Text Guide

Source: https://academia.sh/en/courses/accessible-patterns/interface-text-guide

Turning an interface text guide into rules an auditor can check; a term glossary, tone rules, a per-class length limit, and the criterion that visible text be contained in the accessible name.

Every one of the seven patterns in the previous topic came down to text. A banner's
duration was computed from its message's word count, an error message's adequacy hinged
on whether it named a recovery path, and a badge's second channel was almost always a
single word.

The Micro-States lesson measured interface text as a **layout constraint**: a label's
width, the overlap truncation produces, an error message's line count. This lesson
treats the same text as a **system**: how does an interface text guide stop being a list
of personal style preferences and turn into rules an auditor can check?

#### What the Guide Solves and Where It Fails

An interface text guide keeps different people working on the same interface choosing
the same words. The problem it solves is inconsistency: when one screen reads "borrow,"
another "check out," and a third "sign out," the user assumes three separate actions.

The guide fails in three situations:

- **No example.** "Write short and clear" is not a rule; it cannot be tested.
- **No counter-example.** When the wrong thing is never written down, the rule leaves
  room for interpretation.
- **Nothing audits it.** A guide read by hand stops being enforced as the catalog grows.

All three gaps point to the same fix: rules must be written so a **machine can test
them**. The guide's body can stay prose, but every rule needs an audit counterpart.

#### Four Rule Families

**Term glossary.** One word is chosen per concept; its synonyms are forbidden. The
glossary is not a dictionary but a **decision record**, and the reason is written down
too. If "record" is chosen, "material" and "item" are forbidden.

**Tone.** Three rules are enough: action labels are a short imperative, system messages
never blame the user, and capitals and exclamation marks are never used for emphasis.
The no-blame rule is about accuracy, not politeness — "you entered it wrong" gives the
user no information, because it never names the shape the field expected.

**Length.** The limit changes with the text class: an action label is at most three
words, an empty-state title six, help text fourteen, a message twenty. The numbers are
arbitrary, chosen for that interface; what matters is that they can be audited.

**Visible text versus name.** The **2.5.3 Label in Name** criterion requires the visible
label to be contained in the accessible name. This looks like copy-editing, but it is a
direct text decision: writing "Apply filter" on screen while the name stays "Apply"
removes the criterion.

#### Auditing the Guide

The script below audits a fourteen-entry interface text catalog against the four rule
families, then counts how many different words are used for each concept.

```js
// text-audit.mjs — checking the interface text guide's four rules against the catalog

// Interface text catalog: each entry's class, visible text, and (for the checks) accessible name.
const CATALOG = [
  { key: "button.borrow",   class: "action",  text: "Borrow",                             name: "Borrow" },
  { key: "button.reserve",  class: "action",  text: "Reserve",                            name: "Reserve" },
  { key: "button.checkout", class: "action",  text: "Check out",                          name: "Check out" },
  { key: "button.filter",   class: "action",  text: "Refine results",                     name: "Apply" },
  { key: "button.clear",    class: "action",  text: "Clear filters",                      name: "Clear filters" },
  { key: "button.export",   class: "action",  text: "Export the selected records",        name: "Export the selected records" },
  { key: "title.empty",     class: "title",   text: "No records match the filters",       name: null },
  { key: "title.landing",   class: "title",   text: "SEARCH CATALOG RECORDS",             name: null },
  { key: "error.isbn",      class: "message", text: "ISBN entered wrong!",                name: null },
  { key: "error.date",      class: "message", text: "The return date must be after today. Pick a day from the calendar.", name: null },
  { key: "help.member",     class: "help",    text: "Your member number is on your card's back, below the barcode; enter without spaces.", name: null },
  { key: "help.material",   class: "help",    text: "You can narrow the results by selecting a different material type.", name: null },
  { key: "status.result",   class: "message", text: "128 records listed",                 name: null },
  { key: "button.remove",   class: "action",  text: "Remove",                             name: "Remove type tag" },
];

// --- Rule 1: term glossary --------------------------------------------------
// One word per concept; the others go unused throughout the course.
const TERM = {
  "borrow":  ["check out", "rent", "sign out"],
  "record":  ["material", "item", "product"],
  "filter":  ["refine"],
  "member":  ["customer", "subscriber"],
};

// --- Rule 2: tone ------------------------------------------------------------
const BLAMING = ["entered wrong", "entered incorrectly", "you forgot", "you must", "did not enter"];
const ABBREVIATION = new Set(["ISBN", "QR"]);

// --- Rule 3: length ----------------------------------------------------------
const LIMIT = { action: 3, title: 6, message: 20, help: 14 };

const words = (m) => m.trim().split(/\s+/);
const lower = (m) => m.toLocaleLowerCase("en");
// A plain substring search finds "rent" inside "different" and produces a false
// positive: matching must anchor at the start of a word and leave the end open.
const matches = (text, word) =>
  new RegExp(`(^|[^\\p{L}])${lower(word)}`, "u").test(lower(text));

const findings = [];
for (const c of CATALOG) {
  // 1. term
  for (const [correct, forbidden] of Object.entries(TERM))
    for (const f of forbidden)
      if (matches(c.text, f))
        findings.push({ key: c.key, rule: "term", detail: `"${f}" instead of "${correct}"` });

  // 2. tone
  if (c.text.includes("!"))
    findings.push({ key: c.key, rule: "tone", detail: "exclamation mark" });
  for (const b of BLAMING)
    if (matches(c.text, b))
      findings.push({ key: c.key, rule: "tone", detail: `blaming pattern: "${b}"` });
  for (const w of words(c.text)) {
    const plain = w.replace(/[^\p{L}]/gu, "");
    if (plain.length >= 4 && plain === plain.toLocaleUpperCase("en") && !ABBREVIATION.has(plain))
      findings.push({ key: c.key, rule: "tone", detail: `all caps: "${plain}"` });
  }

  // 3. length
  const n = words(c.text).length;
  if (n > LIMIT[c.class])
    findings.push({ key: c.key, rule: "length", detail: `${n} words, limit ${LIMIT[c.class]}` });

  // 4. 2.5.3 — the visible text must be contained in the name
  if (c.name !== null && !lower(c.name).includes(lower(c.text)))
    findings.push({ key: c.key, rule: "2.5.3", detail: `visible "${c.text}", name "${c.name}"` });
}

console.log(`catalog: ${CATALOG.length} entries, findings: ${findings.length}\n`);
console.log("key                 rule      detail");
for (const f of findings)
  console.log(`${f.key.padEnd(20)}${f.rule.padEnd(10)}${f.detail}`);

const tally = {};
for (const f of findings) tally[f.rule] = (tally[f.rule] ?? 0) + 1;
console.log("\nby rule: " + Object.entries(tally).map(([k, v]) => `${k}=${v}`).join(", "));

// --- How many different words name the same concept -------------------------
console.log("\nconcept        words used");
for (const [correct, forbidden] of Object.entries(TERM)) {
  const seen = new Set();
  for (const c of CATALOG) {
    if (matches(c.text, correct.split(" ")[0])) seen.add(correct);
    for (const f of forbidden) if (matches(c.text, f)) seen.add(f);
  }
  console.log(`${correct.padEnd(14)}${seen.size ? [...seen].join(", ") : "—"}   (${seen.size} form${seen.size === 1 ? "" : "s"})`);
}
```

```
catalog: 14 entries, findings: 10

key                 rule      detail
button.checkout     term      "check out" instead of "borrow"
button.filter       term      "refine" instead of "filter"
button.filter       2.5.3     visible "Refine results", name "Apply"
button.export       length    4 words, limit 3
title.landing       tone      all caps: "SEARCH"
title.landing       tone      all caps: "CATALOG"
title.landing       tone      all caps: "RECORDS"
error.isbn          tone      exclamation mark
error.isbn          tone      blaming pattern: "entered wrong"
help.material       term      "material" instead of "record"

by rule: term=3, 2.5.3=1, length=1, tone=5

concept        words used
borrow        borrow, check out   (2 forms)
record        record, material   (2 forms)
filter        refine, filter   (2 forms)
member        member   (1 form)
```

Ten findings come out of a small fourteen-entry catalog, and they fall into four kinds,
each with a different cost.

**Term drift shows up in three entries.** The last table gives its total effect: two of
the four concepts are named with two different words. For the user, that makes two
concepts look like four. It is also a translation cost: every synonym produces a
separate key and a separate decision in the translation catalog.

**All caps produces three findings** from a single title. Capitals are not a style
choice: letter outlines converge, so reading slows down, and some screen readers treat
an all-caps word as an abbreviation and spell it out letter by letter. Emphasis belongs
to formatting decisions — weight, size, position.

**The blaming pattern and the exclamation mark land on the same entry.** "ISBN entered
wrong!" does not say what was wrong, does not say what to do, and assigns the fault to
the user. The same catalog's `error.date` entry is the counter-example: "The return date
must be after today. Pick a day from the calendar." It gives the rule and the fix
together, and trips no check.

**The length limit is exceeded by exactly one entry.** "Export the selected records" is
four words, over the action limit; the width calculation in the Micro-States lesson
shows where that limit comes from.

**The 2.5.3 finding is the quietest defect.** The button reading "Refine results" on
screen has the name "Apply"; nothing looks wrong visually. Someone using voice control
who says the word on screen never invokes the button. That the audit catches this is the
direct payoff of writing the rule so a machine can test it.

The script's `matches` function also avoids a general substring trap: a plain search
finds "rent" inside "different" and would misfire. Matching instead anchors at the start
of a word and leaves the end open — so "material" and "materials" are both caught, while
"different" is not.

#### Decisions That Belong in the Guide

The audit enforces the rule but does not choose it. The guide's prose section is where
these decisions, and their reasons, get written down.

**Who is being addressed.** Interface text is written in the user's language, not the
system's: "Create record" is the database's business; "Add book" is the user's.

**Who is the subject.** In system messages the subject is the system: "The request could
not be sent." In text describing the user's own action, the subject is the user: "Items
you have borrowed." Mixing the two misplaces responsibility.

**How numbers and dates are written.** This decision belongs to the next lessons; the
guide states it in a single line — never hand-formatted.

**When abbreviations are allowed.** Only if already in the user's vocabulary;
interface-internal abbreviations are forbidden.

#### Common Mistakes and How to Spot Them

**A guide exists, but nothing audits it.** How to spot it: scan the catalog against the
forbidden-word list; a finding means the guide is not being followed.

**The same key holds different text in two places.** How to spot it: count how many
words are used per concept; any count above one is a decision.

**Visible text is not contained in the name.** How to spot it: run the audit's fourth
rule; a visual review cannot catch this.

**Text is embedded in code.** With no catalog to audit, no rule can be enforced. How to
spot it: check whether text is separated from code; the translation catalog set up in
the Application Architecture: Routing, State and Data course is this audit's
prerequisite.

#### Summary

- An interface text guide only holds if every rule has a counterpart a machine can
  test; rules with no example and no counter-example leave room for interpretation.
- Four rule families are enough: a term glossary, tone, a per-class length limit, and
  the requirement that visible text be contained in the accessible name.
- Term drift is not just inconsistency; it multiplies keys in the translation catalog —
  in a fourteen-entry catalog, two of four concepts had two different names.
- All caps and exclamation marks are not emphasis tools; emphasis is carried by weight,
  size, and position.
- The 2.5.3 criterion cannot be caught by visual review: the label shown on screen must
  be contained in the accessible name.
- Word checks skip plain substring search; matching anchors at the start of the word and
  leaves the end open, catching inflected forms without false positives from
  coincidental substrings.

#### Next Step

The heaviest finding the audit caught was an error message, and its counter-example sat
in the same catalog. The difference between the two messages is not style but
**structure**: one says what is wrong, the other says what is right and how to fix it.
The next lesson classifies error messages by their source, determines which parts are
required for each class, and builds a rule set that audits messages against those parts.

### Error Message Writing

Source: https://academia.sh/en/courses/accessible-patterns/error-message-writing

Classifying error messages by their source, each class's required parts, catching messages whose parts are complete but empty of content with a language audit, and deriving a message's placement and lifetime from its class.

The previous lesson's audit found its heaviest finding in an error message: "ISBN
entered wrong!" Its counter-example sat in the same catalog, and the difference was not
style but **structure**. The first says what was wrong; the second says what is right
and what the user should do.

This lesson treats an error message not as a writing problem but as a **structure with
defined parts**. The parts depend on the message's source, and until the source is
classified, no one can say which part is required.

#### Errors Are Classified by Source

Five classes cover every error in the catalog interface. An **input error** is a value
the user typed that fails a format or range rule; the user can fix it. An
**authorization error** is an action outside the user's rights; the user cannot fix it
but can follow a path. A **conflict** is an action blocked by some other state — the
volume is already with someone else. A **transient error** is the system failing to
respond at that moment. An **unrecoverable error** is one where nothing the user does
changes the outcome.

The class determines three things: which parts are required, where the message is
placed, and how long it stays. The computation below runs these rules against six
messages gathered from the catalog.

```js
// error.mjs — classifying error messages by source and auditing their required parts

// Required parts per class. "solution" is a step the user can take.
const CLASS = {
  input:         { parts: ["reason", "solution"],          fieldScoped: true,  persistent: false },
  authorization: { parts: ["reason", "solution"],          fieldScoped: false, persistent: true  },
  conflict:      { parts: ["reason", "solution", "status"], fieldScoped: false, persistent: true  },
  transient:     { parts: ["reason", "solution"],          fieldScoped: false, persistent: false },
  unrecoverable: { parts: ["reason", "id"],                fieldScoped: false, persistent: true  },
};

// Messages gathered from the catalog interface.
const MESSAGES = [
  { id: "H1", class: "input", field: "isbn", text: "ISBN entered wrong!",
    parts: ["reason"] },
  { id: "H2", class: "input", field: "isbn",
    text: "The ISBN must be 13 digits; it appears under the barcode on the book's back cover.",
    parts: ["reason", "solution"] },
  { id: "H3", class: "authorization", field: null,
    text: "You lack permission to borrow this record. Borrowing rights need a student card; ask at the library entrance desk.",
    parts: ["reason", "solution"] },
  { id: "H4", class: "conflict", field: null,
    text: "This copy was borrowed before you got to it. It is due back August 12; you can join the reservation list.",
    parts: ["reason", "solution", "status"] },
  { id: "H5", class: "transient", field: null, text: "An error occurred. Please try again.",
    parts: ["reason", "solution"] },
  { id: "H6", class: "unrecoverable", field: null,
    text: "The loan record could not be created. You can contact the desk with reference number REC-4471.",
    parts: ["reason", "id"] },
];

// 1. Part audit: does each message carry its class's required parts?
console.log("message  class          required parts          missing");
let missingTotal = 0;
for (const m of MESSAGES) {
  const required = CLASS[m.class].parts;
  const missing = required.filter((p) => !m.parts.includes(p));
  missingTotal += missing.length;
  console.log(
    `${m.id.padEnd(8)} ${m.class.padEnd(14)} ${required.join(", ").padEnd(23)} ${missing.length ? missing.join(", ") : "-"}`
  );
}
console.log(`missing parts total: ${missingTotal}`);

// 2. Language audit: blaming pattern, exclamation mark, apology, and technical leakage.
const RULES = [
  { name: "blaming pattern", test: (m) => /\b(you entered|entered wrong|entered incorrectly|you forgot|you must)\b/i.test(m) },
  { name: "exclamation mark", test: (m) => m.includes("!") },
  { name: "apology pattern", test: (m) => /(sorry|apologize|unfortunately)/i.test(m) },
  { name: "empty courtesy", test: (m) => /(please try again|an error occurred)/i.test(m) },
  { name: "technical leakage", test: (m) => /(null|undefined|exception|error code \d|HTTP \d{3})/i.test(m) },
];
console.log("\nmessage  findings");
let findingsTotal = 0;
for (const m of MESSAGES) {
  const findings = RULES.filter((r) => r.test(m.text)).map((r) => r.name);
  findingsTotal += findings.length;
  console.log(`${m.id.padEnd(8)} ${findings.length ? findings.join(", ") : "-"}`);
}
console.log(`language findings total: ${findingsTotal}`);

// 3. Presentation decision: where does the message go, and how long does it stay?
console.log("\nmessage  placement                  persistence");
for (const m of MESSAGES) {
  const c = CLASS[m.class];
  console.log(
    `${m.id.padEnd(8)} ${(c.fieldScoped ? "next to the field" : "next to the action / page top").padEnd(28)} ` +
      (c.persistent ? "until the user dismisses it" : "until the next attempt")
  );
}

// 4. Message length: a readability ceiling.
const LIMIT = 120;
console.log("\nmessage  characters  over limit");
for (const m of MESSAGES) {
  console.log(`${m.id.padEnd(8)} ${String(m.text.length).padStart(10)}  ${m.text.length > LIMIT ? "yes" : "no"}`);
}

// 5. Roll-up: which messages need to be rewritten?
const rewrite = MESSAGES.filter((m) => {
  const missing = CLASS[m.class].parts.filter((p) => !m.parts.includes(p)).length;
  const findings = RULES.filter((r) => r.test(m.text)).length;
  return missing > 0 || findings > 0;
});
console.log(`\nmessages needing a rewrite: ${rewrite.length} / ${MESSAGES.length}`);
for (const m of rewrite) console.log(`  ${m.id}: ${JSON.stringify(m.text)}`);
```

```
message  class          required parts          missing
H1       input          reason, solution        solution
H2       input          reason, solution        -
H3       authorization  reason, solution        -
H4       conflict       reason, solution, status -
H5       transient      reason, solution        -
H6       unrecoverable  reason, id              -
missing parts total: 1

message  findings
H1       blaming pattern, exclamation mark
H2       -
H3       -
H4       -
H5       empty courtesy
H6       -
language findings total: 3

message  placement                  persistence
H1       next to the field            until the next attempt
H2       next to the field            until the next attempt
H3       next to the action / page top until the user dismisses it
H4       next to the action / page top until the user dismisses it
H5       next to the action / page top until the next attempt
H6       next to the action / page top until the user dismisses it

message  characters  over limit
H1               19  no
H2               82  no
H3              114  no
H4              105  no
H5               36  no
H6               94  no

messages needing a rewrite: 2 / 6
  H1: "ISBN entered wrong!"
  H5: "An error occurred. Please try again."
```

#### Required Parts

The output's first section is the part audit. Every class requires a **reason**: the
user must know what happened. Input, authorization, conflict, and transient errors also
require a **solution** — a step the user can take. Conflict additionally needs a
**status**: when the copy comes back, or what the alternative is.

The unrecoverable error is the exception to this pattern. Offering a solution would be
misleading; instead an **id** is required, so the user has a reference number if they
end up talking to staff. This distinction was established in the Error and Warning
States lesson; here it is applied to the message text itself.

Only one message falls short of the part audit: "ISBN entered wrong!" gives the reason
but not the solution. Its counter-example gives both the rule and where it is written —
the digit count and the location under the barcode.

#### Parts Complete, Message Empty

The second section is a language audit, and it catches a subtler defect. The transient
error message passes the part audit: it has a reason, and it has a solution. But neither
carries content. "An error occurred" is not a reason, and "try again" is an
unconditional suggestion — the user does not know how long to wait or why trying again
would help.

The audit flags this as an empty courtesy pattern. The same section also catches the
blaming pattern and the exclamation mark: "entered wrong" pins the fault on the user, and
the exclamation mark raises the volume. The technical-leakage rule belongs here too;
showing the system's internal error code gives the user no information, only eroding
trust. A reference number is not leakage: it is an identifier generated for this purpose,
one the user can hand to staff.

#### Placement and Lifetime

The third section derives the presentation decision from the class. An input error sits
next to its field — which field is the problem can only be understood from position,
and this link is built into the markup the same way the Accessible Error Presentation
lesson describes. The other classes attach to the action or the page.

Lifetime also comes from the class. Input and transient errors clear on the next
attempt; the message loses its meaning once the user retries. Authorization, conflict,
and unrecoverable errors stay until the user dismisses them: retrying does not resolve
them, and the user may want to read the message, note it down, or pass it to someone
else.

The fourth section measures length. The limit is not a hard rule but a warning
threshold: once an error message reaches paragraph length, it is usually carrying two
pieces of information, and the second belongs in help text.

#### What the Audit Produces

The last section flags two messages for a rewrite. What matters is not the count but
**which rule** fired: one is missing a part, the other has a part with no content. The
two defects need different fixes — one needs information added, the other needs its
placeholder content replaced with real content.

This audit is part of the guide file and runs under version control: a new message gets
its class assigned, its parts checked, and its language rules run. As the rule set
grows, it also produces false alarms, and each one is a record for narrowing the rule.

#### Summary

- An error message is not a style problem; it is a structure whose parts are determined
  by its source. Reason is required in every class, solution in four classes, and in the
  unrecoverable class an id takes solution's place.
- The part audit catches missing information, the language audit catches empty
  information: "an error occurred, try again" passes the part audit but trips the empty
  courtesy pattern.
- Blaming suffixes, exclamation marks, apology patterns, and technical leakage are all
  auditable rules; a reference number is not leakage — it is an id the user can use.
- A message's placement and lifetime come from its class: input errors sit next to the
  field and clear on the next attempt; authorization and conflict errors stay until the
  user dismisses them.
- The audit says which rule fired; a missing part and an empty part call for different
  fixes.

#### Next Step

These two lessons treated interface text in a single language. Once the catalog opens
into another language, the text's **surroundings** change along with the text itself:
the same message runs about thirty percent longer in some languages and stops fitting
its box, date and number formats change, and plural rules need more than two forms. The
next lesson takes on localization requirements and measures all of it: how text growth
affects layout, how formatting is derived from the locale, and why concatenating strings
breaks translation.

### Localization Requirements

Source: https://academia.sh/en/courses/accessible-patterns/localization-requirements

Deriving formatting from the locale, plural form count varying by language, short strings growing proportionally more, string concatenation baking in word order, and measuring translation coverage.

The previous two lessons treated interface text in a single language: the guide's rules
and an error message's structure. Once the catalog opens into another language, the
text's surroundings change along with the text itself. The same button label can double
in some languages and stop fitting its box; date, number, and percent formats change;
and a sentence like "records found" does not keep the same word order across languages.

This lesson treats localization not as translation work but as **flexibility the
interface has to carry**. That flexibility has measures, and all of them can be
computed.

#### Formatting Is Not Part of the Text

Dates, numbers, percentages, and currency are not written into interface text; they are
derived from the locale. The computation below produces the same value's form across six
locales and counts their plural rules.

```js
// locale.mjs — text growth, formatting, and plural rules derived from the locale

const LOCALES = ["tr", "en", "de", "fr", "ru", "ja"];

// 1. Formatting: the same value, six locales.
const DATE = new Date(Date.UTC(2026, 7, 12, 9, 30));
const NUMBER = 1234567.89;
console.log("locale  date (long)             number            percent");
for (const locale of LOCALES) {
  const date = new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: "UTC" }).format(DATE);
  const number = new Intl.NumberFormat(locale).format(NUMBER);
  const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 }).format(0.427);
  console.log(`${locale.padEnd(7)} ${date.padEnd(24)} ${number.padEnd(17)} ${percent}`);
}

// 2. Plural rules: how many distinct forms are needed?
console.log("\nlocale  forms needed  1        2        5        11       0.5");
for (const locale of LOCALES) {
  const rules = new Intl.PluralRules(locale);
  const forms = new Set([0, 1, 2, 3, 5, 11, 21, 100].map((n) => rules.select(n)));
  const sample = [1, 2, 5, 11, 0.5].map((n) => rules.select(n).padEnd(8)).join(" ");
  console.log(`${locale.padEnd(7)} ${String(forms.size).padStart(13)}  ${sample}`);
}

// 3. List joining: why concatenating strings breaks.
console.log("\nlocale  list join (three items)");
for (const locale of LOCALES) {
  const list = new Intl.ListFormat(locale, { style: "long", type: "conjunction" })
    .format(["astronomy", "meteorology", "archiving"]);
  console.log(`${locale.padEnd(7)} ${list}`);
}

// 4. Text growth: short strings grow proportionally more.
// Instead of measuring, use a rule: expected growth factor by length band.
const GROWTH = [
  { min: 1,  max: 10,  factor: 2.0 },
  { min: 11, max: 20,  factor: 1.8 },
  { min: 21, max: 30,  factor: 1.6 },
  { min: 31, max: 50,  factor: 1.4 },
  { min: 51, max: 70,  factor: 1.3 },
  { min: 71, max: 999, factor: 1.15 },
];
const factorFor = (n) => GROWTH.find((g) => n >= g.min && n <= g.max).factor;

const INTERFACE = [
  { key: "button.borrow",  text: "Borrow",                                                  box: 120 },
  { key: "button.cancel",  text: "Cancel",                                                   box: 90 },
  { key: "label.shelf",    text: "Shelf code",                                               box: 110 },
  { key: "title.results",  text: "Search results",                                           box: 220 },
  { key: "help.isbn",      text: "ISBN must be 13 digits; it is printed under the barcode.",  box: 420 },
];
const CHAR_WIDTH = 8; // px, average for body text
console.log("\nkey             chars     factor  widest case (px)  box  fits");
for (const i of INTERFACE) {
  const factor = factorFor(i.text.length);
  const width = Math.ceil(i.text.length * factor * CHAR_WIDTH);
  console.log(
    `${i.key.padEnd(15)} ${String(i.text.length).padStart(8)} ${factor.toFixed(2).padStart(5)} ` +
      `${String(width).padStart(17)} ${String(i.box).padStart(5)}  ${width <= i.box ? "yes" : "NO"}`
  );
}

// 5. String concatenation trap: fragments versus a whole sentence.
const fragments = (n) => "Total " + n + " records found";        // fragments translated separately
const whole = (n, locale) =>
  new Intl.NumberFormat(locale).format(n) +
  ({ tr: " kayıt bulundu", en: " records found" }[locale] ?? " records found");
console.log("\nfragmented setup: " + fragments(1234567));
console.log("whole pattern (tr): " + whole(1234567, "tr"));
console.log("whole pattern (en): " + whole(1234567, "en"));
console.log("  -> the fragmented setup never formats the number and freezes word order.");

// 6. Translation coverage: missing-key audit.
const CATALOG = {
  tr: { "button.borrow": "Ödünç al", "button.cancel": "İptal", "label.shelf": "Raf kodu", "title.results": "Arama sonuçları", "help.isbn": "ISBN 13 hane olmalı." },
  en: { "button.borrow": "Borrow", "button.cancel": "Cancel", "label.shelf": "Shelf mark", "title.results": "Search results" },
  de: { "button.borrow": "Ausleihen", "button.cancel": "Abbrechen" },
};
const keys = Object.keys(CATALOG.tr);
console.log("\nlocale  keys  coverage  missing keys");
for (const [locale, table] of Object.entries(CATALOG)) {
  const missing = keys.filter((k) => !(k in table));
  console.log(
    `${locale.padEnd(6)} ${String(Object.keys(table).length).padStart(4)}  ` +
      `${String(Math.round((100 * (keys.length - missing.length)) / keys.length)).padStart(6)}%  ${missing.join(", ") || "-"}`
  );
}
```

```
locale  date (long)             number            percent
tr      12 Ağustos 2026          1.234.567,89      %42,7
en      August 12, 2026          1,234,567.89      42.7%
de      12. August 2026          1.234.567,89      42,7 %
fr      12 août 2026             1 234 567,89      42,7 %
ru      12 августа 2026 г.       1 234 567,89      42,7 %
ja      2026年8月12日               1,234,567.89      42.7%

locale  forms needed  1        2        5        11       0.5
tr                  2  one      other    other    other    other   
en                  2  one      other    other    other    other   
de                  2  one      other    other    other    other   
fr                  2  one      other    other    other    one     
ru                  3  one      few      many     many     other   
ja                  1  other    other    other    other    other   

locale  list join (three items)
tr      astronomy, meteorology ve archiving
en      astronomy, meteorology, and archiving
de      astronomy, meteorology und archiving
fr      astronomy, meteorology et archiving
ru      astronomy, meteorology и archiving
ja      astronomy、meteorology、archiving

key             chars     factor  widest case (px)  box  fits
button.borrow          6  2.00                96   120  yes
button.cancel          6  2.00                96    90  NO
label.shelf           10  2.00               160   110  NO
title.results         14  1.80               202   220  yes
help.isbn             56  1.30               583   420  NO

fragmented setup: Total 1234567 records found
whole pattern (tr): 1.234.567 kayıt bulundu
whole pattern (en): 1,234,567 records found
  -> the fragmented setup never formats the number and freezes word order.

locale  keys  coverage  missing keys
tr        5     100%  -
en        4      80%  help.isbn
de        2      40%  label.shelf, title.results, help.isbn
```

The first section's output gives one date and one number in six separate forms:
separators change, the month's position changes, the percent sign sits before the value
in some locales and after it in others, and even whether a space separates it is
locale-dependent. None of these differences is a designer's choice; every one belongs to
the locale.

The rule is: formatting happens at runtime. Writing "August 12, 2026" into the text
catalog freezes that string in one locale; the catalog should carry only the **pattern**,
and a formatter produces the value.

#### Plural Is Not Just Two Forms

The second section overturns a common assumption. Turkish, English, German, and French get by with two
plural forms, Russian needs three for whole numbers, and Japanese does not change form by number at all.
Decimals behave differently too: French treats 0.5 as singular, Turkish does not, and Russian brings in a
fourth form.

The consequence is direct: a setup with two branches — "1 record" and "N records" —
produces the wrong text in a language that needs three forms. The correct setup asks the
locale for the plural form and keeps separate text for each one. The same holds for list
joining: the third section's output shows that the comma and the conjunction change from
locale to locale, and Japanese uses no conjunction at all.

#### Text Growth Is a Layout Constraint

The fourth section measures growth. The rule is: short strings grow proportionally more.
An eight-character button label can double, while a fifty-character help text grows by
roughly forty percent. The reason is that short text usually cannot be rendered in a
single word in most languages.

The calculation shows that three of the five components do not fit their box at their
widest. This finding belongs to the design stage: giving boxes a fixed width ties the
interface to one language. The fix is boxes that grow with their padding, layout that
allows line wrapping, and — by the rule from the Layout and Spacing topic — deriving
width from content.

The measure itself is an estimate, and it should be labeled as one; real growth is
measured once translation exists. The estimate's job is to show **which components are
at risk** before translation arrives.

#### String Concatenation Breaks Translation

The fifth section compares a fragmented setup with a whole one. In the fragmented
version, the number is pasted in raw and the word order is baked into the code: `"Total
" + n + " records found"`. This loses two things at once — the number is never
formatted, and the translator cannot reorder the words.

The correct setup keeps a single pattern and places values into named slots. The
translator can then move the slots to fit their language's order, and the number is
formatted per locale. This is the interface-text side of the rule set up in the
Internationalization lesson.

#### Coverage Is Measured

The last section counts translation coverage. A missing key is not a defect but a
**state**: translation always lags behind code. What matters is that the gap is visible
and a fallback behavior is defined — which language's text shows for a missing key, and
how that state is marked in the interface.

Coverage measurement is also a release criterion: a language is not shown to users while
its coverage sits below some threshold. The threshold's number varies by product; the
existence of the measurement does not.

#### Summary

- Date, number, and percent formats are not part of the text; they are derived from the
  locale at runtime — the same value takes six different forms across six locales.
- Plural form count depends on the language: one locale in the model needs three forms,
  another makes no distinction at all; a two-branch setup produces wrong text in those
  languages.
- Short strings grow proportionally more; fixed-width boxes tie the interface to one
  language, and in the model three components do not fit their box at their widest.
- String concatenation bakes in word order and leaves the number unformatted; a pattern
  with named slots solves both problems at once.
- Translation coverage is measured, and a fallback behavior is defined for a missing key;
  coverage is a release criterion.

#### Next Step

Every example in this lesson used languages written left to right. Once the interface
opens into a right-to-left language, more than the text's direction changes: the layout
itself mirrors, icon direction can flip meaning, and numbers and embedded Latin-script
terms keep their own direction. The next lesson takes on direction-sensitive layout
decisions and shows, with real computation, why the difference between physical and
logical properties is an architectural decision rather than a translation problem.

### Right-to-Left Languages

Source: https://academia.sh/en/courses/accessible-patterns/right-to-left-languages

The style layer's counterpart to a direction change, logical properties taking precedence over physical ones, icon mirroring tied to a criterion, isolation in bidirectional text, and keyboard contracts written by flow direction.

Every example in the previous lesson used languages written left to right. When the
catalog opens into a right-to-left language, what changes is not just the text's
direction: a record card's border moves to the opposite side, the filter panel relocates
to the other edge of the screen, a forward arrow flips, and a clock icon stays exactly
as it was.

This lesson treats the direction change not as a translation problem but as an
**architectural decision**. The decision is made in the style layer, and its scale is
how many declarations are affected by the direction change.

#### Logical, Not Physical, Axes

Left and right are the screen's fixed directions. Start and end depend on **flow**: in a
left-to-right layout, start is on the left; in a right-to-left layout, it is on the
right. A component whose border is written as "left" stays on the left even after the
direction flips, and the layout breaks; the same border written as "inline start"
follows the flow.

The computation below lists declarations gathered from catalog components alongside
their physical and logical counterparts, and counts how many are direction-sensitive.

```js
// direction.mjs — direction-sensitive layout: logical properties, mirrored and unmirrored elements

// 1. Physical properties' logical counterparts.
const MAPPING = {
  "margin-left": "margin-inline-start",
  "margin-right": "margin-inline-end",
  "padding-left": "padding-inline-start",
  "padding-right": "padding-inline-end",
  "border-left": "border-inline-start",
  "text-align: left": "text-align: start",
  "left": "inset-inline-start",
  "width": "inline-size",
  "margin-top": "margin-block-start",
  "border-bottom": "border-block-end",
};
// Declarations gathered from catalog components.
const DECLARATIONS = [
  { component: "record-card", declaration: "padding-left" },
  { component: "record-card", declaration: "border-left" },
  { component: "record-card", declaration: "margin-top" },
  { component: "filter-panel", declaration: "width" },
  { component: "filter-panel", declaration: "text-align: left" },
  { component: "borrow-button", declaration: "margin-right" },
  { component: "notification-banner", declaration: "left" },
  { component: "notification-banner", declaration: "border-bottom" },
];
const horizontal = (d) => /left|right|width|inline/.test(d);
console.log("component            physical declaration  logical counterpart      direction-sensitive");
let sensitive = 0;
for (const d of DECLARATIONS) {
  if (horizontal(d.declaration)) sensitive++;
  console.log(
    `${d.component.padEnd(21)} ${d.declaration.padEnd(21)} ${MAPPING[d.declaration].padEnd(25)} ${horizontal(d.declaration) ? "yes" : "no"}`
  );
}
console.log(`direction-sensitive declarations: ${sensitive} / ${DECLARATIONS.length}`);
console.log("declarations on the vertical axis are unaffected by direction change; writing");
console.log("them logically still pays off the same way in vertical writing modes.");

// 2. Mirrored and unmirrored elements.
const ELEMENTS = [
  { name: "back arrow", mirrors: true, reason: "direction follows the flow" },
  { name: "forward arrow", mirrors: true, reason: "direction follows the flow" },
  { name: "breadcrumb separator", mirrors: true, reason: "shows hierarchy direction" },
  { name: "progress bar", mirrors: true, reason: "increase direction follows the flow" },
  { name: "clock icon", mirrors: false, reason: "direction is fixed in the real world" },
  { name: "checkmark", mirrors: false, reason: "direction carries no meaning" },
  { name: "search magnifier", mirrors: false, reason: "the object's own shape" },
  { name: "volume icon", mirrors: true, reason: "increase direction follows the flow" },
  { name: "pencil/edit", mirrors: false, reason: "the object's own shape" },
];
console.log("\nelement                mirrors  reason");
for (const e of ELEMENTS) console.log(`  ${e.name.padEnd(21)} ${(e.mirrors ? "yes" : "no").padEnd(8)} ${e.reason}`);
console.log(`mirrored: ${ELEMENTS.filter((e) => e.mirrors).length} / ${ELEMENTS.length}`);

// 3. Numbers and embedded Latin terms keep their own direction.
// Bidirectional text: the paragraph's base direction is right-to-left, the number inside it is left-to-right.
const SEGMENTS = [
  { text: "shelf code", direction: "rtl" },
  { text: "QB-141", direction: "ltr" },
  { text: "year", direction: "rtl" },
  { text: "1998", direction: "ltr" },
];
console.log("\nparagraph base direction: rtl");
console.log("segment      direction  isolation needed");
for (const s of SEGMENTS) {
  console.log(`  ${s.text.padEnd(11)} ${s.direction.padEnd(10)} ${s.direction === "ltr" ? "yes" : "no"}`);
}
console.log("without isolation, the punctuation next to a left-to-right segment drifts to");
console.log("the wrong side; the embedded segment is isolated for this reason.");

// 4. A string's logical order versus its visual order: isolated and not.
// Simple model: in an rtl paragraph, segments reverse order; an ltr segment stays flat inside itself.
function visualOrder(segments, base) {
  const list = base === "rtl" ? [...segments].reverse() : segments;
  return list.map((s) => s.text).join(" ");
}
console.log("\nlogical order : " + SEGMENTS.map((s) => s.text).join(" "));
console.log("visual order  : " + visualOrder(SEGMENTS, "rtl"));

// 5. Scrolling and the keyboard: arrow keys keep their physical meaning.
const KEYS = [
  { key: "Right arrow", ltr: "next item", rtl: "previous item" },
  { key: "Left arrow", ltr: "previous item", rtl: "next item" },
  { key: "Home", ltr: "first item", rtl: "first item" },
  { key: "End", ltr: "last item", rtl: "last item" },
];
console.log("\nkey           left-to-right       right-to-left");
for (const k of KEYS) console.log(`  ${k.key.padEnd(13)} ${k.ltr.padEnd(19)} ${k.rtl}`);
console.log("arrow keys follow physical direction: the right arrow always moves right");
console.log("on screen, so in a right-to-left layout it reaches the previous item.");

// 6. Audit: components with a remaining physical declaration cannot ship.
const remaining = DECLARATIONS.filter((d) => horizontal(d.declaration));
const components = [...new Set(remaining.map((d) => d.component))];
console.log(`\ncomponents with a remaining physical declaration: ${components.length}`);
for (const c of components) {
  const count = remaining.filter((x) => x.component === c).length;
  console.log(`  ${c.padEnd(21)} ${count} declaration${count === 1 ? "" : "s"}`);
}
```

```
component            physical declaration  logical counterpart      direction-sensitive
record-card           padding-left          padding-inline-start      yes
record-card           border-left           border-inline-start       yes
record-card           margin-top            margin-block-start        no
filter-panel          width                 inline-size               yes
filter-panel          text-align: left      text-align: start         yes
borrow-button         margin-right          margin-inline-end         yes
notification-banner   left                  inset-inline-start        yes
notification-banner   border-bottom         border-block-end          no
direction-sensitive declarations: 6 / 8
declarations on the vertical axis are unaffected by direction change; writing
them logically still pays off the same way in vertical writing modes.

element                mirrors  reason
  back arrow            yes      direction follows the flow
  forward arrow         yes      direction follows the flow
  breadcrumb separator  yes      shows hierarchy direction
  progress bar          yes      increase direction follows the flow
  clock icon            no       direction is fixed in the real world
  checkmark             no       direction carries no meaning
  search magnifier      no       the object's own shape
  volume icon           yes      increase direction follows the flow
  pencil/edit           no       the object's own shape
mirrored: 5 / 9

paragraph base direction: rtl
segment      direction  isolation needed
  shelf code  rtl        no
  QB-141      ltr        yes
  year        rtl        no
  1998        ltr        yes
without isolation, the punctuation next to a left-to-right segment drifts to
the wrong side; the embedded segment is isolated for this reason.

logical order : shelf code QB-141 year 1998
visual order  : 1998 year QB-141 shelf code

key           left-to-right       right-to-left
  Right arrow   next item           previous item
  Left arrow    previous item       next item
  Home          first item          first item
  End           last item           last item
arrow keys follow physical direction: the right arrow always moves right
on screen, so in a right-to-left layout it reaches the previous item.

components with a remaining physical declaration: 4
  record-card           2 declarations
  filter-panel          2 declarations
  borrow-button         1 declaration
  notification-banner   1 declaration
```

The first section's output shows that six of eight declarations are direction-sensitive.
The two declarations on the vertical axis are unaffected by the direction change, but
writing them logically still pays off the same way in vertical writing modes. The rule
is simple: every declaration that touches the horizontal axis is written logically, and
this is a decision made before translation begins.

The last section turns the same data into an audit finding: four components still carry
a physical declaration. This is the work item that has to close before a language ships,
and it is automated the same way as the audits in the Tokens topic.

#### Not Every Icon Mirrors

The second section separates the icons. The mirroring criterion is a single question:
does the icon's direction depend on **flow**, or is it the object's own shape? A forward
arrow depends on flow and mirrors; a breadcrumb separator shows hierarchy direction and
mirrors; a progress bar takes its increase direction from the flow and mirrors.

A clock icon does not mirror, because a clock's direction is fixed in the real world. A
checkmark does not mirror, because its direction carries no meaning. A magnifier and a
pencil do not mirror, because they are the object's own shape. Five of nine icons mirror;
the distinction is made by what the icon represents, not by a blanket transform. A rule
that mirrors every icon flips the clock.

#### Numbers and Embedded Terms Keep Their Own Direction

The third and fourth sections take on bidirectional text. Inside a right-to-left
paragraph, numbers and Latin-script terms — a shelf code, an ISBN, a unit of measure —
are written in their own direction. When the paragraph's base direction and a segment's
direction differ, which side the surrounding punctuation falls on becomes ambiguous.

The fix is to isolate the embedded segment: it declares its own direction and does not
affect its neighbors'. The fourth section's comparison shows that logical order and
visual order are not the same — the text's order in memory differs from its order on
screen, and that difference also shows up in copying, search, and sorting.

#### The Keyboard Follows Physical Direction

The fifth section writes down a rule that is often overlooked. Arrow keys follow
physical direction: the right arrow always moves right on screen. In a right-to-left
list, moving right on screen means reaching the list's **previous** item.

This means the keyboard contracts from the Tabs and Menus lessons have to be rewritten
for right-to-left layout: "the right arrow moves to the next item" is a direction-bound
sentence. The correct wording is flow-based — "the arrow in the flow direction moves to
the next item." Home and End are unaffected by the direction change, because the first
and last items are defined by the flow.

#### Measurable Constraints

- Every declaration that touches the horizontal axis is written with logical
  properties; the audit reports components with a remaining physical declaration.
- Icon mirroring is decided one at a time; mirroring everything breaks a clock, a
  checkmark, and shape-based icons.
- Embedded reversed-direction segments are isolated; unisolated concatenation leaves
  punctuation position ambiguous.
- Arrow-key contracts are written by flow direction rather than physical direction.
- The text-growth rule from the previous lesson still applies in right-to-left
  languages; the two constraints are audited together.

#### Common Mistakes

The most common mistake is treating direction support as a translation task and never
touching the style layer — the text gets translated but the layout stays left to right,
and the interface becomes unreadable. The second is mirroring every icon in bulk. The
third is declaring the direction change with a single root attribute and leaving
components' physical declarations untouched: a root declaration alone is not enough
without fixing declarations at the component level.

#### Summary

- Left and right are the screen's fixed directions; start and end depend on flow. Every
  declaration on the horizontal axis is written logically — six of the eight
  declarations in the model are direction-sensitive.
- Icon mirroring is tied to a criterion: an icon whose direction follows the flow
  mirrors, one that is the object's own shape does not; five of nine icons mirror.
- In a right-to-left paragraph, numbers and Latin-script terms keep their own direction
  and are isolated; the text's logical order and its visual order are not the same.
- Arrow keys follow physical direction, so keyboard contracts are written by flow
  direction.
- The audit lists components carrying a physical declaration; this list closes before a
  language ships.

#### Next Step

Direction and formatting decisions adapted the interface's container to the language.
What is left is the **data** that goes inside the container. Every form that asks a
user for a name, an address, or an identity number carries an assumption: that a name
has two parts, that an address is completed by a postal code, that an identity number
has a fixed length. Every one of these assumptions breaks somewhere. The course's last
lesson takes on these fields and turns the assumptions into rules.

### Regional Requirements

Source: https://academia.sh/en/courses/accessible-patterns/regional-requirements

Counting the assumptions baked into name, address, and identity fields, a flexible field setup that accepts every record, requirements read from a country table, and storing identity fields as text.

Direction and formatting decisions adapted the interface's container to the language.
What is left is the data that goes inside the container. A library membership form asks
for three things: a name, an address, and an identity number. Each of these three fields
carries an assumption — that a name has two parts, that an address is completed by a
postal code, that an identity number has a fixed length.

The assumptions stay invisible in most records, because most records fit them. A record
that does not fit cannot get through the form: the user cannot squeeze their name into
one field, cannot leave a required postal code blank, and their identity number is
rejected. This lesson counts the assumptions and turns them into rules.

#### Measuring the Assumption

The computation below runs a strict form — a two-part name field, a required postal code
and region, a fixed-length identity number — against sample records and counts how many
fail.

```js
// region.mjs — turning name, address, and identity field assumptions into rules

// 1. Name field: a strict form assumes two fields (given name + family name, both required).
const STRICT_NAME = { fields: ["given", "family"], required: ["given", "family"], exact: 2 };
// Records: real-world name structures encountered in practice (real examples, chosen for structural variety).
const NAMES = [
  { record: "A1", parts: ["Elif", "Yılmaz"], note: "two parts" },
  { record: "A2", parts: ["Ravi"], note: "single-part name" },
  { record: "A3", parts: ["María", "del", "Carmen", "Ruiz", "García"], note: "two surnames, multiple given names" },
  { record: "A4", parts: ["Nguyễn", "Thị", "Hoa"], note: "family name first" },
  { record: "A5", parts: ["Björn", "Þórsson"], note: "surname derives from the father's name" },
  { record: "A6", parts: ["Jean", "-", "Pierre", "Le", "Roux"], note: "hyphenated name, multi-word surname" },
];
console.log("record  parts  fits strict form  loss");
let overflow = 0;
for (const n of NAMES) {
  const realParts = n.parts.filter((p) => p !== "-").length;
  const fits = realParts === STRICT_NAME.exact;
  if (!fits) overflow++;
  const loss = fits ? "-" : realParts < 2 ? "required field cannot be left blank" : "extra parts get crammed into one field";
  console.log(`${n.record.padEnd(6)} ${String(realParts).padStart(5)}  ${(fits ? "yes" : "NO").padStart(16)}  ${loss}`);
}
console.log(`does not fit the strict form: ${overflow} / ${NAMES.length}`);

// Flexible rule: one free field + an optional display name.
console.log("\nflexible rule: 'full name' one field (required) + 'display name' (optional)");
for (const n of NAMES) {
  const full = n.parts.join(" ").replace(" - ", "-");
  console.log(`  ${n.record}  full name: ${JSON.stringify(full).padEnd(34)} (${n.note})`);
}

// 2. Address field: a strict form assumes postal code and region/state are required.
const ADDRESSES = [
  { record: "B1", lines: 2, postalCode: "34710", region: "İstanbul", country: "TR" },
  { record: "B2", lines: 3, postalCode: null,    region: "Dublin",   country: "IE" },
  { record: "B3", lines: 1, postalCode: "1010",  region: null,       country: "AT" },
  { record: "B4", lines: 4, postalCode: "100-0001", region: "Tokyo", country: "JP" },
  { record: "B5", lines: 2, postalCode: "SW1A 1AA", region: null,    country: "GB" },
];
const STRICT_ADDRESS = { postalCodeRequired: true, regionRequired: true, lineLimit: 2 };
console.log("\nrecord  lines  postal code  region    rule dropped by the strict form");
let addressDropped = 0;
for (const a of ADDRESSES) {
  const dropped = [];
  if (STRICT_ADDRESS.postalCodeRequired && !a.postalCode) dropped.push("postal code required");
  if (STRICT_ADDRESS.regionRequired && !a.region) dropped.push("region required");
  if (a.lines > STRICT_ADDRESS.lineLimit) dropped.push(`line count ${a.lines} > ${STRICT_ADDRESS.lineLimit}`);
  if (dropped.length) addressDropped++;
  console.log(
    `${a.record.padEnd(6)} ${String(a.lines).padStart(5)}  ${String(a.postalCode ?? "-").padEnd(11)} ${String(a.region ?? "-").padEnd(9)} ${dropped.join("; ") || "-"}`
  );
}
console.log(`records dropped by the strict address form: ${addressDropped} / ${ADDRESSES.length}`);

// 3. Identity field: a length and format assumption.
const IDENTITIES = [
  { record: "C1", value: "12345678901", kind: "11 digits" },
  { record: "C2", value: "AB-9931",     kind: "letter + digit" },
  { record: "C3", value: "0012",        kind: "leading zero" },
  { record: "C4", value: "9987654321234", kind: "13 digits" },
];
const STRICT_IDENTITY = { length: 11, digitsOnly: true };
console.log("\nrecord  value           matches strict rule  reason");
for (const c of IDENTITIES) {
  const lengthOk = c.value.length === STRICT_IDENTITY.length;
  const digitsOk = /^\d+$/.test(c.value);
  const matches = lengthOk && digitsOk;
  const reason = matches ? "-" : [!lengthOk && `length ${c.value.length}`, !digitsOk && "non-digit character"].filter(Boolean).join(", ");
  console.log(`${c.record.padEnd(6)} ${c.value.padEnd(15)} ${(matches ? "yes" : "NO").padStart(20)}  ${reason}`);
}
console.log("if the identity field is stored as a number, leading zeros are lost; the field is text.");

// 4. Result: comparing two form designs.
const recordTotal = NAMES.length + ADDRESSES.length;
const droppedTotal = overflow + addressDropped;
console.log(`\nstrict form: ${droppedTotal} / ${recordTotal} records cannot be entered directly (${Math.round((100 * droppedTotal) / recordTotal)}%)`);
console.log("flexible form: full name one field, address a multi-line free-text field + country,");
console.log("               postal code and region requirements toggle on and off by country.");

// 5. Country-dependent field rule: gathered into a single table.
const COUNTRY_RULE = {
  TR: { postalCode: "required", region: "required", postalCodeFormat: "5 digits" },
  IE: { postalCode: "optional", region: "required", postalCodeFormat: "free-form" },
  AT: { postalCode: "required", region: "optional", postalCodeFormat: "4 digits" },
  JP: { postalCode: "required", region: "required", postalCodeFormat: "3-4 digits" },
  GB: { postalCode: "required", region: "optional", postalCodeFormat: "free-form" },
};
console.log("\ncountry  postal code    region         format");
for (const [c, r] of Object.entries(COUNTRY_RULE)) {
  console.log(`  ${c}  ${r.postalCode.padEnd(14)} ${r.region.padEnd(14)} ${r.postalCodeFormat}`);
}
console.log("the rule table is data; it is not baked into the form, it is read by country selection.");
```

```
record  parts  fits strict form  loss
A1         2               yes  -
A2         1                NO  required field cannot be left blank
A3         5                NO  extra parts get crammed into one field
A4         3                NO  extra parts get crammed into one field
A5         2               yes  -
A6         4                NO  extra parts get crammed into one field
does not fit the strict form: 4 / 6

flexible rule: 'full name' one field (required) + 'display name' (optional)
  A1  full name: "Elif Yılmaz"                      (two parts)
  A2  full name: "Ravi"                             (single-part name)
  A3  full name: "María del Carmen Ruiz García"     (two surnames, multiple given names)
  A4  full name: "Nguyễn Thị Hoa"                   (family name first)
  A5  full name: "Björn Þórsson"                    (surname derives from the father's name)
  A6  full name: "Jean-Pierre Le Roux"              (hyphenated name, multi-word surname)

record  lines  postal code  region    rule dropped by the strict form
B1         2  34710       İstanbul  -
B2         3  -           Dublin    postal code required; line count 3 > 2
B3         1  1010        -         region required
B4         4  100-0001    Tokyo     line count 4 > 2
B5         2  SW1A 1AA    -         region required
records dropped by the strict address form: 4 / 5

record  value           matches strict rule  reason
C1     12345678901                      yes  -
C2     AB-9931                           NO  length 7, non-digit character
C3     0012                              NO  length 4
C4     9987654321234                     NO  length 13
if the identity field is stored as a number, leading zeros are lost; the field is text.

strict form: 8 / 11 records cannot be entered directly (73%)
flexible form: full name one field, address a multi-line free-text field + country,
               postal code and region requirements toggle on and off by country.

country  postal code    region         format
  TR  required       required       5 digits
  IE  optional       required       free-form
  AT  required       optional       4 digits
  JP  required       required       3-4 digits
  GB  required       optional       free-form
the rule table is data; it is not baked into the form, it is read by country selection.
```

#### A Name Is Not Two Parts

The first section's output shows that four of six name records do not fit the strict
form. The loss takes two shapes: a single-part name cannot fill the required second
field, and a name with more parts gets crammed into one field. Cramming does not look
like loss, but it corrupts the data: which part is the family name is gone.

The second section shows the flexible rule: a single required **full name** field, plus an optional
display name. This setup takes all six records as they are. The display name is asked separately, because
the name the interface uses in "Hello, X" cannot be derived from the full name — outside single-part
names, deriving it requires an assumption about order, and which records are single-part cannot be known
in advance.

The name field's second rule is sorting: which part a list view sorts by is an interface
decision and cannot be baked into the data. Sorting uses locale-aware comparison — the
same rule as the Tables lesson.

#### An Address's Required Fields Depend on the Country

The third section shows that four of five address records drop out of the strict form.
The reasons differ from record to record: some places have no postal code, some places
do not use a region or state field, and some addresses do not fit two lines.

The last table gives the fix: the requirement rule is not baked into the form; it comes
from a **table read by country**. Once the user picks a country, that table determines
which fields are required, what format the postal code takes, and what the field labels
say. The table is data; adding a new country adds a row, and the code does not change.

The address field's third rule is line count: a multi-line free-text field loses less
information than structured fields. Structure is asked for only when it is genuinely
needed — a shipping account, a tax record — and even then it varies by country.

#### The Identity Field Is Text

The fourth section shows that the identity number fails the strict rule in three
distinct ways: a different length, a non-digit character, and a leading zero. The last
is the most deceptive: when the field is stored as a number, leading zeros are lost and
the value is silently corrupted.

The rule is: an identity field is not a number but **text**. No arithmetic is performed
on it, its format varies by country, and validation is a pattern check. That validation
pattern is also read from the country table, the same as the address rules; a single
regular expression baked into the form breaks on the first foreign record.

#### What the Measurement Means

The fifth section gives the total: eight of eleven records fail the strict form. This
number is tied to the sample set and is not a statistic; its job is to show that **each**
assumption leaves out a class of record.

The flexible form takes all the same records and asks for two things in return: fields
have to stay free-form, and requirements have to be read from data. It has a cost too —
automatic parsing over free-text fields gets harder. The decision comes down to which
loss is acceptable: for a library membership, leaving a user out is a heavier loss than
failing to structure an address.

#### Measurable Constraints

- The name field is a single required free-text field; splitting it into parts happens
  only when genuinely necessary, and even then as optional.
- Address requirements are read from a country table; the table is not baked into code.
- Identity and postal code fields are stored as text; the leading zero is preserved.
- Field labels and validation messages also vary by country; the error-message rule
  follows the structure from the Error Message Writing lesson.
- The required-field marker and error notice are tied to the field the same way as in
  the Accessible Error Presentation lesson.

#### Common Mistakes

The most common mistake is splitting the name field into two required parts. The second
is making the postal code required for every country. The third is storing identity and
postal code fields as numbers. The fourth is writing these rules as conditional code
instead of a country table: every new country then requires a code change, and the rule
set eventually becomes unreadable.

#### Summary

- Name, address, and identity fields each carry an assumption; in the model, eight of
  eleven records fail the strict form.
- A single required full-name field takes every name structure; a display name is asked
  separately and optionally, because it cannot be derived from the full name.
- Address requirements and postal code format come from a table read by country; the
  table is data, not baked into the form.
- Identity and postal code are text: stored as numbers, the leading zero is lost.
- The flexible form's cost is that automatic parsing gets harder; the decision is made
  by which loss is acceptable.

#### Course Wrap-Up

This course treated components not by their appearance but by their **specification**.
The Basic Components topic built the button, the text input, the selection control, and
the dropdown list on the native-element-first rule, writing down each one's role, name,
state, and keyboard contract. The Container and Navigation Components topic specified
the card, the tab, the accordion, the menu, the location indicator, the table, and the
auto-rotating banner with the same discipline, and counted the cost of focus and tab
order in each. The Layer and Feedback Components topic treated components that sit above
the flow — the modal, the tooltip, the notification, the loading indicator, the
skeleton, the empty and error states, the badge — through focus transfer and
announcement policy. The Text and Localization topic then made interface text part of
the component: guide rules, an error message's structure, formatting derived from the
locale, direction-sensitive layout, and this lesson's field assumptions.

Three measures held constant through the course. ARIA is never written where a native
element exists. Every state is announced through at least two channels, not one. And
every decision ties to a measurable constraint — target size, contrast ratio, a time
limit, or a tab-stop count.

The M15 curriculum closes here. Fundamentals of Interface Design tied visual decisions
to perceptual principles and measurable thresholds. User Experience and Behavior Design
established how those decisions are derived from the user and where the ethical
boundary sits. Design Systems turned decisions into tokens, a catalog, and governance,
making them scale. Accessible Component Patterns then wrote every part of the system's
keyboard, focus, and screen reader behavior at the specification level.

This curriculum was built to be read alongside the implementation layer in the M14
Frontend Development curriculum: there, how a component **works**; here, how it
**should behave**. Where the two curricula meet is where an interface both works
correctly and behaves correctly.

