---
title: 'Frontend Quality'
source: 'https://academia.sh/en/courses/frontend-quality'
language: en
updated: '2026-08-17T14:25:35+00:00'
license: 'CC BY-SA 4.0'
lessons: 27
outcomes:
    - 'Defines a performance budget and verifies it by measurement'
    - "Applies accessibility criteria at the standard's level"
    - 'Reduces client-side security risks with layered defenses'
---

# Frontend Quality

The measurable quality axes of a published interface; budgeting performance with user-centric metrics, tying accessibility to standard criteria, client-side security defenses, and the testing and monitoring that verify behavior.

## Performance

Defining user-centric metrics, shortening the critical rendering path, image and font loading strategies, tuning the network layer with headers, long tasks at runtime, and tying it all to a budget that catches regressions.

### User-Centric Metrics

Source: https://academia.sh/en/courses/frontend-quality/user-centric-metrics

Measuring a published interface's speed by the events the user perceives; the definitions of loading, interactivity, and visual stability metrics, computing a layout shift's score, and summarizing a distribution with percentiles.

The North Slope Measurement Station application gets built, bundled, and published. The
build output ships to a hosting target, releases can be rolled back, and environment
variables stay separated. The deployment pipeline answered one question — does the code
reach the user — and the answer was yes.

The unanswered question is: how well does what arrives work for the user? This lesson
builds the measurable form of that question. The metrics tie to events the user
perceives; tying them to a budget is left to this topic's last lesson.

#### Technical Milestones Answer the Wrong Question

A page loading is a process with events defined for the browser: document parsing
finishes, subresources arrive, the load event fires. These events are measurable and
recordable. The problem is not measurability — it is what they represent.

Consider the station list page. The list arrives inside the HTML from the server and
appears immediately; a map component further down the page downloads a large script and
becomes ready two seconds later. The load event fires when the map is ready. The user,
meanwhile, saw the list in one second and has already tapped the station they were
looking for.

The reverse case exists too: the document finishes early, but nothing readable is on
screen because the content gets produced by script. The load event is early; what the
user sees is a blank screen.

In both cases the technical milestone is accurate and describes the experience wrong. A
metric's subject should not be the browser's internal flow — it should be what the user
sees on screen and does.

#### Three Questions, Five Metrics

User-centric metrics separate three questions.

**When did I see something?** This question has two measures. Time to first byte is the
time between sending the request and the response's first byte arriving; it is the first
segment of the timing that the Network Panel Diagnostics lesson breaks into stages. It
measures the server side and sets the ceiling for any optimization done on the client:
every later metric stacks on top of this time. First contentful paint is the moment any
content from the document — text, an image, a canvas drawing — first gets painted on
screen. It reports that the blank screen has ended, not that the page is ready.

**When did I see the real content?** Largest contentful paint is the moment the largest
content element in the viewport gets painted. The metric's idea is this: a page's feeling
of being "loaded" comes from the arrival of the element that occupies the most space. On
the station list page, this element is the list itself; on the measurement detail page,
it is the station photo. The metric keeps updating through loading; if a larger element
gets painted later, the candidate changes and the metric moves forward.

**When did I get a response to my touch?** Interaction to next paint is the time from a
user interaction — a click, a tap, a key — to the screen's next paint that reflects it.
What gets measured is not the handler's own duration but the user's entire perceived
delay: the time the input waits to be processed, the handler's duration, and producing
the frame. Many interactions happen over the course of a page; the metric gives a single
value representing the worst of them.

**Did the screen shift under my feet?** Cumulative layout shift is the sum of unexpected
displacements. It is the quantitative counterpart of the phenomenon introduced as content
shift in the Web Fundamentals and HTML course, and unlike the other metrics it gives not
a duration but a unitless score.

#### The Score for Visual Stability

A shift's score is the product of two fractions. The impact fraction is the ratio of the
union of the shifting element's visible areas across two consecutive frames to the
viewport area: how much of the screen got affected? The distance fraction is the ratio of
the largest distance the element traveled to the viewport's larger dimension: how far did
it go?

The following computation assumes the shifting elements span the full width of the
viewport; under this assumption the impact region reduces from a two-dimensional area to
a single vertical interval. For narrower elements, the same product scales by the width
ratio.

```js
// shift.mjs — a single layout shift's score and the session window total

const VIEWPORT = { width: 390, height: 844 }; // viewport size, CSS pixels

// Each shift: the unstable element's vertical range before and after the shift.
const shifts = [
  { name: "announcement banner opened", before: [96, 700], after: [196, 800] },
  { name: "station image settled", before: [200, 444], after: [224, 468] },
  { name: "measurement badge arrived", before: [512, 560], after: [520, 568] },
];

function shiftScore(shift) {
  const [beforeTop, beforeBottom] = shift.before;
  const [afterTop, afterBottom] = shift.after;

  // Impact region: the union of the visible areas across two frames.
  const unionTop = Math.max(0, Math.min(beforeTop, afterTop));
  const unionBottom = Math.min(VIEWPORT.height, Math.max(beforeBottom, afterBottom));
  const impactFraction = (unionBottom - unionTop) / VIEWPORT.height;

  // Distance fraction: the largest distance the element traveled / the viewport's larger dimension.
  const distance = Math.abs(afterTop - beforeTop);
  const distanceFraction = distance / Math.max(VIEWPORT.width, VIEWPORT.height);

  return { impactFraction, distanceFraction, distance, score: impactFraction * distanceFraction };
}

console.log("shift".padEnd(28) + "moved".padStart(8) + "impact".padStart(8) +
  "distance".padStart(10) + "score".padStart(8));
let total = 0;
for (const s of shifts) {
  const r = shiftScore(s);
  total += r.score;
  console.log(
    s.name.padEnd(28) + `${r.distance}px`.padStart(8) +
    r.impactFraction.toFixed(3).padStart(8) + r.distanceFraction.toFixed(3).padStart(10) +
    r.score.toFixed(4).padStart(8));
}
console.log("-".repeat(62));
console.log("session window total".padEnd(28) + total.toFixed(4).padStart(34));
console.log(`threshold rating: ${total <= 0.1 ? "good" : total <= 0.25 ? "needs improvement" : "poor"}`);
```

```
shift                          moved  impact  distance   score
announcement banner opened     100px   0.834     0.118  0.0988
station image settled           24px   0.318     0.028  0.0090
measurement badge arrived        8px   0.066     0.009  0.0006
--------------------------------------------------------------
session window total                                    0.1085
threshold rating: needs improvement
```

The three shifts have very different impact. The hundred-pixel announcement banner that
opens above the page pushes down everything below it, so it affects eighty-three percent
of the screen and alone produces nearly the entire score. The measurement badge's
eight-pixel jitter, by contrast, amounts to six-hundredths of a percent.

Two rules follow from this. A shift's cost is proportional not to the shifting element's
size but to **how much content sits beneath it**: a small element at the very top of the
page is more expensive than a large one at the very bottom. And because the score is a
product, when either fraction approaches zero the result approaches zero — a shift
outside the viewport counts for nothing at all.

The summing rule is also part of the definition: shifts get summed within a **session
window**, and the page's score is the largest of these windows. A window groups shifts
that have no long gap between them; a page left open for hours does not get a poor score
from small shifts accumulating over that time.

Shifts that start with user movement do not enter the metric. A panel that opens on a
button press and pushes down what is below it is an expected outcome; what gets measured
is the **unexpected** shift.

#### Field Data and Lab Data

The same metric gets collected two separate ways, and the two answer different
questions.

Lab data is a measurement produced under controlled conditions: a fixed network
throttle, a fixed device profile, a browser with no extensions, a cold cache. Because it
is reproducible, it suits comparison; it is the only reliable way to measure the
difference between two builds. The question it does not answer is what real users
actually experience.

Field data is a measurement collected from real visits. It carries device diversity,
network conditions, and user behavior. The question it does not answer is why: it
reports that a metric got worse, not which resource caused the delay.

Neither substitutes for the other. Field data shows that a problem exists; lab data
shows where. Metrics like interaction to next paint arise only through user interaction,
so they cannot be produced directly under lab conditions; measuring them requires
simulating the interaction, and a simulation does not represent a real usage pattern.

#### Summarizing a Distribution

Field data is not a single number but a distribution, and which number summarizes that
distribution changes the conclusion.

```js
// distribution.mjs — two different summaries of the same page: the mean and percentiles

// The distribution is a generated sample, not measured field data:
// a deterministic generator produces 500 visits.
let seed = 20260728;
const random = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;

const visits = Array.from({ length: 500 }, () => {
  // Roughly a third of visits are in the slow condition (distant network, slow device).
  const slow = random() < 0.3;
  return slow
    ? Math.round(3400 + random() * 2100)
    : Math.round(1200 + random() * 800);
});

function percentile(arr, p) {
  const sorted = [...arr].sort((a, b) => a - b);
  const position = (p / 100) * (sorted.length - 1);
  const lower = Math.floor(position);
  const upper = Math.ceil(position);
  return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
}

const mean = visits.reduce((a, b) => a + b, 0) / visits.length;
console.log("largest contentful paint summary (generated sample distribution, 500 visits)");
for (const [label, value] of [
  ["mean", mean],
  ["median (50th)", percentile(visits, 50)],
  ["75th percentile", percentile(visits, 75)],
  ["95th percentile", percentile(visits, 95)],
]) {
  const rating = value <= 2500 ? "good" : value <= 4000 ? "needs improvement" : "poor";
  console.log(`${label.padEnd(16)}${(value / 1000).toFixed(2)} s   -> ${rating}`);
}

const goodRatio = visits.filter((v) => v <= 2500).length / visits.length;
console.log(`share of visits under the threshold: ${(goodRatio * 100).toFixed(1)}%`);
```

```
largest contentful paint summary (generated sample distribution, 500 visits)
mean            2.38 s   -> good
median (50th)   1.73 s   -> good
75th percentile 3.56 s   -> needs improvement
95th percentile 5.15 s   -> poor
share of visits under the threshold: 72.4%
```

The distribution is built inside the program with a deterministic generator; it is not
measured field data but a sample generated to show the difference between two
summarization methods. A third of the visits are assumed to be in the slow condition,
the rest in the fast condition.

The result gives the same data two conflicting answers. The mean is 2.38 seconds and
falls in the good rating; the 75th percentile is 3.56 seconds and falls in the
needs-improvement rating. The gap is not a computation error — it is how the mean works:
fast visits hide the weight of the slow ones.

A percentile cannot do that. The 75th percentile means "three-quarters of users saw
better than this value," and it corresponds directly to a share of users. The share of
visits under the threshold gives the same information: in this distribution, 72 percent
of visits fall under the good threshold.

The metrics' standard thresholds are also defined over a distribution. For largest
contentful paint, up to 2.5 seconds counts as good and up to 4 seconds as needs
improvement; for cumulative layout shift the limits are 0.1 and 0.25; for interaction to
next paint they are 200 and 500 milliseconds. The classification applies not to a single
visit but to the chosen percentile.

#### Choosing Metrics for the Station Pages

The application's two pages are not judged by the same metrics.

The **station list page** is a navigation starting point. The user comes here to see the
list and immediately taps a row. The decisive metrics are largest contentful paint and
cumulative layout shift: if the list arrives late, the user waits; if it shifts late, the
user taps the wrong row.

The **measurement detail page** is a working surface. The user changes the date range,
filters the chart, converts units. The decisive metric is interaction to next paint;
loading happens once, interaction happens dozens of times.

Metric selection therefore depends on the page's job. Tracking every metric with the
same weight on every page hides which job broke.

#### Summary

- Technical milestones measure the browser's internal flow; user-centric metrics measure
  what the user sees and does, and the two can degrade independently of each other.
- The three questions map to five metrics: time to first byte and first contentful paint
  measure the blank screen ending, largest contentful paint measures the real content's
  arrival, interaction to next paint measures response time, cumulative layout shift
  measures visual stability.
- A layout shift's score is the product of the impact fraction and the distance
  fraction; what determines the cost is not the shifting element's size but how much
  content sits beneath it.
- Field data shows that a problem exists, lab data shows where; one is reproducible, the
  other is representative.
- When a distribution is summarized with the mean, slow visits get hidden; because a
  percentile corresponds directly to a share of users, metric thresholds get applied to
  a percentile.

#### Next Step

The metrics are defined, and which number to read them by is settled. The next question
is where the measured time gets spent. If largest contentful paint happens late, the
delay sits in one of three places: the server producing a response, the response being
transported, or the resources the browser has to wait for before it can paint anything.
The third is entirely under the developer's control and is often the largest share. The
next lesson addresses which resources the first paint waits for, and computes how many
round trips that wait costs, through a calculable model.

### Critical Rendering Path

Source: https://academia.sh/en/courses/frontend-quality/critical-rendering-path

The resources first paint has to wait for; the distinction between render-blocking CSS and parser-blocking scripts, computing the round-trip count, and the markup patterns that remove blocking.

The previous lesson defined the metrics: if largest contentful paint happens late, there
is a problem. The delay can have three sources — the server producing the response, the
response being transported, and the resources the browser has to wait for before it can
paint anything. The third is entirely under the developer's control.

This lesson defines that wait. The critical rendering path is the chain of work that
must complete before first paint can happen. The chain's length can be measured and
shortened.

#### Two Different Kinds of Blocking

The browser parses the document top to bottom and requests the resources it encounters
along the way. Two kinds of resource stop this flow, and they stop it differently.

**Render-blocking CSS.** Until an external stylesheet gets downloaded and resolved, the
browser paints nothing. The reason is the cascade: by the rule the Visual Presentation
with CSS course establishes, an element's computed value can change because of a
declaration in a file that has not arrived yet. Painting with incomplete style would mean
a page that looks completely different seconds later. So the browser waits. Parsing the
document continues — a stylesheet does not stop parsing — but painting stops.

**Parser-blocking script.** A script written directly into the document, carrying
neither `defer` nor `async`, stops parsing until it has downloaded and run. The reason is
that the script can modify the document: it can produce or delete the content that
follows it, so the browser cannot continue. Because parsing stops, nothing after it can
get discovered; painting cannot happen either.

The two kinds of blocking compound. A stylesheet that comes before a script also delays
the script's execution: because the script might read computed values, the browser has
to wait for the stylesheet to resolve. A single ordering decision thereby ties three jobs
together.

#### Three Variables

The wait's length depends on three separate quantities, and separating them determines
which optimization will actually help.

**Byte count** is the part that depends on bandwidth. Compression and dead-code
elimination affect this.

**Round-trip count** is the part that depends on latency. When data gets sent over a
connection, the congestion window starts small and grows with each round trip; a large
file therefore does not arrive in a single round trip. This behavior, defined in the
Network Models and Protocols course, produces the following performance consequence: the
first bytes are cheap, the later ones are expensive.

**Chain depth**, in turn, depends on discovery order. If a resource's existence only
becomes known after another resource has downloaded, the two cannot be requested in
parallel; their durations add up.

The following model computes all three together.

```js
// critical-path.mjs — a computation modeling how many round trips first paint costs

const MSS = 1460;          // bytes: the payload one segment carries
const INITIAL_WINDOW = 10; // segments: how many segments can be sent in the first round

// The connection carries the congestion window across rounds; the window doubles each round.
function connection() {
  let window = INITIAL_WINDOW;
  return {
    get window() { return window; },
    // One stage: resources requested concurrently share the same window.
    stage(bytes) {
      let remaining = bytes.reduce((a, b) => a + b, 0);
      let round = 0;
      do {
        remaining -= window * MSS;
        window *= 2;
        round += 1;
      } while (remaining > 0);
      return round;
    },
  };
}

function firstPaint(scenario) {
  const b = connection();
  let round = scenario.connectionSetup;
  const breakdown = [["connection setup", scenario.connectionSetup, "-"]];
  for (const stage of scenario.stages) {
    const previousWindow = b.window;
    const t = b.stage(stage.bytes);
    round += t;
    breakdown.push([stage.name, t, `${previousWindow} -> ${b.window} segments`]);
  }
  return { round, breakdown };
}

// North Slope Measurement Station — the station list page.
// Connection setup: name resolution 1 + transport handshake 1 + secure handshake 1 = 3 rounds.
const scenarios = [
  {
    name: "A: blocking style and script up front",
    connectionSetup: 3,
    stages: [
      { name: "HTML body", bytes: [18_400] },
      { name: "blocking style + script", bytes: [62_000, 141_000] },
      { name: "style discovered by the script", bytes: [24_000] },
    ],
  },
  {
    name: "B: script deferred",
    connectionSetup: 3,
    stages: [
      { name: "HTML body", bytes: [18_400] },
      { name: "blocking style", bytes: [62_000] },
    ],
  },
  {
    name: "C: critical style inlined",
    connectionSetup: 3,
    stages: [
      { name: "HTML body (+ inlined style)", bytes: [18_400 + 9_800] },
    ],
  },
];

console.log("--- slow start: total bytes sendable by the end of round k ---");
let accumulated = 0;
let window = INITIAL_WINDOW;
for (let k = 1; k <= 4; k++) {
  accumulated += window * MSS;
  console.log(`round ${k}  window ${String(window).padStart(3)} segments  total ${String(accumulated).padStart(7)} bytes`);
  window *= 2;
}

console.log("\n--- round-trip breakdown to first paint ---");
const results = [];
for (const s of scenarios) {
  const r = firstPaint(s);
  results.push({ name: s.name, round: r.round });
  console.log(`\n${s.name}`);
  for (const [name, t, p] of r.breakdown) {
    console.log(`  ${name.padEnd(30)} ${t} rounds   ${p}`);
  }
  console.log(`  ${"TOTAL".padEnd(30)} ${r.round} rounds`);
}

console.log("\n--- comparing the scenarios ---");
const baseline = results[0].round;
for (const s of results) {
  console.log(`${s.name.padEnd(34)} ${String(s.round).padStart(2)} rounds   ratio to A: ${(s.round / baseline).toFixed(2)}`);
}
```

```
--- slow start: total bytes sendable by the end of round k ---
round 1  window  10 segments  total   14600 bytes
round 2  window  20 segments  total   43800 bytes
round 3  window  40 segments  total  102200 bytes
round 4  window  80 segments  total  219000 bytes

--- round-trip breakdown to first paint ---

A: blocking style and script up front
  connection setup               3 rounds   -
  HTML body                      2 rounds   10 -> 40 segments
  blocking style + script        3 rounds   40 -> 320 segments
  style discovered by the script 1 rounds   320 -> 640 segments
  TOTAL                          9 rounds

B: script deferred
  connection setup               3 rounds   -
  HTML body                      2 rounds   10 -> 40 segments
  blocking style                 2 rounds   40 -> 160 segments
  TOTAL                          7 rounds

C: critical style inlined
  connection setup               3 rounds   -
  HTML body (+ inlined style)    2 rounds   10 -> 40 segments
  TOTAL                          5 rounds

--- comparing the scenarios ---
A: blocking style and script up front  9 rounds   ratio to A: 1.00
B: script deferred                  7 rounds   ratio to A: 0.78
C: critical style inlined           5 rounds   ratio to A: 0.56
```

The first table shows the congestion window growing: in the first round a little over
fourteen kilobytes of data can be sent, and by the end of the fourth round that exceeds
two hundred kilobytes. This number is not a limit but a starting assumption; the initial
window depends on configuration, and the model exposes the value as a constant up front.

The three scenarios build the same page with three different markups. In scenario A, the
blocking style and script sit at the top of the document, and a second stylesheet gets
discovered only after the script has downloaded. In scenario B, the script gets deferred;
both its own bytes and the chain it discovers drop out of the critical path. In scenario
C, the first screen's style gets inlined into the document and no external request
remains.

The result reads through ratios: scenario B waits seventy-eight percent of A's time,
scenario C fifty-six percent. An absolute duration cannot be given, because a round's
duration depends on the network — but the round count follows from the markup itself and
is comparable.

The model simplifies reality at two points. It assumes that concurrent requests within a
stage share the same window, and it does not account for the window shrinking after idle
time. Both push the result in favor of A, not B and C; the real difference, in other
words, is not smaller than what the model shows but larger.

#### Removing the Blocking

The ways of shortening the critical path correspond to the two kinds of blocking.

**Taking the script out of the critical path.** The `defer` attribute lets the script
download in parallel with parsing and run, in write order, only after document parsing
finishes. `async` also downloads in parallel but runs the moment it arrives; there is no
order guarantee, and running it still interrupts parsing at that moment. `defer` suits
scripts that depend on each other; `async` suits scripts that are independent and
self-contained. Scripts loaded as modules behave as deferred by default.

**Making a stylesheet conditional.** When the `media` attribute gets written on a
stylesheet link, the file does not block painting if the condition does not currently
hold. Print stylesheets and styles that only apply on wide screens get taken out of the
critical path this way. The file still downloads, but at low priority and without
holding up painting.

**Inlining critical CSS.** The declarations sufficient to paint the first screen get
written directly into the document; the remaining style loads through a non-blocking
path. This is the source of scenario C's gain, and it has a cost: inlined style cannot be
cached separately, arrives again with every page request, and enlarges the document. The
decision depends on the repeat-visit rate — a gain on a page dominated by first visits is
a loss on a panel opened every day.

**Moving a late-discovered resource earlier.** If a resource only gets discovered after a
stylesheet resolves or a script runs, it can be requested at the top of the document with
a `preload` declaration. This declaration is a correction, not an optimization: it should
not get used when a direct way to shorten the chain exists, because written incorrectly
it makes a resource download twice or takes bandwidth away from a resource that is
genuinely critical.

#### The Discovery Chain

Scenario A's fourth line — "style discovered by the script" — alone produces more than
one round's cost, because that round can only start after the script has downloaded and
run.

The browser's preload scanner skims the document quickly before parsing it and requests
the resources it sees ahead of time. The one thing the preload scanner cannot see is
resources not written in the document: links inserted by a script, other stylesheets
called from within a stylesheet, addresses determined only after a configuration file
gets read.

This is why the most effective step in critical-path optimization is often not reducing
bytes but **flattening the chain**: making every critical resource visible in the
document's first bytes.

#### The Station List Page's Document Head

```html
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>North Slope Measurement Station — Stations</title>

  <!-- first screen's style: inlined, no external request -->
  <style>
    .station-list { display: grid; gap: 12px; }
    .station-list > li { min-height: 72px; }
  </style>

  <!-- remaining style: does not block painting, applies once it has downloaded -->
  <link rel="stylesheet" href="/assets/station.9f2c1a.css" media="print"
        onload="this.media='all'">
  <noscript><link rel="stylesheet" href="/assets/station.9f2c1a.css"></noscript>

  <!-- print style never blocks painting -->
  <link rel="stylesheet" href="/assets/print.3ce8b0.css" media="print">

  <!-- application script: downloads in parallel with parsing, runs in order once parsing finishes -->
  <script src="/assets/entry.4a1c.js" defer></script>
</head>
```

The second link is a markup trick and needs explaining: a stylesheet written with
`media="print"` does not block painting, and once the file has downloaded, the `onload`
handler sets the media to `all`, applying the style. The second link inside `noscript`
makes sure the style still arrives when the script does not run. The trick exists because
there is no direct "blocking" declaration available for a stylesheet link.

The `min-height` declaration in the inlined style is for stability, not performance: the
list rows take their height before the data arrives, so the layout shift computed in the
previous lesson does not happen. Two goals meet in the same declaration.

#### Summary

- The critical rendering path is the chain of work first paint has to wait for;
  render-blocking CSS stops painting, and a parser-blocking script stops both parsing
  and painting.
- The wait's three components get handled separately: byte count depends on bandwidth,
  round-trip count on latency, and chain depth on discovery order.
- Because the congestion window grows with rounds, the first bytes are cheap; taking a
  resource out of the critical path can give a much larger gain than shrinking it.
- `defer`, `async`, and `media` declarations remove blocking; inlining critical style
  erases the external request entirely but gives up cacheability.
- The preload scanner sees only resources written in the document; flattening the chain
  is often more effective than reducing bytes.

#### Next Step

When the critical path shortens, first paint moves earlier, but on most pages largest
contentful paint is not a block of text — it is an image. The card images on the station
list and the station photo on the measurement detail page carry far more bytes than the
document itself; font files, in turn, determine when the text becomes readable. Both have
their own decisions about size, format, and loading. The next lesson takes up these
decisions and computes which candidate file downloads under which condition.

### Image and Font Optimization

Source: https://academia.sh/en/courses/frontend-quality/image-and-font-optimization

Separating an image's transfer, decode, and paint costs; computing the correct file selection from a candidate set, deciding by format class, and the timeline of a font-loading strategy.

When the critical path shortens, first paint moves earlier. Largest contentful paint, on
most pages, is not a block of text — it is an image: the card images on the station list
and the station photo on the measurement detail page carry far more bytes than the
document itself. Font files, in turn, determine when the text becomes readable.

This lesson takes up the size, format, and loading decisions for both asset types.

#### An Image's Three Separate Costs

Image optimization usually gets reduced to a single number: file size. Yet the same
image produces cost in three separate places, and they are independent of each other.

**Transfer cost** is the number of bytes downloaded. Compression lowers this.

**Decode cost** is turning the compressed bytes into pixels. It spends processor time,
and the result occupies memory independent of compression: four bytes per pixel. This is
the image counterpart of the transferred-size–resource-size distinction from the Network
Panel Diagnostics lesson, and here the gap is far larger. A photo that compresses well
does not tax the network — it taxes memory.

**Paint cost** is scaling the decoded pixels to the box on screen. If the source pixel
count exceeds the target box, the surplus gets rescaled on every frame.

The second and third costs depend on the selected file's **pixel dimensions**, not its
file size. The following computation puts this distinction into numbers.

```js
// candidates.mjs — which file gets selected from a candidate set, and the decoded pixel cost

// station card image: aspect ratio 3:2
const candidates = [400, 800, 1200, 1600, 2400]; // width descriptors (w)
const ASPECT_RATIO = 2 / 3;

// sizes declaration: full width under a 720px viewport, 360px above it
const layoutWidth = (viewportWidth) =>
  viewportWidth <= 720 ? viewportWidth - 32 : 360;

function selectedCandidate(viewportWidth, pixelRatio) {
  const needed = layoutWidth(viewportWidth) * pixelRatio;
  return candidates.find((a) => a >= needed) ?? candidates[candidates.length - 1];
}

const decodedBytes = (width) => Math.round(width * width * ASPECT_RATIO * 4);

console.log("viewport".padStart(9) + "ratio".padStart(7) + "layout".padStart(9) +
  "needed".padStart(9) + "selected".padStart(10) + "decoded".padStart(10) +
  "largest candidate".padStart(20) + "multiple".padStart(10));
for (const [viewport, ratio] of [[390, 2], [390, 3], [768, 1], [768, 2], [1280, 2]]) {
  const selected = selectedCandidate(viewport, ratio);
  const needed = layoutWidth(viewport) * ratio;
  const selectedBytes = decodedBytes(selected);
  const largestBytes = decodedBytes(candidates[candidates.length - 1]);
  console.log(
    `${viewport}px`.padStart(9) + `${ratio}x`.padStart(7) +
    `${layoutWidth(viewport)}px`.padStart(9) + `${needed}px`.padStart(9) +
    `${selected}w`.padStart(10) +
    `${(selectedBytes / 1024 / 1024).toFixed(2)} MB`.padStart(10) +
    `${(largestBytes / 1024 / 1024).toFixed(2)} MB`.padStart(20) +
    `${(largestBytes / selectedBytes).toFixed(1)}x`.padStart(10));
}
console.log("decoded = width x height x 4 bytes (1 byte per channel, RGBA)");
console.log("multiple = how many times larger decoded memory would be if the largest candidate got selected");
```

```
 viewport  ratio   layout   needed  selected   decoded   largest candidate  multiple
    390px     2x    358px    716px      800w   1.63 MB            14.65 MB      9.0x
    390px     3x    358px   1074px     1200w   3.66 MB            14.65 MB      4.0x
    768px     1x    360px    360px      400w   0.41 MB            14.65 MB     36.0x
    768px     2x    360px    720px      800w   1.63 MB            14.65 MB      9.0x
   1280px     2x    360px    720px      800w   1.63 MB            14.65 MB      9.0x
decoded = width x height x 4 bytes (1 byte per channel, RGBA)
multiple = how many times larger decoded memory would be if the largest candidate got selected
```

Selection happens in two steps. First, the element's **layout width** gets resolved from
the `sizes` declaration; this is a rule set written with media queries, and the browser
produces a single CSS pixel value by looking at the viewport. Then this value gets
multiplied by the pixel ratio, and the smallest file in the candidate set that meets that
number gets selected.

What the table really shows is the last column. The same image's largest candidate
produces fourteen and a half megabytes of pixel data whenever it gets decoded, no matter
the condition; on a narrow screen, though, a four-hundred-pixel file is enough and spends
thirty-six times less memory. This gap does not show up in file sizes — the ratio between
the two files' compressed sizes is far smaller.

The same table holds a second observation: the card is smaller on a wide screen. In a
responsive layout, a single card's width drops as the column count rises, so the
assumption that desktop always wants a larger image is wrong. The `sizes` declaration
must get derived from the layout, not from a guess.

#### Format Selection

The format decision gets made by content class, not by product names.

**Vector or raster?** Icons, charts, logos, and line graphics get defined by geometry;
stored in vector format, they stay sharp at every scale, and their decoded memory depends
on geometric complexity, not pixel count. A photograph is raster; it cannot get converted
to vector.

**Lossy or lossless?** A lossy encoder compresses by discarding information the eye does
not distinguish, and it is the right choice for a photograph. A lossless encoder
preserves every pixel; in content with sharp edges — a screenshot, an image containing
text, flat color regions — the blur lossy compression leaves behind becomes visible.

**Is transparency needed?** A format that supports an alpha channel should get chosen if
it is; it should not if it is not, because a fourth channel adds data.

**Does it offer progressive rendering?** Some formats let a low-resolution version get
produced from the file's first bytes. On a large image, this separates the time between
the user seeing something and seeing everything.

Different formats being supported by different clients gets solved with the `picture`
element: each option in the source list declares its own content type, the client skips
a type it does not recognize, and it eventually falls back to the `img` element. This is
the markup-level counterpart of feature detection; it requires no assumption about which
format will be supported.

#### When It Gets Requested

Once the correct file is selected, two decisions remain: when to request it, and whether
to reserve its space up front.

**Lazy loading** defers requesting images outside the viewport. The rule's one exception
matters: the image that is a candidate for largest contentful paint must not be
lazy-loaded. A deferred request delays the metric itself.

**Fetch priority** is the other end of the same problem. Every image in the document
gets requested at low priority by default; writing `fetchpriority="high"` on the hero
image in the first screen moves its request ahead of the others.

**Space reservation** is a stability decision, not a performance one, but it gets given
on the same element. When `width` and `height` attributes are written on an image, the
browser knows the aspect ratio before the file downloads and reserves the box up front.
Left unwritten, everything beneath the image shifts the moment it arrives — this is the
most common source of the score computed in the first lesson.

#### The Font's Timeline

The Font Loading lesson in the Visual Presentation with CSS course separated
`font-display` values by two periods: during the block period no text gets painted,
during the swap period text gets painted with a fallback font. The question asked here is
different: what does the user see if the file arrives at a given moment?

```js
// font-timeline.mjs — the state of the text depending on the file's arrival time
// The three periods defined in the specification: block, swap, fallback retreat.
const PERIODS = {
  block:    { blockDuration: 3000, swapDuration: Infinity },
  swap:     { blockDuration: 100,  swapDuration: Infinity },
  fallback: { blockDuration: 100,  swapDuration: 3000 },
  optional: { blockDuration: 100,  swapDuration: 0 },
};

function timeline(value, arrival) {
  const { blockDuration, swapDuration } = PERIODS[value];
  const invisibleDuration = Math.min(arrival, blockDuration);
  if (arrival <= blockDuration) {
    return { invisibleDuration, fallbackDuration: 0, result: "requested font" };
  }
  const swapEnd = blockDuration + swapDuration;
  if (arrival <= swapEnd) {
    return { invisibleDuration, fallbackDuration: arrival - blockDuration, result: "requested font" };
  }
  return { invisibleDuration, fallbackDuration: Infinity, result: "fallback font stays" };
}

const duration = (ms) => (ms === Infinity ? "for the page's lifetime" : `${ms} ms`);

console.log("--- state of the text by file arrival time ---");
console.log("value".padEnd(10) + "arrival".padStart(9) + "invisible".padStart(11) +
  "fallback".padStart(24) + "   final state");
for (const value of Object.keys(PERIODS)) {
  for (const arrival of [80, 900, 4200]) {
    const c = timeline(value, arrival);
    console.log(
      value.padEnd(10) + `${arrival} ms`.padStart(9) +
      `${c.invisibleDuration} ms`.padStart(11) + duration(c.fallbackDuration).padStart(24) +
      "   " + c.result);
  }
}
```

```
--- state of the text by file arrival time ---
value       arrival  invisible                fallback   final state
block         80 ms      80 ms                    0 ms   requested font
block        900 ms     900 ms                    0 ms   requested font
block       4200 ms    3000 ms                 1200 ms   requested font
swap          80 ms      80 ms                    0 ms   requested font
swap         900 ms     100 ms                  800 ms   requested font
swap        4200 ms     100 ms                 4100 ms   requested font
fallback      80 ms      80 ms                    0 ms   requested font
fallback     900 ms     100 ms                  800 ms   requested font
fallback    4200 ms     100 ms for the page's lifetime   fallback font stays
optional      80 ms      80 ms                    0 ms   requested font
optional     900 ms     100 ms for the page's lifetime   fallback font stays
optional    4200 ms     100 ms for the page's lifetime   fallback font stays
```

When the file arrives quickly, all five values give the same result; the distinction
appears in the slow condition. For a file arriving at nine hundred milliseconds, `block`
leaves the text invisible for nine hundred milliseconds, `swap` and `fallback` paint it
with a fallback font after a hundred milliseconds, and `optional` never uses the file on
that page at all.

The decision gets made by which metric gets protected. `swap` protects first contentful
paint and accepts the layout shift: text painted with the fallback font changes measure
when the real font arrives. `optional` protects layout stability and gives up the
designed font. `block` breaks both and is only defensible where a fallback would be
meaningless — icon fonts, for instance.

The duration values get given in the specification as recommendations and vary by
implementation; the numbers in the model are taken from these recommendations. A changed
number does not change the values' ordering relative to each other.

There are three ways to reduce font cost, and all three improve this table: subsetting
the file to its own text reduces the bytes to download, requesting the file early with
`preload` moves the arrival time earlier, and bringing the fallback font's metrics closer
to the real font with `size-adjust` and similar descriptors brings the shift at swap time
closer to zero.

#### The Station Card's Markup

```html
<article class="station-card">
  <img
    src="/images/north-slope-800.img"
    srcset="/images/north-slope-400.img   400w,
            /images/north-slope-800.img   800w,
            /images/north-slope-1200.img 1200w"
    sizes="(max-width: 720px) calc(100vw - 32px), 360px"
    width="1200" height="800"
    fetchpriority="high"
    alt="The North Slope station's measurement pole beneath its snow cover">
  <h2>North Slope 1</h2>
</article>
```

The file extension in the example is a placeholder; it gets written according to the
selected format. The rule lies in the structure, not the extension: the candidate set
gets declared with real pixel widths, the `sizes` declaration gets derived from the
layout, `width` and `height` give the aspect ratio up front, and the priority declaration
gets written only on the first-screen card. If more than one format will be offered, the
same `img` element gets wrapped in a `picture`, and each `source` declares its own
content type; `img` remains as the last resort.

Cards in the list that fall outside the viewport carry no priority declaration;
`loading="lazy"` gets written on them instead. Two different instances of the same
component receive different declarations, and this distinction gets exposed as an option
in the component's interface.

#### Summary

- An image's transfer, decode, and paint costs are separate; decoded memory depends on
  pixel count, not file size, and the gap between them can span multiples.
- Candidate selection is two steps: the layout width gets resolved from the `sizes`
  declaration, gets multiplied by the pixel ratio, and the smallest candidate that meets
  it gets selected; the declaration must get derived from the layout.
- The format decision gets made by content class, not product name: vector or raster,
  lossy or lossless, whether transparency is needed, whether it offers progressive
  rendering.
- The image that is a candidate for largest contentful paint does not get lazy-loaded;
  the priority declaration gets written only on the first-screen image, and space
  reservation gets done on every image.
- Font display behavior is a metric preference: `swap` protects first contentful paint,
  `optional` protects layout stability.

#### Next Step

Every decision up to this point concerned the document and the assets themselves: which
file, how big, when it gets requested. One more layer exists, and there a gain can get
achieved without reducing a single byte. Does the same user opening the station list a
second time have to redownload the same files? Does connection setup happen from scratch
every time? The answer to these questions lies not in the request but in the headers the
response carries and the connection hints written into the document. The next lesson
takes up these headers by measuring them on a local server.

### Network Performance

Source: https://academia.sh/en/courses/frontend-quality/network-performance

Separating freshness from validation, measuring cache headers on a local server, the immutability promise of content-hashed names, and what connection hints actually earn.

The previous three lessons dealt with the document and the assets themselves: which
file, how big, when it gets requested. One more layer exists, and there a gain can get
achieved without reducing a single byte. Does the same user opening the station list a
second time have to redownload the same files?

The answer to this question lies not in the request but in the headers the response
carries. The Caching Strategies lesson in the Browser and the Web Platform course took up
program-controlled caching; the subject here is what the server determines with headers
alone, without a program getting written.

#### Freshness and Validation Are Two Separate Decisions

The HTTP cache answers two separate questions, and confusing them is the most common
cause of misconfiguration.

**Freshness lifetime** answers this question: how long can this response be used
without a query? The `max-age` directive in the `Cache-Control` header gives a duration;
until the duration expires, the browser **makes no request at all**. The cheapest request
is the one never made.

The **validator** answers this question: is the copy I have still valid? The `ETag`
header carries a signature tied to the content. Once the freshness lifetime expires, the
browser sends this signature back in an `If-None-Match` header; if the content has not
changed, the server returns a response with no body.

The two decisions complement each other. Freshness prevents the request entirely;
validation makes the request cheap. The `no-cache` directive leads to a
misunderstanding: it does not forbid storage, it only requires validation before every
use. The directive that forbids storage is `no-store`.

#### Measuring the Headers

The following server serves three resources under three separate policies and genuinely
handles the conditional request.

```js
// cache-server.mjs — shows freshness and validation headers on three separate resources
import { createServer } from "node:http";
import { createHash } from "node:crypto";

const bodies = {
  "/assets/station.9f2c1a.css": {
    type: "text/css",
    content: ".station-list{display:grid;gap:12px}",
    // Content-hashed name: if the address changes, the content has changed.
    cache: "public, max-age=31536000, immutable",
  },
  "/api/stations": {
    type: "application/json",
    content: JSON.stringify({ stations: ["north-slope-1", "north-slope-2"] }),
    // Gets validated on every use; the body only gets sent if it changed.
    cache: "no-cache",
  },
  "/api/announcement": {
    type: "application/json",
    content: JSON.stringify({ announcement: "Maintenance window: 03:00-04:00" }),
    // Fresh for one minute; stale served and revalidated in the background for the next five.
    cache: "public, max-age=60, stale-while-revalidate=300",
  },
};

const tag = (content) =>
  `"${createHash("sha256").update(content).digest("hex").slice(0, 16)}"`;

createServer((req, res) => {
  res.sendDate = false;
  const resource = bodies[req.url];
  if (!resource) {
    res.writeHead(404, { "Content-Type": "text/plain" });
    return res.end("not found\n");
  }

  const validator = tag(resource.content);
  const headers = {
    "Cache-Control": resource.cache,
    ETag: validator,
    Vary: "Accept-Encoding",
  };

  // Conditional request: the body does not get sent if the client's validator is still valid.
  if (req.headers["if-none-match"] === validator) {
    res.writeHead(304, headers);
    return res.end();
  }

  res.writeHead(200, {
    ...headers,
    "Content-Type": resource.type,
    "Content-Length": Buffer.byteLength(resource.content),
  });
  res.end(resource.content);
}).listen(8241, "127.0.0.1", () => console.log("listening: 127.0.0.1:8241"));
```

```bash
#!/usr/bin/env bash
# Starts cache-server.mjs, measures freshness and validation behavior, stops it.
node cache-server.mjs > /dev/null &
server=$!
sleep 1
base=http://127.0.0.1:8241

for path in /assets/station.9f2c1a.css /api/stations /api/announcement; do
  printf '%s\n' "$path"
  curl -sS -o /dev/null -D - "$base$path" \
    | grep -iE '^(HTTP/|cache-control|etag|content-length)' \
    | tr -d '\r' | sed 's/^/  /'
done

printf '\n--- conditional request ---\n'
tag=$(curl -sS -o /dev/null -D - "$base/api/stations" \
  | awk 'tolower($1) == "etag:" { print $2 }' | tr -d '\r')
printf 'validator held by the client: %s\n' "$tag"

curl -sS -o /dev/null -H "If-None-Match: $tag" "$base/api/stations" \
  -w 'conditional request   -> status %{http_code}, downloaded body %{size_download} bytes\n'
curl -sS -o /dev/null "$base/api/stations" \
  -w 'unconditional request -> status %{http_code}, downloaded body %{size_download} bytes\n'

kill "$server"
```

```
/assets/station.9f2c1a.css
  HTTP/1.1 200 OK
  Cache-Control: public, max-age=31536000, immutable
  ETag: "f4c1b262ab403423"
  Content-Length: 36
/api/stations
  HTTP/1.1 200 OK
  Cache-Control: no-cache
  ETag: "be211c47597a1001"
  Content-Length: 46
/api/announcement
  HTTP/1.1 200 OK
  Cache-Control: public, max-age=60, stale-while-revalidate=300
  ETag: "510bd98694f649be"
  Content-Length: 50

--- conditional request ---
validator held by the client: "be211c47597a1001"
conditional request   -> status 304, downloaded body 0 bytes
unconditional request -> status 200, downloaded body 46 bytes
```

The validator values are stable in this output because they get produced from a digest
of the content; they change when the content changes. Port 8241 is an arbitrary choice
and must be free.

The measured difference is clear: the conditional request returns with no body. The
round trip still happened — a conditional request does not eliminate latency, only
transfer. Freshness lifetime eliminates the round trip too. This difference between the
two determines which policy gets written on which resource.

#### The Immutable Asset, the Changing Document

The `immutable` directive makes a promise: the content at this address will never
change. The promise can only be kept if the address gets derived from the content. The
cache-busting approach in the Rendering Strategies and Infrastructure course provides
exactly that: when a content hash gets written into the file name, the address changes
whenever the content changes, and the old address staying fresh forever creates no
problem.

The document cannot make this promise, because its address is fixed and its content
changes with every release. The correct policy for a document is validation, not a long
freshness period.

For the case in between, there is the `stale-while-revalidate` directive: after the
freshness period expires, the old copy gets served immediately for a specified duration
while the refresh happens in the background. The user does not wait; the data refreshes
with one round trip's delay. It suits content that tolerates delay, like an announcement
banner; it does not suit content like an account balance.

How many bytes each of these three policies transfers on a repeat visit can be computed.

```js
// repeat-visit.mjs — how header policy affects bytes transferred on a repeat visit

const HEADER_OVERHEAD = 320; // bytes: the approximate share of request + response headers

// The station list page's resources and their selected policy.
const resources = [
  { name: "document",      bytes:  18_400, policy: "no-cache" },
  { name: "style",         bytes:  62_000, policy: "immutable" },
  { name: "entry script",  bytes: 148_112, policy: "immutable" },
  { name: "shared script", bytes: 101_760, policy: "immutable" },
  { name: "font",          bytes:  41_600, policy: "immutable" },
  { name: "card image",    bytes:  86_400, policy: "immutable" },
  { name: "station data",  bytes:   9_240, policy: "max-age=60" },
];

const fullSize = (k) => HEADER_OVERHEAD + k.bytes;
const firstVisit = resources.reduce((t, k) => t + fullSize(k), 0);

// Bytes transferred for one resource on a repeat visit.
function repeatBytes(resource, elapsedSeconds, changed) {
  const hasChanged = changed.has(resource.name);
  switch (resource.policy) {
    case "immutable":
      // Content-hashed name: if it changed, the address has changed, and the new resource downloads from scratch.
      return hasChanged ? fullSize(resource) : 0;
    case "no-cache":
      // Gets validated on every use: if unchanged, only the headers arrive.
      return hasChanged ? fullSize(resource) : HEADER_OVERHEAD;
    case "max-age=60":
      if (elapsedSeconds <= 60) return 0;             // still fresh: no request
      return hasChanged ? fullSize(resource) : HEADER_OVERHEAD;
    default:
      return fullSize(resource);
  }
}

const scenarios = [
  { name: "after 30 seconds, same version", elapsed: 30, changed: new Set() },
  { name: "after 300 seconds, same version", elapsed: 300, changed: new Set() },
  {
    name: "new version published",
    elapsed: 300,
    changed: new Set(["document", "style", "entry script"]),
  },
];

console.log(`first visit: ${(firstVisit / 1024).toFixed(1)} KB`);
for (const s of scenarios) {
  console.log(`\n--- ${s.name} ---`);
  console.log("resource".padEnd(14) + "repeat".padStart(10) + "  policy");
  let total = 0;
  for (const k of resources) {
    const b = repeatBytes(k, s.elapsed, s.changed);
    total += b;
    console.log(k.name.padEnd(14) + `${(b / 1024).toFixed(2)} KB`.padStart(10) + "  " + k.policy);
  }
  console.log("-".repeat(36));
  console.log("TOTAL".padEnd(14) + `${(total / 1024).toFixed(2)} KB`.padStart(10));
  console.log(`ratio to first visit: ${(total / firstVisit).toFixed(4)}`);
}

console.log("\nif no freshness header had been written, every repeat visit ratio would be 1.0000.");
```

```
first visit: 458.7 KB

--- after 30 seconds, same version ---
resource          repeat  policy
document         0.31 KB  no-cache
style            0.00 KB  immutable
entry script     0.00 KB  immutable
shared script    0.00 KB  immutable
font             0.00 KB  immutable
card image       0.00 KB  immutable
station data     0.00 KB  max-age=60
------------------------------------
TOTAL            0.31 KB
ratio to first visit: 0.0007

--- after 300 seconds, same version ---
resource          repeat  policy
document         0.31 KB  no-cache
style            0.00 KB  immutable
entry script     0.00 KB  immutable
shared script    0.00 KB  immutable
font             0.00 KB  immutable
card image       0.00 KB  immutable
station data     0.31 KB  max-age=60
------------------------------------
TOTAL            0.63 KB
ratio to first visit: 0.0014

--- new version published ---
resource          repeat  policy
document        18.28 KB  no-cache
style           60.86 KB  immutable
entry script   144.95 KB  immutable
shared script    0.00 KB  immutable
font             0.00 KB  immutable
card image       0.00 KB  immutable
station data     0.31 KB  max-age=60
------------------------------------
TOTAL          224.41 KB
ratio to first visit: 0.4892

if no freshness header had been written, every repeat visit ratio would be 1.0000.
```

The resource sizes are the computation's input, not the output of a measured
application; the header share is also an assumption. What gets computed is the policy's
consequence.

In the first two scenarios, the repeat visit is nearly free. The third scenario teaches
the real lesson: when a new version gets published, only the parts that **changed**
download again. Because the shared script did not change, its address did not change
either, and it never gets requested. This is where bundling decisions have a performance
consequence — if frequently changing code and rarely changing code get grouped into the
same chunk, every release redownloads both.

#### Vary and the Cache Key

If a response varies according to request headers, this must get declared with the
`Vary` header. Left undeclared, intermediate caches serve a single copy to everyone: a
compressed response can go to a client that does not support compression, or a page
produced for one language can go to a user in another.

The rule is a correctness rule, and it affects performance too: adding an unnecessary
header to the `Vary` list splits the cache key more than it needs to be and lowers the
hit rate. If a user-specific header enters the list, the cache is effectively disabled.

#### Connection Hints

Connection hints written into the document tell the browser to do a job early. Each one
moves a different stage earlier.

`dns-prefetch` performs only name resolution. It is the cheapest hint and earns the
least.

`preconnect` performs name resolution, the transport connection, and the secure
handshake ahead of time. It spends the rounds counted as "connection setup" in the
critical-path model before the resource even gets requested. It has a cost: a connection
that gets established and goes unused holds resources on both the client and the server,
so it should get written only for origins that will definitely be used.

`preload` requests a specific resource early. As described in the Critical Rendering
Path lesson, this is a correction: it moves a late-discovered critical resource earlier.
The `as` attribute is required; written incorrectly, the resource downloads twice.

`prefetch` downloads, at idle priority, a resource that will be needed on the **next**
navigation. Its priority is the lowest, and it is wasted bandwidth if the user never goes
to that page.

The order is a priority order: flatten the chain first, then `preconnect`, and `preload`
last. A hint written without measurement often does harm by taking bandwidth away from
the critical resource.

#### Summary

- Freshness lifetime prevents the request entirely, the validator makes the request
  bodiless; the two solve separate problems, and `no-cache` forbids not storage but
  unvalidated use.
- A content-hashed address is the condition for the `immutable` promise to be kept; a
  document with a fixed address cannot make this promise and gets managed through
  validation.
- In a new release, only the parts whose address changed download again; a bundling
  decision is therefore a caching decision.
- The `Vary` header is a correctness matter before it is a performance matter: its
  absence serves the wrong response, its excess splits the cache.
- Connection hints move different stages earlier and each one has a cost; a hint written
  without measurement cuts off the critical resource.

#### Next Step

Once the network side is arranged, bytes arrive on time and a repeat visit makes almost
no request. The application can still feel heavy, though: the list stutters while
scrolling, a letter typed into the filter box appears on screen late, the page may freeze
for a moment when the date range changes. None of these problems are in the network; all
of them come from downloaded code running in a single queue. The next lesson takes up
this queue: it computes how long tasks get measured, what chunking earns, and how layout
thrashing scales with row count.

### Runtime Performance

Source: https://academia.sh/en/courses/frontend-quality/runtime-performance

Measuring long tasks on the main thread with total blocking time, the measured effect of chunking work, layout thrashing scaling with row count, and ways to avoid painting what is not visible.

Once the network side is arranged, bytes arrive on time and a repeat visit makes almost
no request. The application can still feel heavy: the station list stutters while
scrolling, a letter typed into the filter box appears on screen late, the page may freeze
for a moment when the date range changes.

None of these problems are in the network. All of them come from downloaded code
running in a single queue. The Performance Recording lesson in the Browser and the Web
Platform course took up the tool that makes this queue visible; this lesson takes up how
to drain it.

#### Long Tasks and Total Blocking Time

The main thread is a queue of tasks. Once a task starts, it cannot get split: for as
long as it runs, user input cannot get processed and no frame can get produced. A task
exceeding fifty milliseconds counts as a **long task**, and the threshold's
justification is input response.

The metric is not the count of long tasks but the sum of the portions that exceed the
threshold.

```js
// blocking.mjs — total blocking time and the same work chunked into pieces

const LONG_TASK = 50; // ms

const tasks = [
  { name: "document parsing", duration: 42 },
  { name: "bundle evaluation", duration: 186 },
  { name: "station list construction", duration: 128 },
  { name: "measurement formatting", duration: 61 },
  { name: "input handler", duration: 18 },
  { name: "scroll handler", duration: 33 },
];

console.log("task".padEnd(28) + "duration".padStart(10) + "blocking".padStart(12));
let totalBlocking = 0;
for (const t of tasks) {
  const blocking = Math.max(0, t.duration - LONG_TASK);
  totalBlocking += blocking;
  console.log(t.name.padEnd(28) + `${t.duration} ms`.padStart(10) + `${blocking} ms`.padStart(12));
}
console.log("-".repeat(50));
console.log("total blocking time".padEnd(28) + `${totalBlocking} ms`.padStart(22));

// If the same work gets split into pieces not exceeding 50 ms, blocking drops to zero.
const chunked = tasks.flatMap((t) => {
  const pieces = Math.ceil(t.duration / LONG_TASK);
  return Array.from({ length: pieces }, () => t.duration / pieces);
});
const chunkedBlocking = chunked.reduce((t, s) => t + Math.max(0, s - LONG_TASK), 0);
console.log(`if the same work gets split into ${chunked.length} pieces: ${chunkedBlocking} ms of blocking`);
console.log(`total work duration unchanged: ${tasks.reduce((t, g) => t + g.duration, 0)} ms`);
```

```
task                          duration    blocking
document parsing                 42 ms        0 ms
bundle evaluation               186 ms      136 ms
station list construction       128 ms       78 ms
measurement formatting           61 ms       11 ms
input handler                    18 ms        0 ms
scroll handler                   33 ms        0 ms
--------------------------------------------------
total blocking time                         225 ms
if the same work gets split into 12 pieces: 0 ms of blocking
total work duration unchanged: 468 ms
```

The task durations are the computation's input; what gets computed is the metric
itself. Of the 468 milliseconds of work, 225 milliseconds is blocking. The
42-millisecond parsing task does not count at all, because it sits under the threshold —
the metric measures not the entirety of the work but the excess the user would wait
through.

The last two lines say what matters: when the same work gets split into pieces not
exceeding fifty milliseconds, blocking drops to zero and the total work duration does
not change at all. What gets done is not reducing the work — it is **inserting exit
points in between.**

#### Measuring Chunking

What the model claims can be measured. The following program does the same work three
different ways and, for each, records how long the event loop stays blocked using a
responsiveness probe.

```js
// chunking.mjs — measuring the same work done in one chunk versus chunked
// Responsiveness probe: reschedules itself every turn and records its delay.
function startProbe(log) {
  let running = true;
  let previous = performance.now();
  const turn = () => {
    if (!running) return;
    const now = performance.now();
    log.push(now - previous);
    previous = now;
    setTimeout(turn, 0);
  };
  setTimeout(turn, 0);
  return () => { running = false; };
}

const ROWS = 300_000;
function rowText(i) {
  // Some work per row: producing a formatted measurement line.
  const temperature = -12 + ((i * 37) % 240) / 10;
  const timestamp = new Date(Date.UTC(2026, 0, 1, 0, 0, i % 60)).toISOString();
  return `north-slope-${String(i).padStart(5, "0")} ${timestamp} ${temperature.toFixed(1)} C`.length;
}

async function inOneChunk() {
  let total = 0;
  for (let i = 0; i < ROWS; i++) total += rowText(i);
  return total;
}

async function chunked(chunkSize) {
  let total = 0;
  for (let i = 0; i < ROWS; i++) {
    total += rowText(i);
    if ((i + 1) % chunkSize === 0) await new Promise((resolve) => setImmediate(resolve));
  }
  return total;
}

async function measure(name, work) {
  const delays = [];
  const stop = startProbe(delays);
  await new Promise((resolve) => setTimeout(resolve, 20)); // let the probe warm up
  delays.length = 0;
  const start = performance.now();
  const result = await work();
  const elapsed = performance.now() - start;
  await new Promise((resolve) => setTimeout(resolve, 20)); // let the final poll get recorded
  stop();
  const longest = Math.max(...delays);
  return { name, elapsed, longest, result, turns: delays.length };
}

const measurements = [
  await measure("in one chunk", inOneChunk),
  await measure("2000-row chunks", () => chunked(2000)),
  await measure("500-row chunks", () => chunked(500)),
];

console.log("--- same work, different chunking (durations depend on the machine) ---");
console.log("approach".padEnd(24) + "total".padStart(10) + "longest block".padStart(19) +
  "polls".padStart(9));
for (const m of measurements) {
  console.log(m.name.padEnd(24) + `${m.elapsed.toFixed(1)} ms`.padStart(10) +
    `${m.longest.toFixed(1)} ms`.padStart(19) + String(m.turns).padStart(9));
}
const baseline = measurements[0];
console.log("\nratio of longest block to the single chunk:");
for (const m of measurements) {
  console.log(`  ${m.name.padEnd(24)} ${(m.longest / baseline.longest).toFixed(3)}`);
}
console.log(`all approaches produced the same result: ${new Set(measurements.map((m) => m.result)).size === 1}`);
```

```
--- same work, different chunking (durations depend on the machine) ---
approach                     total      longest block    polls
in one chunk              123.6 ms           124.9 ms       16
2000-row chunks           126.6 ms             2.1 ms      141
500-row chunks            130.7 ms             1.7 ms      146

ratio of longest block to the single chunk:
  in one chunk             1.000
  2000-row chunks          0.017
  500-row chunks           0.014
all approaches produced the same result: true
```

The durations depend on the machine and its load; they vary on every run. What does not
change are the ratios. The total duration stays close across the three approaches —
chunking does not reduce the work, and it even adds a small per-turn overhead. The
longest block, though, drops by roughly two orders of magnitude: the single block,
measured here at about a hundred and twenty-five milliseconds, spreads into blocks of
only a couple of milliseconds.

The poll count shows why. In the single-chunk approach, control never returns to the
queue while the work runs; in the chunked approach, control returns after every chunk,
and waiting input can get processed.

This measurement was taken not in a browser but in a single-threaded runtime; it
correctly models the main thread's behavior, but the exit point in a browser is not
`setImmediate`. Its browser counterpart is a wait or an idle-callback that hands control
back to the event loop.

Chunking also has a limit: the smaller the chunk, the larger the per-turn overhead
grows. In this measurement, the 500-row chunks take a longer total than the 2000-row
chunks. The right chunk size is not "as small as possible" — it is the largest chunk
that fits inside the frame budget.

#### Taking Work Out of the Queue Entirely

Chunking keeps the work in the main queue and allows interruption. A second approach is
to take the work out of the queue entirely.

Workers, introduced in the Browser and the Web Platform course, run on a separate thread
and never occupy the main queue. The work they suit is clear: pure computation, parsing,
compression, filtering and sorting a large dataset. The work they do not suit is equally
clear — access to the document tree. Summarizing tens of thousands of measurements on
the measurement detail page can move to a worker; the result returns to the main queue
only as the rows to paint.

The cost is data transfer. Message passing requires copying, and on large arrays this
copying can eat the gain; transferable objects exist for this reason.

#### Layout Thrashing Scales with Row Count

The Animation Performance lesson in the Layout Systems and Responsive Design course
defined forced synchronous layout: a write invalidates layout, and a subsequent
measurement read forces the computation immediately. Ordering reads and writes this way
repeats the computation on every turn, and this gets called **layout thrashing**.

What got counted there was the number of computations. The question here is what that
number does as the list grows.

```js
// thrashing.mjs — how layout thrashing scales with row count

const FRAME_BUDGET = 1000 / 60; // ms, assuming 60 frames per second
const COMPUTE_COST = 0.05;      // ms: the assumed cost of a single layout computation
const VISIBLE_ROWS = 20;        // rows that fit in the viewport

// Three approaches: how many layout computations does each force for N rows?
const approaches = {
  "read-write inside the loop": (n) => Math.max(0, n - 1),
  "reads then writes": () => 0,
  "only visible rows": (n) => Math.max(0, Math.min(n, VISIBLE_ROWS) - 1),
};

console.log("rows".padStart(7) +
  Object.keys(approaches).map((a) => a.padStart(30)).join(""));
for (const n of [10, 50, 200, 1000, 5000]) {
  const cells = Object.values(approaches).map((f) => {
    const computations = f(n);
    const duration = computations * COMPUTE_COST;
    return `${computations} computations / ${duration.toFixed(1)} ms`.padStart(30);
  });
  console.log(String(n).padStart(7) + cells.join(""));
}

console.log(`\nframe budget: ${FRAME_BUDGET.toFixed(2)} ms`);
console.log("how many frame budgets get consumed:");
console.log("rows".padStart(7) +
  Object.keys(approaches).map((a) => a.padStart(30)).join(""));
for (const n of [10, 50, 200, 1000, 5000]) {
  const cells = Object.values(approaches).map((f) =>
    (f(n) * COMPUTE_COST / FRAME_BUDGET).toFixed(1).padStart(30));
  console.log(String(n).padStart(7) + cells.join(""));
}

console.log("\nthe compute cost is an assumption; when it changes, the columns' ratios do not.");
```

```
   rows    read-write inside the loop             reads then writes             only visible rows
     10       9 computations / 0.5 ms       0 computations / 0.0 ms       9 computations / 0.5 ms
     50      49 computations / 2.5 ms       0 computations / 0.0 ms      19 computations / 1.0 ms
    200    199 computations / 10.0 ms       0 computations / 0.0 ms      19 computations / 1.0 ms
   1000    999 computations / 50.0 ms       0 computations / 0.0 ms      19 computations / 1.0 ms
   5000  4999 computations / 250.0 ms       0 computations / 0.0 ms      19 computations / 1.0 ms

frame budget: 16.67 ms
how many frame budgets get consumed:
   rows    read-write inside the loop             reads then writes             only visible rows
     10                           0.0                           0.0                           0.0
     50                           0.1                           0.0                           0.1
    200                           0.6                           0.0                           0.1
   1000                           3.0                           0.0                           0.1
   5000                          15.0                           0.0                           0.1

the compute cost is an assumption; when it changes, the columns' ratios do not.
```

On a ten-row list there is no difference between the three approaches; this is why the
problem stays invisible during development. At five thousand rows, the first approach
consumes fifteen frame budgets — meaning no frame gets produced for a quarter of a
second.

The two solutions differ in character. Batching reads and writes drops the cost to
**zero** and is independent of list size; processing only the visible rows **fixes** the
cost, and it does not change no matter how large the list grows. The second leaves the
thrashing itself in place but cuts off its scaling.

#### Not Painting What Is Not Visible

The idea of processing only what is visible is not limited to layout thrashing; it
applies to the browser's entire rendering pipeline.

**Virtualization** puts only the list's visible portion into the document tree. Nodes
get reused as the list scrolls. The cost is that content absent from the tree is also
absent from in-page search and from the accessibility tree.

**Content visibility** approaches the same goal without breaking the markup: the
`content-visibility` property lets paint and layout work get skipped while a box is
off-screen. The skipped box still has to occupy space; otherwise the scrollbar jumps as
content comes into view. This is why an estimated size gets given along with the
declaration.

**Containment**, in turn, keeps a box's effect confined to its own boundaries. The
`contain` declaration, introduced in the Animation Performance lesson, prevents a change
inside a card from spreading its layout computation across the entire page.

#### Interaction Delay's Three Components

Interaction to next paint is a single number, but it is the sum of three separate
delays, and their fixes differ.

**Input delay** is the time between the user interaction and the handler starting. Its
cause is another task running at that moment. Its fix is chunking or moving the work to
a worker; speeding up the handler does not touch this portion.

**Processing time** is the handler's own duration. Its fix is shrinking the handler and
deferring work that can be deferred: make the change the user sees immediately, leave
logging and analytics for later.

**Presentation delay** is the time between the handler finishing and the frame getting
produced. Its cause is the size of the layout and paint work the handler triggers. Its
fix is shrinking the area that gets updated.

A recording showing which component grew also tells you which fix not to try. An
optimization done without measurement often gets applied to the wrong component.

#### Summary

- Total blocking time is the sum of the portions of long tasks that exceed the
  fifty-millisecond threshold; chunking the work can drop this metric to zero without
  changing the total duration.
- Measurement confirms this: when the same work gets chunked, the longest block drops by
  roughly two orders of magnitude while the total duration stays close; the smaller the
  chunk, the larger the per-turn overhead grows.
- Workers take work out of the main queue entirely; their limits are not being able to
  access the document tree and the cost of copying data.
- Layout thrashing's cost grows linearly with row count; batching drops the cost to
  zero, working with only the visible rows fixes it.
- Interaction to next paint is the sum of input delay, processing time, and presentation
  delay, and each component's fix is different.

#### Next Step

Five lessons defined the metrics, shortened the critical path, sized the assets, tuned
the network layer, and drained the runtime. All of this shares one gap: every
improvement was made once. When a new feature gets added, the bundle grows; when a new
image gets placed, the first screen's weight increases; when a library enters the shared
chunk, the repeat-visit gain erodes. This erosion needs to get noticed at the next
release, not months later through a complaint. The next lesson ties the metrics to a
written budget and builds a checker that compares two build outputs to catch
regressions.

### Performance Budget

Source: https://academia.sh/en/courses/frontend-quality/performance-budget

Tying performance decisions to a written limit; budget types, where the limit gets derived from, a program that checks build output against the budget, and catching regressions by comparing two builds.

The previous five lessons built a series of improvements: the metrics got defined, the
critical path got shortened, the assets got sized, the network layer got tuned, the
runtime got drained. All of them share the same gap — they were done once.

When a new feature gets added, the bundle grows; when a library enters the shared
chunk, the repeat-visit gain erodes. This erosion needs to get noticed at the next
release. This lesson builds that mechanism.

#### A Budget Is Not a Target

The two get confused, and when they do, a budget loses its function.

A **target** is a value someone wants to reach. It has direction and no enforcement;
nothing happens when it gets exceeded.

A **budget** is a limit that is not allowed to get exceeded. Something happens when it
does: the build counts as failed, the merge stops, the release does not ship. A number
with no enforcement is not a budget.

The practical consequence of this distinction is that a budget gets drawn where it is
reachable. A limit set far below the current value shows red from day one and quickly
starts getting ignored. The right approach is to set the limit slightly above today's
value and lower it over time.

#### Three Budget Types

Budgets get separated by what they measure, and all three are needed, because each one
catches what the other misses.

A **metric budget** sets a limit on user-centric metrics: largest contentful paint stays
under this value, cumulative layout shift does not exceed this score. Because it
directly measures the experience, it is the most meaningful budget; it is also the
noisiest, because it is sensitive to measurement conditions.

A **quantity budget** sets a limit on countable magnitudes: chunk sizes, request count,
image weight, font file count. It is deterministic — the same build always produces the
same number — and so it is the budget best suited to enforcement. Its link to the
experience is indirect: a small bundle does not always mean a fast page.

A **rule budget** contains not a number but a condition: no render-blocking external
stylesheet on the first screen, no synchronous script in the document, no lazy-loading of
the image that is a candidate for largest contentful paint. It protects decisions that
cannot get reduced to a number.

A solid setup combines all three: quantity and rule budgets get checked on every build,
the metric budget at regular intervals.

#### Where the Limit Comes From

For a number to be a budget, it needs a justification. There are three ways to justify
one.

**Deriving from a metric's standard threshold.** The classification limits noted in the
first lesson — 2.5 seconds for largest contentful paint, 0.1 for cumulative layout shift,
200 milliseconds for interaction to next paint — can get written directly as a metric
budget. The check applies to the 75th percentile.

**Working backward from a metric to a quantity.** A target metric value gets chosen, a
target network and device profile gets assumed, and the budget in between gets converted
to bytes. The round-trip model from the Critical Rendering Path lesson is the skeleton of
this computation: once how many rounds can get spent is decided, how many bytes can get
sent follows.

**Leaving headroom above today's value.** The most practical and least ambitious way. A
small margin gets added on top of the measured value, and the limit gets set there. This
budget delivers no improvement; it **stops things from getting worse.** For most teams,
this is the right starting point.

Whichever way gets chosen, the budget must be written down and live in the repository. A
limit no one can see is a limit that does not exist.

#### The Checker

The following program compares a build manifest against the budget, then compares two
builds and reports regressions.

```js
// budget.mjs — compares chunk sizes against a budget and finds regressions between two builds

const KB = 1024;

// Budget: the aggregation rule per group, the upper limit (bytes), and the growth tolerance (ratio).
// "sum" measures the whole group; "largest" measures the single heaviest piece in the group.
const BUDGET = {
  entry:  { aggregation: "sum",     limit: 170 * KB, tolerance: 0.03 },
  shared: { aggregation: "sum",     limit: 120 * KB, tolerance: 0.03 },
  route:  { aggregation: "largest", limit:  45 * KB, tolerance: 0.05 },
  style:  { aggregation: "sum",     limit:  40 * KB, tolerance: 0.05 },
};
const TOTAL_LIMIT = 360 * KB;

// Build manifest: each piece's name, group, and compressed size.
const previousBuild = [
  { name: "entry.4a1c.js",            group: "entry",  bytes: 148_112 },
  { name: "shared.9d02.js",           group: "shared", bytes: 101_760 },
  { name: "route-stations.1f77.js",   group: "route",  bytes:  38_240 },
  { name: "route-measurements.55be.js", group: "route", bytes:  21_904 },
  { name: "station.6b3e.css",         group: "style",  bytes:  31_648 },
];

const newBuild = [
  { name: "entry.7e90.js",            group: "entry",  bytes: 151_296 },
  { name: "shared.c214.js",           group: "shared", bytes: 124_368 },
  { name: "route-stations.02ab.js",   group: "route",  bytes:  39_120 },
  { name: "route-measurements.55be.js", group: "route", bytes:  21_904 },
  { name: "route-report.8c4d.js",     group: "route",  bytes:  17_856 },
  { name: "station.a9f1.css",         group: "style",  bytes:  32_960 },
];

const kb = (bytes) => `${(bytes / KB).toFixed(1)} KB`;

function sumByGroup(build) {
  const measure = new Map();
  for (const [group, { aggregation }] of Object.entries(BUDGET)) {
    const pieces = build.filter((p) => p.group === group).map((p) => p.bytes);
    measure.set(group, aggregation === "largest"
      ? Math.max(0, ...pieces)
      : pieces.reduce((a, b) => a + b, 0));
  }
  return measure;
}

const grandTotal = (build) => build.reduce((t, p) => t + p.bytes, 0);

// --- 1. Budget check ---
function check(build, title) {
  const totals = sumByGroup(build);
  let passed = true;
  console.log(`--- ${title} ---`);
  console.log("group".padEnd(8) + "rule".padEnd(9) + "size".padStart(11) +
    "limit".padStart(11) + "usage".padStart(10) + "  result");
  for (const [group, { limit, aggregation }] of Object.entries(BUDGET)) {
    const size = totals.get(group);
    const usage = size / limit;
    const result = size <= limit ? "pass" : "FAIL";
    if (size > limit) passed = false;
    console.log(group.padEnd(8) + aggregation.padEnd(9) + kb(size).padStart(11) +
      kb(limit).padStart(11) + `${(usage * 100).toFixed(1)}%`.padStart(10) + "  " + result);
  }
  const overallTotal = grandTotal(build);
  const totalResult = overallTotal <= TOTAL_LIMIT ? "pass" : "FAIL";
  if (overallTotal > TOTAL_LIMIT) passed = false;
  console.log("-".repeat(55));
  console.log("TOTAL".padEnd(8) + "sum".padEnd(9) + kb(overallTotal).padStart(11) +
    kb(TOTAL_LIMIT).padStart(11) +
    `${((overallTotal / TOTAL_LIMIT) * 100).toFixed(1)}%`.padStart(10) + "  " + totalResult);
  console.log(`budget check: ${passed ? "pass" : "FAIL"}\n`);
  return passed;
}

check(previousBuild, "previous build");
const newPassed = check(newBuild, "new build");

// --- 2. Regression detection: growth that stays under the limit also gets reported ---
console.log("--- comparing the two builds ---");
const previousTotals = sumByGroup(previousBuild);
const newTotals = sumByGroup(newBuild);

console.log("group".padEnd(8) + "previous".padStart(11) + "new".padStart(11) +
  "diff".padStart(11) + "ratio".padStart(9) + "  warning");
const warnings = [];
for (const [group, { tolerance }] of Object.entries(BUDGET)) {
  const a = previousTotals.get(group);
  const b = newTotals.get(group);
  const ratio = (b - a) / a;
  const exceeded = ratio > tolerance;
  if (exceeded) warnings.push(`${group}: ${(ratio * 100).toFixed(1)}% growth (tolerance ${tolerance * 100}%)`);
  console.log(group.padEnd(8) + kb(a).padStart(11) + kb(b).padStart(11) +
    `${b - a >= 0 ? "+" : ""}${((b - a) / KB).toFixed(1)} KB`.padStart(11) +
    `${ratio >= 0 ? "+" : ""}${(ratio * 100).toFixed(1)}%`.padStart(9) +
    "  " + (exceeded ? "TOLERANCE EXCEEDED" : "-"));
}

// New pieces get reported separately: because the address changes, the name comparison
// happens with the content hash stripped out.
const rootName = (name) => name.replace(/\.[0-9a-f]{4,}\./, ".");
const previousNames = new Set(previousBuild.map((p) => rootName(p.name)));
const newPieces = newBuild.filter((p) => !previousNames.has(rootName(p.name)));

console.log(`\nnew pieces: ${newPieces.map((p) => `${rootName(p.name)} (${kb(p.bytes)})`).join(", ") || "(none)"}`);
if (warnings.length === 0) console.log("regression warning: (none)");
else {
  console.log("regression warning:");
  for (const w of warnings) console.log(`  ${w}`);
}

const exit = newPassed && warnings.length === 0 ? 0 : 1;
console.log(`\nexit code: ${exit}`);
process.exitCode = exit;
```

```
--- previous build ---
group   rule            size      limit     usage  result
entry   sum         144.6 KB   170.0 KB     85.1%  pass
shared  sum          99.4 KB   120.0 KB     82.8%  pass
route   largest      37.3 KB    45.0 KB     83.0%  pass
style   sum          30.9 KB    40.0 KB     77.3%  pass
-------------------------------------------------------
TOTAL   sum         333.7 KB   360.0 KB     92.7%  pass
budget check: pass

--- new build ---
group   rule            size      limit     usage  result
entry   sum         147.8 KB   170.0 KB     86.9%  pass
shared  sum         121.5 KB   120.0 KB    101.2%  FAIL
route   largest      38.2 KB    45.0 KB     84.9%  pass
style   sum          32.2 KB    40.0 KB     80.5%  pass
-------------------------------------------------------
TOTAL   sum         378.4 KB   360.0 KB    105.1%  FAIL
budget check: FAIL

--- comparing the two builds ---
group      previous        new       diff    ratio  warning
entry      144.6 KB   147.8 KB    +3.1 KB    +2.1%  -
shared      99.4 KB   121.5 KB   +22.1 KB   +22.2%  TOLERANCE EXCEEDED
route       37.3 KB    38.2 KB    +0.9 KB    +2.3%  -
style       30.9 KB    32.2 KB    +1.3 KB    +4.1%  -

new pieces: route-report.js (17.4 KB)
regression warning:
  shared: 22.2% growth (tolerance 3%)

exit code: 1
```

The build manifests are the program's input; they are not the output of a measured
project but two lists written to demonstrate the checker's behavior.

#### What the Output Says

The `aggregation` field in the budget definition carries an important distinction. For
the entry, shared, and style groups, the limit applies to the **sum**: all of these
pieces load on every page. For the route group, the limit applies to the **single
largest piece**, because the user loads only one route at a time. The wrong aggregation
rule breaks the budget unfairly when a new route gets added.

The new build fails in two places. The shared piece exceeds its own limit by one and a
bit percent; the total exceeds its limit by five percent. The real reason the total gets
exceeded is the shared piece's growth, but the newly added route piece also contributes
to the total. Reading the two lines together tells you whether the problem comes from a
single change or from accumulation.

The comparison table catches what the budget cannot. The entry piece grew by 2.1 percent
and is still well under the limit; it produces no warning. The shared piece grew by 22
percent, and because the tolerance is 3 percent, it produces a warning — this warning
would have fired even if the piece had stayed under the limit.

This is the distinction. **A budget protects an absolute limit; a tolerance protects the
rate of change.** If only the budget gets checked, a bundle growing 2 percent with every
release stays silent for months and then suddenly crosses the limit one day; by that day,
which change was responsible can no longer be found.

This is also why new pieces get reported separately. Because content-hashed names change
with every release, the comparison happens with the hash stripped out; otherwise every
piece would look new.

#### The Budget's Place in the Deployment Pipeline

The checker returning an exit code is part of the design: in a validation step that runs
after the build, a nonzero exit code stops the step. The convention established in the
Shell Programming course is what carries the enforcement here.

Two decisions are required. **Where does it run?** A check that runs on every change
finds the problem before it gets merged; a check that runs only on the main branch
requires searching for the responsible change once the problem is found.

**What does it do?** The choice between a budget overage stopping the build and merely
producing a warning determines how serious the budget is. A budget that does not stop
the build turns into a target. Even so, an escape hatch is required: the limit must be
raisable deliberately, and raising it must leave a record. When the change made to the
budget file goes through review, the escape hatch gets checked too.

#### Noise and False Positives

Quantity budgets are deterministic; the same input produces the same number. Metric
budgets are not, and applied directly they produce false positives.

Three precautions are required. Measurement gets taken **multiple times** and the median
gets used; a single run is not enough for a decision. Measurement conditions get
**fixed**: the same network throttle, the same device profile, a browser with no
extensions — the reproducibility conditions from the Network Panel Diagnostics lesson are
the budget's precondition here. And the threshold gets chosen **clearly larger** than the
measurement's natural variability; a tolerance that sits inside the noise produces a
check that stays permanently red and therefore gets ignored.

There is also a scope decision. A budget applies not to every page but to
representative pages: the station list page and the measurement detail page. The first
lesson showed that these two pages get judged by different metrics; their budgets, for
the same reason, get written separately.

#### Summary

- A budget is a limit where something happens when it gets exceeded; a number with no
  enforcement is a target, and it does not stop things from getting worse.
- The three budget types cover each other's gaps: the metric budget measures the
  experience but is noisy, the quantity budget is deterministic but indirect, the rule
  budget protects decisions that do not reduce to a number.
- A budget group's aggregation rule changes the outcome: pieces loaded on every page get
  summed, and for optional pieces the largest one gets measured.
- An absolute limit and a growth tolerance protect different things; when only the limit
  gets checked, small and steady growth stays invisible until it reaches the limit.
- Metric budgets can only get tied to enforcement once measurement conditions are fixed
  and the median of multiple runs gets taken.

#### Next Step

Performance is no longer a matter of opinion: it has defined metrics, written limits,
and a check that runs on every build. That is not the whole of the quality axis, though.
A page can load fast and still be unusable — a list that cannot get navigated with a
keyboard, a button that tells a screen reader nothing, text with insufficient contrast.
These problems do not have to be a matter of opinion either; they too have defined
metrics, measurable thresholds, and conformance levels. The next lesson takes up
accessibility standards and defines usability with the same discipline — testable
criteria.

## Accessibility

The structure of accessibility criteria and conformance levels; how native elements map onto the accessibility tree, accessible name computation, correct and incorrect use of ARIA, keyboard access, live regions, contrast thresholds, and the limits of automated auditing.

### Accessibility Standards

Source: https://academia.sh/en/courses/frontend-quality/accessibility-standards

The four principles that make accessibility measurable, the structure of criterion numbers, the conjunction rule for conformance levels, and conformance being defined at the scale of a process rather than a page.

Performance was tied to a measurable budget at this point: a threshold was written for
each metric, a change that crossed the threshold counted as a regression, and the
regression was caught before release. The value of this discipline was moving the
decision out of debate and into measurement — the question "is the page fast" was
replaced by "which metric crosses which threshold."

The same discipline is needed on a second quality axis. Whether an interface is usable
cannot be measured if it is left as an impression a sighted user forms by trying it with
a mouse; what cannot be measured cannot be caught when it regresses. This lesson builds
the structure that answers the question: usability is tied to criteria that are countable
and testable as true or false.

#### Four Principles

Accessibility criteria are grouped under four principles. Principles are not criteria;
they cannot be tested. They are the framework that groups criteria and states which
question each one answers.

**Perceivable.** Content and interface components must be presented so a user can
perceive them. If a piece of information depends on a single sense alone, it does not
exist for a user who cannot use that sense. An image's alternative text, a video's
captions, color not being the sole carrier, and a text's contrast threshold all fall
under this principle.

**Operable.** Interface components and navigation must be usable. What this measures is
not whether the user reaches the information, but whether they can **operate** the
interface: reaching every control by keyboard, being able to exit wherever they entered,
seeing where focus is, having enough time.

**Understandable.** Information and the operation of the interface must be
understandable. Language declaration, predictable behavior, error identification, and
correction suggestions belong to this principle. A page that changes on its own when a
form field takes focus produces an interface that works but is not understood.

**Robust.** Content must be robust enough to be interpreted reliably by a wide variety of
user agents, including assistive technologies. This principle requires the markup we
produce to rely on defined contracts rather than the behavior of one particular
implementation. A component's name, role, and value being programmatically readable is
the criterion for this principle.

The order of the four principles is not arbitrary; it forms a dependency chain: something
that cannot be perceived cannot be operated, the understandability of something that
cannot be operated is never asked, and if none of them is declared robustly, none of the
three reaches assistive technology.

#### What the Criterion Number Says

Every criterion has a three-part number, and the number alone locates what is being
discussed.

The first digit is the **principle**: 1 perceivable, 2 operable, 3 understandable, 4
robust. The second digit is the **guideline** under the principle; guidelines cannot be
tested either — they group criteria by topic (2.4 navigability, 3.3 input assistance, and
so on). The third digit is the **success criterion** under that guideline, and it is the
only testable unit.

So when the number 2.4.7 is read, it is known to belong to the operable principle's
navigation guideline even if the criterion's name is unknown. Numbers are never reused
once published: when a criterion is added it gets a new number, and the meaning of
existing numbers does not change. This lets two people understand the same thing from the
same number, and it is why writing the criterion number in a bug report is valuable.

The shared property of criteria is that they are **testable**: each is written so it can
be answered true or false for a given piece of content. "The page should be accessible"
is not a criterion; "the contrast ratio between the text and its background must be at
least 4.5:1" is.

#### Conformance Levels

Criteria are distributed across three conformance levels: A, AA, and AAA. The level does
not describe the criterion's importance but the **cost of meeting it and the breadth of
its scope**. When level A is not met, user groups cannot use the interface at all; level
AA removes barriers that seriously hinder use; level AAA is additional requirements that
may not apply to every content type.

Levels are cumulative and evaluated by a **conjunction** rule: AA conformance means every
A criterion and every AA criterion is met. If even one A criterion fails, the surface
conforms to no level at all. The script below applies this rule to three surfaces of the
North Slope Measurement Station interface.

```js
// conformance.mjs — deriving the principle from a criterion number and computing conformance level

const PRINCIPLE = { 1: "perceivable", 2: "operable", 3: "understandable", 4: "robust" };

const CRITERION = {
  "1.1.1": ["Non-text Content", "A"],
  "1.3.1": ["Info and Relationships", "A"],
  "1.4.1": ["Use of Color", "A"],
  "1.4.3": ["Contrast (Minimum)", "AA"],
  "1.4.6": ["Contrast (Enhanced)", "AAA"],
  "1.4.11": ["Non-text Contrast", "AA"],
  "2.1.1": ["Keyboard", "A"],
  "2.1.2": ["No Keyboard Trap", "A"],
  "2.4.1": ["Bypass Blocks", "A"],
  "2.4.3": ["Focus Order", "A"],
  "2.4.7": ["Focus Visible", "AA"],
  "2.5.3": ["Label in Name", "A"],
  "3.3.1": ["Error Identification", "A"],
  "3.3.2": ["Labels or Instructions", "A"],
  "3.3.3": ["Error Suggestion", "AA"],
  "3.3.5": ["Help", "AAA"],
  "4.1.2": ["Name, Role, Value", "A"],
  "4.1.3": ["Status Messages", "AA"],
};

const LEVELS = ["A", "AA", "AAA"];

// North Slope station's three surfaces: "passed" | "failed" | "not-applicable"
const FAILED = {
  "measurement-list": ["1.4.3", "1.4.6", "4.1.3"],
  "measurement-entry": ["1.4.6", "3.3.5", "4.1.2"],
  "submission-confirmation": ["3.3.5"],
};
const NOT_APPLICABLE = {
  "measurement-list": ["3.3.1", "3.3.2", "3.3.3"],
  "measurement-entry": [],
  "submission-confirmation": ["3.3.1", "3.3.2", "3.3.3"],
};

const results = (surface) =>
  Object.fromEntries(
    Object.keys(CRITERION).map((n) => [
      n,
      FAILED[surface].includes(n) ? "failed" : NOT_APPLICABLE[surface].includes(n) ? "not-applicable" : "passed",
    ]),
  );

const levelMet = (s, level) =>
  Object.entries(CRITERION).filter(([, [, l]]) => l === level).every(([n]) => s[n] !== "failed");

function conformanceLevel(s) {
  let result = "not conformant";
  for (const l of LEVELS) {
    if (!levelMet(s, l)) break;
    result = l;
  }
  return result;
}

console.log("surface                    passed failed not-applicable   pct    conformance");
for (const surface of Object.keys(FAILED)) {
  const s = results(surface);
  const d = Object.values(s);
  const passed = d.filter((v) => v === "passed").length;
  const failed = d.filter((v) => v === "failed").length;
  const na = d.filter((v) => v === "not-applicable").length;
  console.log(
    `${surface.padEnd(26)} ${String(passed).padStart(6)} ${String(failed).padStart(6)} ${String(na).padStart(14)}  ` +
      `${((100 * passed) / (passed + failed)).toFixed(1).padStart(5)}  ${conformanceLevel(s).padStart(14)}`,
  );
}

console.log("\nremaining criteria:");
for (const surface of Object.keys(FAILED)) {
  for (const n of FAILED[surface]) {
    const [name, level] = CRITERION[n];
    console.log(`  ${surface.padEnd(26)} ${n.padEnd(7)} ${level.padEnd(4)} ${PRINCIPLE[n[0]].padEnd(16)} ${name}`);
  }
}

const PROCESS = ["measurement-list", "measurement-entry", "submission-confirmation"];
const lowest = PROCESS
  .map((y) => conformanceLevel(results(y)))
  .reduce((a, b) => (LEVELS.indexOf(a) < LEVELS.indexOf(b) ? a : b));
console.log(`\nprocess: ${PROCESS.join(" -> ")}`);
console.log(`  process conformance level: ${lowest}`);

const all = PROCESS.flatMap((y) => Object.values(results(y)));
const p = all.filter((v) => v === "passed").length;
const f = all.filter((v) => v === "failed").length;
console.log(`  criterion pass rate: ${((100 * p) / (p + f)).toFixed(1)}`);
console.log(`  criteria audited for AA: ${Object.values(CRITERION).filter(([, l]) => l !== "AAA").length}`);
```

```
surface                    passed failed not-applicable   pct    conformance
measurement-list               12      3              3   80.0               A
measurement-entry              15      3              0   83.3  not conformant
submission-confirmation        14      1              3   93.3              AA

remaining criteria:
  measurement-list           1.4.3   AA   perceivable      Contrast (Minimum)
  measurement-list           1.4.6   AAA  perceivable      Contrast (Enhanced)
  measurement-list           4.1.3   AA   robust           Status Messages
  measurement-entry          1.4.6   AAA  perceivable      Contrast (Enhanced)
  measurement-entry          3.3.5   AAA  understandable   Help
  measurement-entry          4.1.2   A    robust           Name, Role, Value
  submission-confirmation    3.3.5   AAA  understandable   Help

process: measurement-list -> measurement-entry -> submission-confirmation
  process conformance level: not conformant
  criterion pass rate: 85.4
  criteria audited for AA: 16
```

The three rows show three separate situations. The measurement list meets every A
criterion but misses two AA criteria; its level is A. The measurement entry passes
fifteen criteria, yet because it fails a single A criterion (4.1.2 Name, Role, Value) it
conforms to no level at all. The submission confirmation misses only one AAA criterion
and is at level AA.

The **not-applicable** column is not an exemption. Because the submission confirmation
page has no form, the error criteria are not evaluated on that surface; the moment a form
is added to the page, three criteria come back into scope. A not-applicable result means
the criterion has no counterpart in that content — not that a criterion with a
counterpart was skipped.

#### Conformance Is a Property of the Process, Not the Page

The last part of the script shows why conformance cannot be reported surface by surface.
Submitting a measurement is a process that passes through three surfaces: the user
selects a record from the list, fills in the entry form, and sees the result on the
confirmation surface. If one link in the chain breaks, the process cannot be completed;
that is why the process's conformance level is the lowest of its links.

This rule has three extensions, and all three change a decision in practice.

**The whole page counts.** A surface's conformance is the conformance of the whole
surface, not of one part of it. A third-party component tucked in a corner of the page is
in the same scope.

**A process counts as a whole.** The listing surface alone being conformant does not mean
the measurement-submission process is conformant.

**Non-conforming content must not interfere.** Even when a section is left out of the
conformance scope, it must not block use of the rest of the page. A component that traps
focus, plays audio automatically, or locks keyboard navigation cannot be excused by being
declared out of scope.

A fourth condition concerns the choice of technology: the markup and scripts used must be
supported by assistive technologies. This condition excludes from conformance a component
declared in a way that has no defined counterpart — even if the component works in one
particular implementation.

#### The Difference Between a Budget and a Criterion

The difference between a performance budget and a conformance rule shows up in the
output's last lines. 85.4 percent of criteria pass across the three surfaces; even so,
the process has no conformance level. That percentage feeds no decision.

The reason is that the two measures have different mathematical structures. Performance
metrics are defined over a distribution: a load-time metric asks that 75 percent of users
stay below a given threshold, and the remaining 25 percent are knowingly accepted.
Accessibility criteria are a conjunction: either every criterion is met or there is no
conformance. There is no averaging, because the user does not navigate an average page —
they stop at the one surface they cannot use.

This does not mean accessibility cannot be budgeted; the shape of the budget changes. In
performance the budget is a list of thresholds; in accessibility it is a **count of
zero**: the release that ships must have zero unmet A and AA criteria. Known, not-yet-fixed
defects are kept in an open list with the criterion number, the affected surface, and a
fix date. The definition of a regression is the same: if a criterion that passed
yesterday fails today, that is a regression.

#### Summary

- Accessibility criteria are grouped under four principles: perceivable, operable,
  understandable, and robust. Principles are not tested; they group criteria.
- The criterion number's first digit gives the principle, the second the guideline, and
  the third the testable criterion; numbers are never reused, and they give bug reports a
  shared language.
- Conformance levels are cumulative: AA conformance means every A and AA criterion is
  met; when a single A criterion fails, the surface conforms to no level.
- Conformance is meaningful at the scale of a process, not a surface; the whole page
  counts, a process counts as a whole, and out-of-scope content must not block the rest.
- A performance budget is a distribution threshold; accessibility conformance is a
  conjunction — the criterion pass rate produces no decision, the count of unmet A and AA
  criteria does.

#### Next Step

This lesson built the framework of criteria but did not open up the structure that the
criteria inspect. Criterion 4.1.2 requires a component's name, role, and value to be
programmatically readable; where does that triple live, and who produces it? The answer
is the accessibility tree introduced in the Semantic Markup and Media topic: the browser
derives a second tree from the document tree, and assistive technologies read that tree.
The next lesson takes up this derivation at the rule level, showing which element
produces which role and in what order an element's accessible name is computed.

### The Role of Semantic Markup

Source: https://academia.sh/en/courses/frontend-quality/role-of-semantic-markup

How the accessibility tree is derived from the document tree, the implicit roles native elements produce, the order in which the accessible name is computed, and an accounting of the cost paid for a meaningless container.

The previous lesson put criterion 4.1.2 on the list: an interface component's **name**,
**role**, and **value** must be programmatically readable. For the criterion to be
testable, this triple must actually exist somewhere. A button's name does not live in the
page's visual appearance; it lives in a second structure derived from the document.

That structure is the **accessibility tree** introduced in the Semantic Markup and Media
topic. This lesson takes up how the tree is derived at the rule level: which node enters
the tree, where the role of one that enters comes from, and in what order its name is
computed. All three answers live in the markup; none is read from the presentation layer.

#### The Tree Is Derived From the Tree

The browser walks the Document Object Model tree and builds a second tree. The second
tree's nodes are not the elements themselves but their **accessibility counterparts**:
each node carries a role, a name, a description, and a set of states.

Some nodes never enter the tree during derivation. The pruning rules are these: elements
not rendered in the presentation layer or whose visibility has been removed do not enter;
elements carrying the `hidden` attribute do not enter; an element declaring
`aria-hidden="true"` and its whole subtree do not enter; an image whose alternative text
has been left empty is treated as presentational and does not enter.

This produces a distinction. There are two ways to remove an element from the screen, and
they give different results in the tree. Taking an element out of rendering in the
presentation layer also deletes it from the tree; moving it off-screen or making it
transparent keeps it in the tree. A skip link being able to work while invisible rests on
the second path: the link is in the tree, it takes focus, and it is only invisible to the
eye.

#### Implicit Role

Every node that enters the tree has a role, and when no declaration is written, that role
comes from the element itself. This is called the **implicit role**. Part of the mapping
is conditional: the same element produces a different role depending on an attribute.

The `a` element produces the link role only when it carries an `href` attribute; without
one it is a meaningless node in the tree. The `input` element's role comes from its type
attribute: a text field, a search field, a number field, and a checkbox are separate
roles. The `th` element's role is column header or row header depending on its scope
attribute. The mapping from sectioning elements to landmark roles, and the conditions on
that mapping, were established in the Web Fundamentals and HTML course; here the same
derivation continues for form controls.

#### Computing the Accessible Name

The question after role is name. The **accessible name** is the string that introduces a
node to the user, and it does not come from a single source: it is computed by a defined
order of precedence. The script below implements the local part of that order. ARIA
sources sit at the very top of the order and are the next lesson's subject; here we see
what happens without them.

```js
// name.mjs — implicit role of native elements and accessible name computation (ARIA sources excluded)

function role(d) {
  const a = d.attrs ?? {};
  switch (d.element) {
    case "a": return a.href === undefined ? "generic" : "link";
    case "button": return "button";
    case "input":
      return { checkbox: "checkbox", radio: "radio", number: "spinbutton",
               search: "searchbox", submit: "button" }[a.type ?? "text"] ?? "textbox";
    case "select": return "combobox";
    case "table": return "table";
    case "th": return a.scope === "row" ? "rowheader" : "columnheader";
    case "td": return "cell";
    case "tr": return "row";
    case "fieldset": return "group";
    case "img": return a.alt === "" ? "— (presentational)" : "image";
    case "ul": return "list";
    case "li": return "listitem";
    case "h1": case "h2": case "h3": return "heading";
    case "output": return "status";
    default: return "generic";
  }
}

// roles that take a name from content in ARIA
const FROM_CONTENT = new Set(["button", "link", "heading", "cell", "columnheader",
  "rowheader", "listitem", "checkbox", "radio", "option"]);

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

// Order: local source -> content text (roles that allow it) -> last resort
function name(d, labels) {
  const a = d.attrs ?? {};
  if (a.hidden) return ["", "hidden"];
  if (d.element === "img" && typeof a.alt === "string" && a.alt !== "") return [a.alt, "alt"];
  for (const [el, sub] of [["table", "caption"], ["fieldset", "legend"], ["figure", "figcaption"]]) {
    if (d.element !== el) continue;
    const c = (d.children ?? []).find((x) => x.element === sub);
    if (c) return [text(c), sub];
  }
  if (["input", "select", "textarea"].includes(d.element)) {
    if (labels[a.id]) return [labels[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"];
  }
  if (FROM_CONTENT.has(role(d))) {
    const m = text(d);
    if (m) return [m, "content text"];
  }
  if (a.title) return [a.title, "title (last resort)"];
  return ["", "no name"];
}

// --- North Slope: examples from the filter panel and the measurement table --
const LABELS = {
  "filter-code": "Station code",
  "filter-alert": "Only measurements with an alert",
  value: "Measured value (°C)",
};

const EXAMPLE = [
  ["<button>Filter</button>", { element: "button", children: ["Filter"] }],
  ['<button><img alt="Clear filter"></button>',
   { element: "button", children: [{ element: "img", attrs: { alt: "Clear filter" } }] }],
  ['<button><img alt=""></button>',
   { element: "button", children: [{ element: "img", attrs: { alt: "" } }] }],
  ['<input id="filter-code" type="search"> + label',
   { element: "input", attrs: { id: "filter-code", type: "search" } }],
  ['<input type="search" placeholder="station">',
   { element: "input", attrs: { id: "search", type: "search", placeholder: "station" } }],
  ['<input id="value" type="number"> + label',
   { element: "input", attrs: { id: "value", type: "number" } }],
  ['<input id="filter-alert" type="checkbox"> + label',
   { element: "input", attrs: { id: "filter-alert", type: "checkbox" } }],
  ["<table><caption>Daily measurements</caption>",
   { element: "table", children: [{ element: "caption", children: ["Daily measurements"] }] }],
  ["<fieldset><legend>Measurement source</legend>",
   { element: "fieldset", children: [{ element: "legend", children: ["Measurement source"] }] }],
  ['<a href="/stations/">Stations</a>',
   { element: "a", attrs: { href: "/stations/" }, children: ["Stations"] }],
  ["<a>Stations</a>", { element: "a", children: ["Stations"] }],
  ['<div class="button">Save</div>', { element: "div", children: ["Save"] }],
  ['<th scope="col">Temperature</th>',
   { element: "th", attrs: { scope: "col" }, children: ["Temperature"] }],
  ['<img src="alert.svg" alt="">', { element: "img", attrs: { alt: "" } }],
];

console.log("markup".padEnd(51) + "role".padEnd(20) + "accessible name".padEnd(35) + "source");
for (const [markup, node] of EXAMPLE) {
  const [n, from] = name(node, LABELS);
  console.log(
    markup.padEnd(51) + role(node).padEnd(20) +
    (n === "" ? "(empty)" : '"' + n + '"').padEnd(35) + from,
  );
}
```

```
markup                                             role                accessible name                    source
<button>Filter</button>                            button              "Filter"                           content text
<button><img alt="Clear filter"></button>          button              "Clear filter"                     content text
<button><img alt=""></button>                      button              (empty)                            no name
<input id="filter-code" type="search"> + label     searchbox           "Station code"                     label element
<input type="search" placeholder="station">        searchbox           "station"                          placeholder (last resort)
<input id="value" type="number"> + label           spinbutton          "Measured value (°C)"              label element
<input id="filter-alert" type="checkbox"> + label  checkbox            "Only measurements with an alert"  label element
<table><caption>Daily measurements</caption>       table               "Daily measurements"               caption
<fieldset><legend>Measurement source</legend>      group               "Measurement source"               legend
<a href="/stations/">Stations</a>                  link                "Stations"                         content text
<a>Stations</a>                                    generic             (empty)                            no name
<div class="button">Save</div>                     generic             (empty)                            no name
<th scope="col">Temperature</th>                   columnheader        "Temperature"                      content text
<img src="alert.svg" alt="">                       — (presentational)  (empty)                            no name
```

There are three places in the table worth reading closely.

**Content only produces a name for certain roles.** The set in the script enforces this:
button, link, heading, and cell roles take their name from their inner text; text field,
table, and group roles do not. That is why the text `Stations` produces a name inside a
link, while the same text inside an `a` element without an `href` produces nothing. The
distinction is not arbitrary: the text inside a text field is its **value**, not its
name; if the two were conflated, what the user typed would be mistaken for the field's
name.

**Text alternative traversal collects the names of child nodes.** The button that clears
the filter has no text inside it, only an image; the image's alternative text becomes the
button's name. The third row is the reverse case: when the same button's alternative text
is left empty, the image is treated as presentational and pruned, leaving a nameless
button. An icon appears on screen; the tree has no name. This is the most common defect
in icon buttons, and it directly fails criterion 4.1.2.

**Last-resort sources turn into a name but do not do the job.** The search field with placeholder text
gains a name. That name is text that disappears from the screen once the field is filled, and it also
carries the low contrast measured in the Color and Contrast lesson. The computation accepting it as a name
does not mean the design may put it in place of a label. The same is true of the `title` attribute: it
exists in the computation, it depends on a pointing device, and it is no substitute for a persistent
label.

#### Value and State

The last member of the triple is value, and it varies by role: a text field's value is
the text typed into it, a checkbox's value is whether it is checked, and a number field's
value is the number itself along with its lower and upper bounds. Alongside these are
**states**: disabled, required, invalid, read-only, expanded.

In native elements, all of these declarations come from attributes and are never written
separately. When `required` is written, the requirement passes into the tree; when
`disabled` is written, both the enabled state is declared and the element drops out of
focus; when `min` and `max` are written on a number field, the value range appears in the
tree. This is why constraint validation in the Web Fundamentals and HTML course does two
jobs at once: it declares the rule to the browser and the state to the accessibility
tree.

#### The Cost of a Meaningless Container

When the button that deletes a row of the measurement table is built with a `div`
element, it looks the same on screen. In the tree, six declarations are missing at once,
and each one has to be put back by hand: the role (that it is a button), the accessible
name, focusability, operability with the space and enter keys, the declaration of the
disabled state, and its relationship to form submission.

Five of these six items are recovered by changing a single element name in the markup.
Preferring a native element is not a matter of style; it drives the number of
declarations that must be reproduced down to zero. The same accounting applies to a
table: in a grid built from a stack of `div` elements, row and column relationships,
which data a header cell belongs to, and the total row count never reach the tree at all;
a user moving between cells cannot hear the name of the column they are in.

The decision criterion is this: if a defined element exists for the job, it is used. When
no defined element exists — a tabbed panel, a tree view, a combobox — the missing
declarations must be written by hand, and ARIA is the tool for that.

#### Summary

- The accessibility tree is derived from the document tree; nodes that are not rendered,
  carry `hidden`, declare `aria-hidden`, or are images with empty alternative text are
  pruned.
- The implicit role comes from the element and is conditional: `a` is a link only if it
  carries `href`, `input`'s role is determined by its type, and `th`'s role is determined
  by its scope.
- The accessible name is computed by a defined order; content only produces a name for
  roles that allow it, and text alternative traversal collects the alternative text of
  child nodes.
- A button containing an icon with empty alternative text stays nameless; a placeholder
  and `title` produce a name in the computation but are no substitute for a persistent
  label.
- Value and state declarations come from attributes on native elements; a control built
  from a meaningless container must have its role, name, focusability, key behavior, and
  state produced by hand.

#### Next Step

The last section left a gap: how is a component declared when it has no defined element?
The filter panel above the measurement list is a section that opens and closes, alert
notifications appear on their own, and the measurement table has sortable columns. None
of these has a native counterpart, and all three must appear correctly in the tree. The
next lesson takes up the set of declarations that fills this gap — roles, states, and
properties — and shows, using the same name computation, how that set breaks the tree
when it is used incorrectly.

### ARIA Usage

Source: https://academia.sh/en/courses/frontend-quality/aria-usage

The role, state, and property declarations written into the accessibility tree; the accessible name computation in full, the rationale for the first rule, and the concrete breakages misuse produces.

The previous lesson established what native elements declare to the tree and left a gap:
components with no defined counterpart. The filter panel opens and closes, the
measurement table's columns are sortable, alert notifications appear after the fact. None
of these behaviors has a single element that corresponds to it; for them to appear
correctly in the tree, the missing declarations have to be written.

The set that writes these declarations is ARIA. The same set is the most frequently
misused tool, and when misused it makes an interface less usable than if no declaration
had been written at all. This lesson builds two things together: what the set does, and
what it breaks.

#### What ARIA Changes

ARIA declarations change **only the accessibility tree**. Writing an attribute does not
add behavior to the element: it does not make it focusable, does not make it listen for
keys, does not handle clicks, does not change its appearance.

This one sentence explains the source of most misuse. Code that writes the button role
onto a `div` element produces something that looks like a button on screen and is
declared as a button in the tree, but does not respond to the space key. When declaration
and behavior diverge, the resulting state is worse than if the declaration had never been
written: the user has been told it is a button and not given a way to use it.

#### Role, State, Property

The set carries three kinds of declaration.

**Role** states what the element is, and it replaces the implicit role. It is written
once and is not expected to change at runtime.

**State** is the element's condition at a given moment, and it changes with interaction:
whether it is expanded (`aria-expanded`), checked (`aria-checked`), selected
(`aria-selected`), busy (`aria-busy`), invalid (`aria-invalid`).

**Property** is a more lasting characteristic of the element: its name (`aria-label`,
`aria-labelledby`), its description (`aria-describedby`), what it controls
(`aria-controls`), whether it is required (`aria-required`), which order it is sorted in
(`aria-sort`).

The distinction is not cosmetic: state declarations **must be updated as they change**.
If the `aria-expanded` value is not updated when the panel opens, the tree carries
information that contradicts what is on screen. Wrong information costs more than missing
information.

#### The First Rule

The first of ARIA's writing rules is this: **if a native element carrying the required
meaning exists, ARIA is not used.** Instead of writing the button role onto a `div`
element, `button` is written; instead of the list role, `ul` is used; instead of the
heading role, a heading element is used.

The rationale is the accounting from the previous lesson. A native element brings role,
name, focusability, key behavior, state declarations, and form relationship together; a
role declaration brings only the first of these. Writing the remaining five by hand is
both longer and a task where a new mistake can be made in every implementation.

The remaining rules are extensions of this first one: a native element's meaning is not
changed unless necessary; every interactive element with a written role must be operable
by keyboard; a focusable element is not hidden from the tree; every interactive element
must have an accessible name.

#### The Full Name Computation

The previous lesson's name computation was limited to local sources. ARIA sources sit at
the very top of the order and suppress everything local. The script below implements the
full order and runs a rule audit over the same set of markup.

```js
// aria.mjs — full accessible name computation (ARIA sources on top) and misuse audit

const INTERACTIVE = new Set(["button", "link", "checkbox", "textbox", "searchbox", "spinbutton"]);
const FROM_CONTENT = new Set(["button", "link", "heading", "cell", "columnheader", "checkbox", "listitem"]);
const NO_AUTHOR_NAME = new Set(["generic", "presentation", "none", "paragraph"]);   // does not accept an author-provided name

function role(d) {
  const a = d.attrs ?? {};
  if (a.role) return a.role;
  switch (d.element) {
    case "a": return a.href === undefined ? "generic" : "link";
    case "button": return "button";
    case "input": return a.type === "checkbox" ? "checkbox" : a.type === "search" ? "searchbox" : "textbox";
    case "table": return "table";
    case "section": return a["aria-label"] || a["aria-labelledby"] ? "region" : "generic";
    case "h2": return "heading";
    default: return "generic";
  }
}

const clean = (s) => s.replace(/\s+/g, " ").trim();

// Content text; aria-hidden subtree is skipped
function text(d, visible = true) {
  if (typeof d === "string") return d;
  const a = d.attrs ?? {};
  if (visible && (a["aria-hidden"] === true || a.hidden)) return "";
  if (d.element === "img") return a.alt ?? "";
  return clean((d.children ?? []).map((c) => text(c, visible)).join(" "));
}

// 1 aria-labelledby -> 2 aria-label -> 3 local source -> 4 content -> 5 last resort
function name(d, doc) {
  const a = d.attrs ?? {};
  const r = role(d);
  const takesAuthorName = !NO_AUTHOR_NAME.has(r);

  if (takesAuthorName && a["aria-labelledby"]) {
    const parts = a["aria-labelledby"].split(" ").map((k) => doc[k]).filter(Boolean);
    // the text of a referenced node is used even if the node itself is hidden
    if (parts.length > 0) return [clean(parts.map((p) => text(p, false)).join(" ")), "aria-labelledby"];
  }
  if (takesAuthorName && a["aria-label"]) return [clean(a["aria-label"]), "aria-label"];
  if (["input", "select", "textarea"].includes(d.element) && doc["label:" + a.id]) {
    return [text(doc["label:" + a.id]), "label element"];
  }
  if (FROM_CONTENT.has(r)) {
    const m = text(d);
    if (m) return [m, "content text"];
  }
  return ["", "no name"];
}

// --- North Slope: filter panel and measurement table components -------------
const DOC = {
  "heading": { element: "h2", children: ["Filter"] },
  "count": { element: "span", children: ["3 criteria"] },
  "hidden-name": { element: "span", attrs: { hidden: true }, children: ["Delete measurement row"] },
  "label:code": { element: "label", children: ["Station code"] },
};

const EXAMPLE = [
  ['<button>Filter</button>',
   { element: "button", children: ["Filter"], focusable: true }],
  ['<button aria-label="Save">Delete measurement</button>',
   { element: "button", attrs: { "aria-label": "Save" }, children: ["Delete measurement"], focusable: true }],
  ['<button aria-labelledby="heading count">Open</button>',
   { element: "button", attrs: { "aria-labelledby": "heading count" }, children: ["Open"], focusable: true }],
  ['<button aria-labelledby="no-such-id">Delete</button>',
   { element: "button", attrs: { "aria-labelledby": "no-such-id" }, children: ["Delete"], focusable: true }],
  ['<div role="button" tabindex="0">Delete</div>',
   { element: "div", attrs: { role: "button" }, children: ["Delete"], focusable: true }],
  ['<div role="button">Delete</div>',
   { element: "div", attrs: { role: "button" }, children: ["Delete"], focusable: false }],
  ['<input id="code" aria-label="Code"> + label',
   { element: "input", attrs: { id: "code", "aria-label": "Code" }, focusable: true }],
  ['<div aria-label="Filter panel">…</div>',
   { element: "div", attrs: { "aria-label": "Filter panel" }, children: ["3 criteria"], focusable: false }],
  ['<section aria-label="Filter panel">…',
   { element: "section", attrs: { "aria-label": "Filter panel" }, children: ["3 criteria"], focusable: false }],
  ['<button aria-hidden="true">Export</button>',
   { element: "button", attrs: { "aria-hidden": true }, children: ["Export"], focusable: true }],
  ['<button aria-labelledby="hidden-name">×</button>',
   { element: "button", attrs: { "aria-labelledby": "hidden-name" }, children: ["×"], focusable: true }],
  ['<table role="presentation">…</table>',
   { element: "table", attrs: { role: "presentation" }, children: ["Temperature"], focusable: false }],
];

console.log("markup".padEnd(55) + "role".padEnd(14) + "accessible name".padEnd(27) + "source");
for (const [markup, d] of EXAMPLE) {
  const [n, from] = name(d, DOC);
  console.log(markup.padEnd(55) + role(d).padEnd(14) + (n === "" ? "(empty)" : '"' + n + '"').padEnd(27) + from);
}

console.log("\nrule audit:");
let count = 0;
for (const [markup, d] of EXAMPLE) {
  const a = d.attrs ?? {};
  const r = role(d);
  const [n] = name(d, DOC);
  const visible = text(d);
  const findings = [];
  if (a["aria-hidden"] === true && d.focusable) findings.push("focusable element hidden from tree");
  if (a["aria-labelledby"] && !a["aria-labelledby"].split(" ").some((k) => DOC[k]))
    findings.push("aria-labelledby references an invalid id");
  if ((a["aria-label"] || a["aria-labelledby"]) && NO_AUTHOR_NAME.has(r))
    findings.push("name declaration ignored on a role that does not accept an author name");
  if (INTERACTIVE.has(r) && !d.focusable) findings.push("interactive role is not focusable");
  if (INTERACTIVE.has(r) && n === "") findings.push("interactive element has no name");
  if (["presentation", "none"].includes(a.role) && ["table", "ul", "ol", "li"].includes(d.element))
    findings.push("native structural meaning removed: row and column relationships absent from the tree");
  if (INTERACTIVE.has(r) && /\p{L}/u.test(visible) && n &&
      !n.toLocaleLowerCase("en-US").includes(visible.toLocaleLowerCase("en-US")))
    findings.push('visible text ("' + visible + '") is not contained in the accessible name');
  for (const f of findings) {
    count++;
    console.log("  " + markup.padEnd(55) + f);
  }
}
console.log(`\n${EXAMPLE.length} examples, ${count} findings`);
```

```
markup                                                 role          accessible name            source
<button>Filter</button>                                button        "Filter"                   content text
<button aria-label="Save">Delete measurement</button>  button        "Save"                     aria-label
<button aria-labelledby="heading count">Open</button>  button        "Filter 3 criteria"        aria-labelledby
<button aria-labelledby="no-such-id">Delete</button>   button        "Delete"                   content text
<div role="button" tabindex="0">Delete</div>           button        "Delete"                   content text
<div role="button">Delete</div>                        button        "Delete"                   content text
<input id="code" aria-label="Code"> + label            textbox       "Code"                     aria-label
<div aria-label="Filter panel">…</div>                 generic       (empty)                    no name
<section aria-label="Filter panel">…                   region        "Filter panel"             aria-label
<button aria-hidden="true">Export</button>             button        (empty)                    no name
<button aria-labelledby="hidden-name">×</button>       button        "Delete measurement row"   aria-labelledby
<table role="presentation">…</table>                   presentation  (empty)                    no name

rule audit:
  <button aria-label="Save">Delete measurement</button>  visible text ("Delete measurement") is not contained in the accessible name
  <button aria-labelledby="heading count">Open</button>  visible text ("Open") is not contained in the accessible name
  <button aria-labelledby="no-such-id">Delete</button>   aria-labelledby references an invalid id
  <div role="button">Delete</div>                        interactive role is not focusable
  <div aria-label="Filter panel">…</div>                 name declaration ignored on a role that does not accept an author name
  <button aria-hidden="true">Export</button>             focusable element hidden from tree
  <button aria-hidden="true">Export</button>             interactive element has no name
  <table role="presentation">…</table>                   native structural meaning removed: row and column relationships absent from the tree

12 examples, 8 findings
```

The `focusable` field in the script represents whether the element actually takes focus;
for the `div` rows it corresponds to whether `tabindex` was written.

#### Reading the Findings

**A name declaration suppresses the visible text.** In the second row the button reads
"Delete measurement" on screen, but its name in the tree is "Save." A person using the
interface by voice says the text they see, and the name they say matches no element.
Criterion 2.5.3 forbids exactly this: the visible label text must be contained within the
accessible name. The same breakage happens more subtly in the seventh row — the field has
a `label` element, but because the `aria-label` declaration suppresses it, a field that
reads "Station code" on screen has the name "Code."

**An invalid reference drops silently.** In the fourth row `aria-labelledby` points to an
id that does not exist; the computation skips that step and the name comes from content
text instead. The result looks harmless in this example, but the intended declaration has
been ignored. On an icon button with no content, the same mistake leaves a nameless
button.

**A reference to a hidden element is valid.** The eleventh row shows this: the text
inside a hidden `span` becomes the name of the button that references it. This is the
defined way to give a name to a delete button that shows only an × on screen.

**Some roles do not accept an author-provided name.** In the eighth row an `aria-label`
written on a `div` element is ignored; in the ninth row, the same declaration written on
a `section` element both turns the element's role into a region and produces a name. A
name declaration is written against the element's role, not the element.

**A focusable element cannot be hidden from the tree.** The tenth row produces two
findings at once: the button takes focus but is absent from the tree. A user navigating
by keyboard hits a stop where focus disappears; the tab key goes somewhere, and nothing is
declared there.

**Removing structural meaning removes data.** In the last row the table's role has been
converted to presentational. For a table used for layout, that is the right decision; in
a data-bearing measurement table, row and column relationships drop out of the tree
entirely, and a user moving between cells cannot learn which column they are in.

#### Correct Usage in the Filter Panel

The markup below declares two structures that no native element covers, according to the
rule: a panel that opens and closes, and a sortable column.

```html
<h2 id="filter-heading">Filter</h2>
<button aria-expanded="false" aria-controls="filter-body">
  Filter criteria
</button>
<div id="filter-body" hidden>
  <label for="filter-code">Station code</label>
  <input id="filter-code" type="search">
  <p><label><input type="checkbox" name="alert"> Only measurements with an alert</label></p>
</div>

<table aria-labelledby="table-heading">
  <caption id="table-heading">Daily measurements</caption>
  <thead>
    <tr>
      <th scope="col" aria-sort="descending">
        <button>Date</button>
      </th>
      <th scope="col" aria-sort="none"><button>Temperature</button></th>
    </tr>
  </thead>
</table>
```

Three decisions deserve attention. The disclosure control is a `button` element; no role
is written, because the required role already exists. The `aria-expanded` state must be
updated every time the panel opens and closes; if it stays fixed, the tree carries wrong
information. The sort state is written on the header cell, not on the button inside it:
what is being sorted is the column, and the button is only the control that starts the
sort.

#### Summary

- ARIA declarations change only the accessibility tree; they add no behavior,
  focusability, or appearance.
- Role declares what the element is, state its current condition, property its more
  lasting characteristics; state declarations must be updated together with interaction.
- The first rule is not using ARIA when a native element carrying the required meaning
  exists; a native element brings name, focus, key behavior, and states along with role.
- In the name computation, ARIA sources suppress local sources; a divergence between the
  visible text and the accessible name fails criterion 2.5.3.
- An invalid id reference drops silently, a reference to a hidden element is valid, and
  `aria-label` is ignored on roles that do not accept an author name.
- Hiding a focusable element from the tree and removing a data table's structural meaning
  produce breakages more severe than writing no declaration at all.

#### Next Step

One row of the rule audit was left unopened in this lesson: an element with the button
role written but not taking focus. It is not enough for the declaration to be correct;
the element must also be operable by keyboard, and these two requirements are defined in
separate criteria. The next lesson looks at the whole interface from the keyboard: which
elements are in the tab order, how the order is computed, how an opened panel contains
focus, and at what point that containment turns into a trap.

### Keyboard Access

Source: https://academia.sh/en/courses/frontend-quality/keyboard-access

The requirements for keyboard operability, comparing tab order with visual order, the boundary where focus containment turns into a trap, and the condition on single-character shortcuts.

The previous lesson's rule audit left one row open: an element with the button role
declared but not taking focus. The declaration was correct; the behavior was absent.
That distinction is the subject of two different criteria, and the second is this
lesson's starting point.

Keyboard access is not only the problem of a minority who use a keyboard. Screen readers
navigate a page through the keyboard interface; tools driven by voice commands and switch
access use the same interface. A component that does not work with the keyboard works in
none of these tools. A practical conclusion follows: keyboard testing is the cheapest,
broadest test that can be run before trying any assistive technology.

#### What the Criterion Requires

Criterion 2.1.1 requires that every function of the content be operable through a
keyboard interface. Two details of the wording matter. First, it says "keyboard
interface": not a physical keyboard, but every tool that uses that interface. Second, the
criterion also requires that individual keystrokes not depend on **specific timing**; a
key sequence that must be pressed at a particular speed does not meet the criterion.

The one exception is functions where the path of the input itself carries information —
freehand drawing, for instance. Panning a map by dragging does not fall under this
exception, because the same function can be provided with pan buttons too.

Criterion 2.1.2 is the other end of the chain: if a component can be entered by keyboard,
it must be possible to exit it through the same interface. The two criteria together form
a whole; the first secures entry, the second secures exit.

#### Tab Order Must Match Visual Order

Criterion 2.4.3 requires focusable elements to be navigated in an order that preserves
meaning and operability. Tab order comes from document order; visual order comes from the
presentation layer. When the two diverge, a keyboard user jumps back and forth across the
screen.

On the measurement list page this divergence happens for two reasons: the filter panel
was added to the markup later, after the table, and the presentation layer placed it
above the table; and a positive `tabindex` value was written on the export button with
the intent of making it "come first."

```js
// tab-order.mjs — comparing tab order with visual order (2.4.3)

// visual: [row, column] — where the presentation layer places it
const PAGE = [
  { name: "a.skip-link", doc: 1, tabindex: null, visual: [0, 1] },
  { name: "button#sort-date", doc: 2, tabindex: null, visual: [3, 1] },
  { name: "button#sort-temperature", doc: 3, tabindex: null, visual: [3, 2] },
  { name: "button.delete[T-01]", doc: 4, tabindex: null, visual: [4, 1] },
  { name: "input#filter-code", doc: 5, tabindex: null, visual: [1, 1] },
  { name: "input#filter-alert", doc: 6, tabindex: null, visual: [1, 2] },
  { name: "button#filter", doc: 7, tabindex: null, visual: [1, 3] },
  { name: "button#export", doc: 8, tabindex: 1, visual: [2, 1] },
];

// Rule established in M14/K04: ascending tabindex first, then document order
function tabOrder(items) {
  const positive = items.filter((o) => (o.tabindex ?? 0) > 0)
    .sort((a, b) => a.tabindex - b.tabindex || a.doc - b.doc);
  const zero = items.filter((o) => (o.tabindex ?? 0) === 0 || o.tabindex === null)
    .sort((a, b) => a.doc - b.doc);
  return [...positive, ...zero];
}

const visualOrder = (items) =>
  [...items].sort((a, b) => a.visual[0] - b.visual[0] || a.visual[1] - b.visual[1]);

function invertedPairs(items) {
  const tab = tabOrder(items).map((o) => o.name);
  const visual = visualOrder(items).map((o) => o.name);
  const pairs = [];
  for (let i = 0; i < visual.length; i++) {
    for (let j = i + 1; j < visual.length; j++) {
      if (tab.indexOf(visual[i]) > tab.indexOf(visual[j])) pairs.push([visual[i], visual[j]]);
    }
  }
  return pairs;
}

function report(title, items) {
  console.log(title);
  const tab = tabOrder(items);
  const visual = visualOrder(items);
  console.log("  no  tab order                visual order");
  for (let i = 0; i < tab.length; i++) {
    console.log(`  ${String(i + 1).padStart(2)}  ${tab[i].name.padEnd(24)}  ${visual[i].name}`);
  }
  const pairs = invertedPairs(items);
  console.log(`  inverted pair count: ${pairs.length}`);
  for (const [a, b] of pairs.slice(0, 4)) {
    console.log(`    "${a}" comes first visually, after "${b}" in tab order`);
  }
}

report("filter panel comes after the table in source, tabindex=1 present:", PAGE);

// Fix: filter panel is moved earlier in the source, positive tabindex is removed
const FIXED = PAGE.map((o) => {
  const updated = { ...o, tabindex: null };
  if (o.name.startsWith("input#filter") || o.name === "button#filter") updated.doc = o.doc - 3;
  if (o.name === "button#export") updated.doc = 5;
  if (o.name.startsWith("button#sort") || o.name.startsWith("button.delete")) updated.doc = o.doc + 4;
  return updated;
});

console.log("");
report("filter panel moved earlier in source, tabindex removed:", FIXED);
```

```
filter panel comes after the table in source, tabindex=1 present:
  no  tab order                visual order
   1  button#export             a.skip-link
   2  a.skip-link               input#filter-code
   3  button#sort-date          input#filter-alert
   4  button#sort-temperature   button#filter
   5  button.delete[T-01]       button#export
   6  input#filter-code         button#sort-date
   7  input#filter-alert        button#sort-temperature
   8  button#filter             button.delete[T-01]
  inverted pair count: 13
    "a.skip-link" comes first visually, after "button#export" in tab order
    "input#filter-code" comes first visually, after "button#export" in tab order
    "input#filter-code" comes first visually, after "button#sort-date" in tab order
    "input#filter-code" comes first visually, after "button#sort-temperature" in tab order

filter panel moved earlier in source, tabindex removed:
  no  tab order                visual order
   1  a.skip-link               a.skip-link
   2  input#filter-code         input#filter-code
   3  input#filter-alert        input#filter-alert
   4  button#filter             button#filter
   5  button#export             button#export
   6  button#sort-date          button#sort-date
   7  button#sort-temperature   button#sort-temperature
   8  button.delete[T-01]       button.delete[T-01]
  inverted pair count: 0
```

Thirteen inverted pairs occur on a page of eight elements. The number is the product of
two mistakes: the positive `tabindex` value alone produces seven pairs, and the wrong
placement in source order produces the rest. The second report shows the direction of the
fix — the order is corrected in the **source**, not in the presentation layer. Because
the presentation layer determines the visual order, there is no consistent way other than
making the source match it.

The inverted-pair count can be used as an audit measure: any value greater than zero
means at least one user jumps backward on screen. If the delete button on every row of
the measurement table lengthens the tab order, the roving tabindex pattern established in
the Browser and the Web Platform course reduces the group to a single stop; this shortens
the sequence without disturbing the order.

#### Focus Containment and the Trap

When the measurement entry form opens as a dialog, the page behind it must not remain
accessible: the tab key must cycle within the dialog. This arrangement is called **focus
containment**, and when set up incorrectly it turns into a trap that fails criterion
2.1.2. The script below models three setups as a state machine and audits each one by
exploring which states are reachable.

```js
// focus-trap.mjs — state machine for focus containment and keyboard-trap audit (2.1.2)

const BACKGROUND = ["input#filter-code", "button#filter", "button#new-measurement", "button.delete[T-01]"];
const DIALOG = ["input#date", "input#value", "button#save", "button#close"];
const KEYS = ["Tab", "Shift+Tab", "Escape"];

// type: "unconstrained" | "no-exit" | "correct"
function machine(type) {
  const hasExit = type !== "no-exit";
  return (d, key) => {
    if (!d.open) {
      const i = BACKGROUND.indexOf(d.focus);
      if (key === "Tab") return { open: false, focus: BACKGROUND[(i + 1) % BACKGROUND.length] };
      if (key === "Shift+Tab") return { open: false, focus: BACKGROUND[(i + BACKGROUND.length - 1) % BACKGROUND.length] };
      return d;
    }
    const ring = type === "unconstrained" ? [...DIALOG, ...BACKGROUND] : DIALOG;
    const i = ring.indexOf(d.focus);
    if (key === "Tab") return { open: true, focus: ring[(i + 1) % ring.length] };
    if (key === "Shift+Tab") return { open: true, focus: ring[(i + ring.length - 1) % ring.length] };
    if (key === "Escape" && hasExit) return { open: false, focus: "button#new-measurement" };
    return d;
  };
}

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

function check(type) {
  const next = machine(type);
  const start = { open: true, focus: DIALOG[0] };
  const seen = new Map([[key(start), start]]);
  const queue = [start];
  while (queue.length > 0) {
    const d = queue.shift();
    for (const k of KEYS) {
      const y = next(d, k);
      if (seen.has(key(y))) continue;
      seen.set(key(y), y);
      queue.push(y);
    }
  }
  const states = [...seen.values()];
  return {
    reached: states.length,
    leak: states.some((d) => d.open && BACKGROUND.includes(d.focus)),
    exit: states.some((d) => !d.open),
  };
}

console.log("setup          states reached  leak to background  keyboard exit  2.1.2");
for (const type of ["unconstrained", "no-exit", "correct"]) {
  const s = check(type);
  console.log(
    type.padEnd(14) +
      String(s.reached).padStart(14) +
      (s.leak ? "  yes" : "  no").padEnd(18) +
      (s.exit ? " yes" : " no").padEnd(17) +
      (s.exit ? "passed" : "FAILED"),
  );
}

// Key sequence in the correct setup
console.log("\nfocus trace in the correct setup:");
let d = { open: true, focus: DIALOG[0] };
const next = machine("correct");
console.log("  " + "start".padEnd(18) + " " + "dialog open".padEnd(14) + " focus: " + d.focus);
for (const k of ["Tab", "Tab", "Tab", "Tab", "Shift+Tab", "Escape", "Tab"]) {
  d = next(d, k);
  console.log(`  ${k.padEnd(18)} ${(d.open ? "dialog open" : "dialog closed").padEnd(14)} focus: ${d.focus}`);
}
```

```
setup          states reached  leak to background  keyboard exit  2.1.2
unconstrained             12  yes              yes             passed
no-exit                    4  no               no              FAILED
correct                    8  no               yes             passed

focus trace in the correct setup:
  start              dialog open    focus: input#date
  Tab                dialog open    focus: input#value
  Tab                dialog open    focus: button#save
  Tab                dialog open    focus: button#close
  Tab                dialog open    focus: input#date
  Shift+Tab          dialog open    focus: button#close
  Escape             dialog closed  focus: button#new-measurement
  Tab                dialog closed  focus: button.delete[T-01]
```

The three rows separate three distinct defects. The **unconstrained** setup passes the
criterion — an exit exists — but while the dialog is open, focus leaks to the four stops
in the background. The user focuses on elements they cannot see, covered by the dialog;
this is not a conformance failure, but a defect that breaks usability. The **no-exit**
setup reaches only four states: there is no way out of the dialog at all. This is a
keyboard trap by definition, and it fails criterion 2.1.2. The **correct** setup reaches
all eight states, with no leak and an exit.

The last two lines of the focus trace show a separate rule: when the dialog closes, focus
returns to the button that opened it. If it does not, focus falls to the root of the
document and the user starts navigating from the very beginning. This behavior was
established in the Browser and the Web Platform course as the responsibility that arises
when a program moves focus; here it becomes a requirement of the criterion.

Containment itself is not set up in only one way. Capturing the tab key and cycling focus
around a ring is one way; hiding the content outside the dialog from the tree and taking
it out of interaction is another. The second is preferred, because it establishes
containment not only against the tab key but also against the screen reader's own
navigation commands — in a dialog where only the tab key is captured, a user can jump to
the background from the landmark list.

#### Focus Visibility

In an interface operated by keyboard, the location of focus must be visible at every
moment; criterion 2.4.7 requires this. Removing the focus indicator from the presentation
layer fails the criterion in a single line. The indicator can be changed: its thickness,
color, and shape are design decisions. It cannot be removed.

There are two additional conditions. The indicator must carry enough contrast to be distinguished from
adjacent colors — this is the subject of the non-text contrast criterion and will be computed in the
contrast lesson. A focused element must also **not remain obscured** — this too is the subject of a
separate criterion, added in WCAG 2.2: if a bar stuck to the bottom of the page or a header pinned to the
top completely covers the focused element, the indicator exists but is not visible.

#### Shortcuts

Writing a single-letter shortcut to jump to the filter field on the measurement list
seems reasonable. Criterion 2.1.4 imposes at least one of three conditions on shortcuts
like this: the shortcut must be possible to turn off, it must be possible to remap so the
user uses it together with a modifier key, or it must be active only while the relevant
component has focus.

The rationale shows up in two usage patterns. Surrounding speech can be transcribed for a
user dictating text, and single letters get interpreted as shortcuts; a user who has
difficulty pressing a single key on the keyboard triggers a shortcut unintentionally. On
the measurement entry form, this means lines get deleted while typing in the description
field.

The workable decision is this: single-character shortcuts are bound to the focused
component, not to the document as a whole. Row navigation with arrow keys in the
measurement table works while the table has focus; when the table does not have focus,
the same keys scroll the page. Shortcuts bound to the document as a whole are written
with a modifier key, and the user is left a way to turn them off.

#### Summary

- 2.1.1 requires every function to be operable from a keyboard interface without
  depending on keystroke timing; 2.1.2 requires every place entered to be exitable
  through the same interface.
- Tab order comes from the source, visual order from the presentation layer; a divergence
  produces inverted pairs, and the fix is made in the source, not the presentation layer.
- Positive `tabindex` values push ahead of the whole order by themselves, raising the
  inverted-pair count on their own.
- Focus containment gives different results across three setups: an unconstrained setup
  lets focus leak to the background, a no-exit setup is a keyboard trap, and a correct
  setup returns focus to the control that opened it on close.
- The focus indicator can be changed in form but not removed; it must also carry enough
  contrast and must not be obscured by another element.
- Single-character shortcuts must be possible to turn off, to remap, or active only while
  the component has focus.

#### Next Step

This lesson computed where focus goes but did not ask what the user **learns** once focus
arrives there. How is it announced that a new context has opened when the dialog opens?
When the filter is applied, how does the number of rows remaining in the table reach a
user who cannot see the screen? The next lesson takes up the announcement mechanisms: the
polite/assertive distinction in live regions, the queuing of announcements, and the
navigation planes that landmarks and headings form.

### Screen Reader Experience

Source: https://academia.sh/en/courses/frontend-quality/screen-reader-experience

A document's navigation planes and auditing plane defects, what an announcement carries, the polite and assertive priority of live regions, and the behavior of the announcement queue.

The previous lesson computed where focus goes. What the user learns once focus arrives
there is a separate question, and its answer comes from two channels: the information
that comes from the element itself, and the announcement of content that changes
independently of the element.

This lesson's subject is those two channels. The internal workings of screen readers and
the exact words they speak vary by tool; what is covered here is **what information is
defined in the tree** and which priority rules put announcements in a queue. These rules
depend on the specification, not the tool.

#### A Page Is Not Read Linearly

A screen reader user does not listen to a page from beginning to end; that would be an
unusable approach on a long document. The approach used is jumping between the document's
**navigation planes**: from landmarks to a section, from headings to a topic, from a link
list to a destination, from a control list to a form.

Each plane is derived from the document, and defects in derivation make the plane
unusable. The script below extracts three planes from the measurement list page and
audits each one against its own criterion.

```js
// nav-planes.mjs — deriving three navigation planes from a document and auditing defects

const LANDMARK = { header: "banner", nav: "navigation", main: "main", aside: "complementary",
  footer: "contentinfo", section: "region", form: "form" };

// topLevel: is it a direct child of the document?
const DOC = [
  { element: "header", topLevel: true },
  { element: "nav", topLevel: false, label: "Site-wide navigation" },
  { element: "main", topLevel: true },
  { element: "h1", text: "North Slope Measurement Station" },
  { element: "form", topLevel: false, label: "Filter" },
  { element: "a", text: "Detail", href: "/measurements/T-01" },
  { element: "h3", text: "Daily Measurements" },
  { element: "a", text: "Detail", href: "/measurements/T-02" },
  { element: "a", text: "click here", href: "/method.html" },
  { element: "h2", text: "Alerts" },
  { element: "nav", topLevel: false },
  { element: "a", text: "Measurement method document", href: "/method.html" },
  { element: "footer", topLevel: true },
];

const VAGUE = new Set(["click here", "click", "more", "read more", "link"]);

function landmarks(doc) {
  const list = [];
  for (const d of doc) {
    let role = LANDMARK[d.element];
    if (!role) continue;
    if ((d.element === "header" || d.element === "footer") && !d.topLevel) continue;
    if ((d.element === "section" || d.element === "form") && !d.label) continue;
    list.push({ role, label: d.label ?? null });
  }
  return list;
}

const headings = (doc) =>
  doc.filter((d) => /^h[1-6]$/.test(d.element)).map((d) => ({ level: Number(d.element[1]), text: d.text }));

const links = (doc) => doc.filter((d) => d.element === "a");

console.log("landmark plane:");
for (const y of landmarks(DOC)) console.log(`  ${y.role}${y.label ? ': "' + y.label + '"' : ""}`);

console.log("\nheading plane:");
for (const b of headings(DOC)) console.log(`  ${"  ".repeat(b.level - 1)}h${b.level} ${b.text}`);

console.log("\nlink plane:");
for (const b of links(DOC)) console.log(`  "${b.text}" -> ${b.href}`);

console.log("\ndefect audit:");
const findings = [];

// 1. More than one unlabeled landmark of the same kind
const counts = {};
for (const y of landmarks(DOC)) counts[y.role] = (counts[y.role] ?? 0) + 1;
for (const y of landmarks(DOC)) {
  if (counts[y.role] > 1 && !y.label) findings.push(`unlabeled ${y.role} landmark, ${counts[y.role]} of the same role exist`);
}

// 2. Heading level skip
let previous = 0;
for (const b of headings(DOC)) {
  if (previous && b.level > previous + 1) findings.push(`heading level skipped: h${previous} -> h${b.level} ("${b.text}")`);
  previous = b.level;
}

// 3. Same text different href / different text same href / vague text
const texts = {};
const hrefs = {};
for (const b of links(DOC)) {
  (texts[b.text] ??= new Set()).add(b.href);
  (hrefs[b.href] ??= new Set()).add(b.text);
  if (VAGUE.has(b.text.toLocaleLowerCase("en-US"))) findings.push(`non-descriptive link text: "${b.text}"`);
}
for (const [t, h] of Object.entries(texts)) {
  if (h.size > 1) findings.push(`text "${t}" goes to ${h.size} different targets`);
}
for (const [h, t] of Object.entries(hrefs)) {
  if (t.size > 1) findings.push(`target ${h} is linked with ${t.size} different texts`);
}

for (const b of findings) console.log("  - " + b);
console.log(`  ${findings.length} findings total`);
```

```
landmark plane:
  banner
  navigation: "Site-wide navigation"
  main
  form: "Filter"
  navigation
  contentinfo

heading plane:
  h1 North Slope Measurement Station
      h3 Daily Measurements
    h2 Alerts

link plane:
  "Detail" -> /measurements/T-01
  "Detail" -> /measurements/T-02
  "click here" -> /method.html
  "Measurement method document" -> /method.html

defect audit:
  - unlabeled navigation landmark, 2 of the same role exist
  - heading level skipped: h1 -> h3 ("Daily Measurements")
  - non-descriptive link text: "click here"
  - text "Detail" goes to 2 different targets
  - target /method.html is linked with 2 different texts
  5 findings total
```

Each of the five findings breaks a different plane.

**The second, unlabeled navigation block** cannot be distinguished in the landmark list:
the user sees two "navigation" entries and can only tell which one is the in-page
navigation by opening both. The fix is about the name, not the structure.

**The heading level skip** breaks the outline. The output's indentation shows the
problem: an h3 following an h1 puts a topic above the h2 that follows it, when it should
fall beneath it. The heading plane is the document's table of contents; a skipped level
produces a break that does not exist in the actual content.

**Three defects appear together in the link plane.** Text disconnected from context says
nothing when read alone in a list. The same text going to two different destinations
produces two entries in the list that cannot be told apart — the "Detail" link on every
row of the measurement table is exactly this case, and the fix is adding the measurement
code to the link text. Linking to the same destination with two different texts is the
reverse defect: the user assumes there are two separate pages.

#### What an Announcement Carries

When focus arrives at an element, the information conveyed to the user comes from four
fields of that element in the tree: its **role**, its **accessible name**, its **value**,
and its **states**; to these is added the **extra description** linked through
`aria-describedby`. The order of delivery and the exact words vary by tool; the content
of the fields does not.

Two practical rules follow. First, the role's name is not written into the name: a button
named "Filter button" gets called a button twice, once by its name and once by its role.
Second, the extra description is not appended to the name; it comes **after** the name —
long help text is written into the description, not the name, or the control's name turns
into a paragraph.

#### Live Regions and Priority

Content that changes without the page reloading is a separate problem: if the user is not
there, they cannot learn about the change. A **live region** is a section that is
announced when its content changes, and it was set up for the error summary in the Web
Fundamentals and HTML course.

A region has two priorities. **Polite** priority queues the announcement and does not
interrupt the user's current reading; status messages belong here. **Assertive** priority
jumps ahead by overriding whatever is pending; only announcements that stop the user's
work — data loss, a submission error — belong here. A `role="status"` declaration sets up
a polite region, and a `role="alert"` declaration sets up an assertive one.

The script below models this priority as a queue. The model does not show how long
delivery takes; it shows what the priority in the specification does to the queue.

```js
// announcements.mjs — queuing model for live-region announcements by polite/assertive priority

const REGION = {
  "filter-status": { role: "status", priority: "polite" },
  "submission-status": { role: "status", priority: "polite" },
  "error-summary": { role: "alert", priority: "assertive" },
};

const DURATION = 2; // number of ticks an announcement occupies

function run(title, events, lastTick) {
  console.log(title);
  console.log("  tick  " + "incoming".padEnd(41) + "announced".padEnd(27) + "queue");
  let queue = [];           // pending polite announcements
  let active = null;        // { text, remaining }
  let updated = 0, dropped = 0;

  for (let tick = 0; tick <= lastTick; tick++) {
    const incoming = events.filter((o) => o.tick === tick);
    let incomingText = [];
    for (const o of incoming) {
      const b = REGION[o.region];
      incomingText.push(`${o.region}: ${o.text}`);
      if (b.priority === "assertive") {
        dropped += queue.length + (active ? 1 : 0);
        queue = [];
        active = { text: o.text, remaining: DURATION };      // interrupts an ongoing announcement
      } else {
        const i = queue.findIndex((k) => k.region === o.region);
        if (i >= 0) { queue[i] = { ...o }; updated++; }   // the region's content was refreshed
        else queue.push({ ...o });
      }
    }
    if (!active && queue.length > 0) active = { text: queue.shift().text, remaining: DURATION };

    const announced = active && active.remaining === DURATION ? active.text : active ? "(ongoing)" : "—";
    console.log(
      `  ${String(tick).padStart(2)}  ${(incomingText.join(" | ") || "—").padEnd(41)}` +
        `${announced.padEnd(27)}${queue.length === 0 ? "—" : queue.map((k) => k.text).join(" | ")}`,
    );
    if (active && --active.remaining === 0) active = null;
  }
  console.log(`  queued announcements refreshed: ${updated}, dropped by an assertive announcement: ${dropped}`);
}

const EVERY_KEYSTROKE = [
  { tick: 0, region: "filter-status", text: "12 measurements listed" },
  { tick: 1, region: "filter-status", text: "5 measurements listed" },
  { tick: 2, region: "filter-status", text: "3 measurements listed" },
  { tick: 3, region: "submission-status", text: "Submitting record" },
  { tick: 4, region: "error-summary", text: "Record submission failed" },
  { tick: 7, region: "submission-status", text: "Record submitted" },
];

const DEBOUNCED = [
  { tick: 2, region: "filter-status", text: "3 measurements listed" },
  { tick: 3, region: "submission-status", text: "Submitting record" },
  { tick: 6, region: "error-summary", text: "Record submission failed" },
  { tick: 9, region: "submission-status", text: "Record submitted" },
];

run("announcement on every keystroke:", EVERY_KEYSTROKE, 9);
console.log("");
run("single announcement with debounced triggering:", DEBOUNCED, 11);
```

```
announcement on every keystroke:
  tick  incoming                                 announced                  queue
   0  filter-status: 12 measurements listed    12 measurements listed     —
   1  filter-status: 5 measurements listed     (ongoing)                  5 measurements listed
   2  filter-status: 3 measurements listed     3 measurements listed      —
   3  submission-status: Submitting record     (ongoing)                  Submitting record
   4  error-summary: Record submission failed  Record submission failed   —
   5  —                                        (ongoing)                  —
   6  —                                        —                          —
   7  submission-status: Record submitted      Record submitted           —
   8  —                                        (ongoing)                  —
   9  —                                        —                          —
  queued announcements refreshed: 1, dropped by an assertive announcement: 1

single announcement with debounced triggering:
  tick  incoming                                 announced                  queue
   0  —                                        —                          —
   1  —                                        —                          —
   2  filter-status: 3 measurements listed     3 measurements listed      —
   3  submission-status: Submitting record     (ongoing)                  Submitting record
   4  —                                        Submitting record          —
   5  —                                        (ongoing)                  —
   6  error-summary: Record submission failed  Record submission failed   —
   7  —                                        (ongoing)                  —
   8  —                                        —                          —
   9  submission-status: Record submitted      Record submitted           —
  10  —                                        (ongoing)                  —
  11  —                                        —                          —
  queued announcements refreshed: 0, dropped by an assertive announcement: 0
```

The first run produces two losses. Because every character typed into the filter field
triggers an announcement, the pending count announcement is refreshed once: while the
user is listening to the "12" announcement, the count drops to five and then to three,
and the first announced count is already stale. The second loss is worse: when the
assertive error notification arrives, the pending "Submitting record" announcement is
dropped. The user hears that submission failed without ever hearing that it started.

The second run applies both fixes together. The filter announcement is triggered once
after typing stops, not on every keystroke; this is the accessibility counterpart of the
debounced triggering from the Browser and the Web Platform course. The error notification
also lands after the queue has drained. The loss count drops to zero.

Three rules follow. Text written to a polite region must announce the **final state**,
not intermediate states. Because assertive priority drops whatever is pending, it is used
only for announcements that stop the user's work. If a region updates frequently, the
announcement is written after things **settle**, not after every update.

#### Setting Up the Region

A live region must be present in the document **beforehand**; the content of a region
added afterward may not be announced. An empty container is placed when the page is set
up, and its content is filled in later.

The region's content must be a short, status-reporting sentence. Turning the table itself
into a live region — that is, marking the table itself with an `aria-live` declaration —
leads to the entire content being announced on every row change. The correct setup is
keeping a status region outside the table and writing "3 measurements listed" into it.
The table itself stays silent; after hearing the count, the user navigates to the table
on their own.

Criterion 4.1.3 turns this into a requirement: status messages presented without a change
of focus must be programmatically determinable and must be announced without moving
focus. The criterion has two sides — the message must be announced, but **focus must not
be stolen** to announce it. Code that moves focus to the table after a filter result
fails the criterion from the opposite direction.

#### Summary

- A screen reader user does not read a document linearly; they jump between landmark,
  heading, and link planes, and each plane is derived from the document.
- Plane defects are countable: an unlabeled duplicate landmark, a skipped heading level,
  link text disconnected from context, the same text to different targets, and different
  text to the same target.
- The information announced when focus arrives at an element comes from role, name,
  value, state, and extra description; the role's name is not written into the name, and
  long text goes into the description, not the name.
- Polite priority queues an announcement, assertive priority jumps ahead by dropping
  whatever is pending; assertive is only for announcements that stop the user's work.
- In a frequently updated region, the announcement is written after things settle, not on
  every change, and it announces the final state.
- A live region must already exist in the document, its content must be short, and the
  announcement must not move focus.

#### Next Step

This lesson established how information is conveyed through non-visual channels. The
visual-channel counterpart of the same information is tied to a measurable threshold:
whether text and component boundaries on screen can be distinguished from their
surroundings. The next lesson treats contrast as a computable quantity — deriving the
contrast ratio from relative luminance, the different thresholds for text and non-text
elements, and auditing every state of the filter panel against those thresholds.

### Color and Contrast

Source: https://academia.sh/en/courses/frontend-quality/color-and-contrast

A batch audit of component states against contrast thresholds, the focus ring's test against two neighbors at once, why color cannot be a single channel, and the thresholds that text scaling introduces.

The previous lesson established how information is conveyed through non-visual channels.
The visual channel's counterpart question is this: can a piece of text on screen, or a
component's boundary, be distinguished from what surrounds it? The answer to this
question depends on a measurable quantity, and it is the most direct of the countable
criteria on this quality axis.

The definition, formula, and bounds of the contrast ratio were established in the
Contrast and Accessibility lesson of the Fundamentals of Interface Design course: the
0.05-offset ratio of two colors' relative luminances ranges from 1:1 to 21:1, is blind to
hue, and is not a linear perceptual measure. This lesson uses the same formula on the
implementation side and takes up two places a design audit skips: a component's
**states** and the focus indicator's **two neighbors**.

#### States Are Audited Too

When a color palette is audited, what gets audited is usually the default state: text
color, background color, button fill. But a component does not sit on screen with a
single pair of colors. When the button is hovered, the fill changes; when it becomes
disabled, both fill and text change; when it is focused, a ring appears around it. Every
state is a new color pair, and every pair is a separate match to check.

The script below audits the component states of the measurement list interface.

```js
// contrast.mjs — contrast audit of component states and the focus ring's two-sided test

const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
function channel(v) {
  const s = v / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(rgb(a)), luminance(rgb(b))].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

const COLOR = {
  surface: "#ffffff",
  surfaceSecondary: "#f2f4f7",
  text: "#16202a",
  textSecondary: "#5b6b7a",
  textDisabled: "#9aa7b2",
  action: "#1f5fa8",
  onAction: "#ffffff",
  actionEmphasis: "#17497f",
  alertSurface: "#fdf0c8",
  alertText: "#6b4e00",
  border: "#c3ccd6",
  sortArrow: "#b4bcc4",
};

const PT_PX = 96 / 72;
const isLarge = (px, weight) => px >= 18 * PT_PX || (weight >= 700 && px >= 14 * PT_PX);

// [name, foreground, background, kind, px, weight]
const STATE = [
  ["button default", "onAction", "action", "text", 16, 600],
  ["button hover", "onAction", "actionEmphasis", "text", 16, 600],
  ["button disabled", "textDisabled", "surfaceSecondary", "text", 16, 600],
  ["filter label", "text", "surface", "text", 16, 400],
  ["filter hint", "textSecondary", "surface", "text", 13, 400],
  ["table heading", "text", "surfaceSecondary", "text", 14, 700],
  ["row text", "text", "surface", "text", 16, 400],
  ["alert badge", "alertText", "alertSurface", "text", 13, 600],
  ["field border", "border", "surface", "non-text", 0, 0],
  ["sort indicator", "sortArrow", "surfaceSecondary", "non-text", 0, 0],
];

console.log("state                foreground background threshold measured  result");
let failing = 0;
for (const [name, fg, bg, kind, px, weight] of STATE) {
  const threshold = kind === "non-text" ? 3.0 : isLarge(px, weight) ? 3.0 : 4.5;
  const k = contrast(COLOR[fg], COLOR[bg]);
  if (k < threshold) failing++;
  console.log(
    name.padEnd(20) + " " + COLOR[fg].padEnd(11) + COLOR[bg].padEnd(11) +
      threshold.toFixed(1).padStart(6) + k.toFixed(2).padStart(9) + "  " + (k >= threshold ? "passed" : "FAILED"),
  );
}
console.log(`${failing} of ${STATE.length} matches fail the threshold`);

// The focus ring faces two neighbors at once: the inner fill and the outer surface
console.log("\nsingle-color focus ring, two-sided test (threshold 3.0):");
console.log("ring       vs fill        vs surface    result");
for (const ring of ["#2a7de1", "#16202a", "#ffffff", "#000000"]) {
  const a = contrast(ring, COLOR.action);
  const b = contrast(ring, COLOR.surface);
  console.log(
    ring.padEnd(11) + a.toFixed(2).padStart(13) + b.toFixed(2).padStart(14) +
      "  " + (a >= 3 && b >= 3 ? "passed" : "FAILED"),
  );
}

// Which grays pass both sides at once
const okGrays = [];
for (let v = 0; v <= 255; v++) {
  const g = "#" + v.toString(16).padStart(2, "0").repeat(3);
  if (contrast(g, COLOR.action) >= 3 && contrast(g, COLOR.surface) >= 3) okGrays.push(v);
}
console.log(`\ngrays passing both sides at once: ${okGrays.length} / 256`);
console.log(`  permitted range: ${okGrays[0]}–${okGrays[okGrays.length - 1]}`);

// Two-band ring: each band faces its own neighbor
const innerBand = "#ffffff", outerBand = "#16202a";
console.log("\ntwo-band ring:");
console.log(`  inner band ${innerBand} — vs fill (${COLOR.action}) ${contrast(innerBand, COLOR.action).toFixed(2)}`);
console.log(`  outer band ${outerBand} — vs surface (${COLOR.surface}) ${contrast(outerBand, COLOR.surface).toFixed(2)}`);
console.log(`  between bands ${contrast(innerBand, outerBand).toFixed(2)}`);

// 1.4.1: is the information carried by color alone?
console.log("\nmeasurement attribute indicators (1.4.1):");
for (const [name, channels] of [
  ["threshold exceeded", ["color"]],
  ["device error", ["color", "icon", "text"]],
  ["manually entered", ["color"]],
  ["verified", ["color", "text"]],
]) {
  const singleChannel = channels.length === 1 && channels[0] === "color";
  console.log(`  ${name.padEnd(19)} channels: ${channels.join(", ").padEnd(22)} ${singleChannel ? "FAILED" : "passed"}`);
}
```

```
state                foreground background threshold measured  result
button default       #ffffff    #1f5fa8       4.5     6.44  passed
button hover         #ffffff    #17497f       4.5     9.15  passed
button disabled      #9aa7b2    #f2f4f7       4.5     2.23  FAILED
filter label         #16202a    #ffffff       4.5    16.48  passed
filter hint          #5b6b7a    #ffffff       4.5     5.48  passed
table heading        #16202a    #f2f4f7       4.5    14.96  passed
row text             #16202a    #ffffff       4.5    16.48  passed
alert badge          #6b4e00    #fdf0c8       4.5     6.80  passed
field border         #c3ccd6    #ffffff       3.0     1.62  FAILED
sort indicator       #b4bcc4    #f2f4f7       3.0     1.74  FAILED
3 of 10 matches fail the threshold

single-color focus ring, two-sided test (threshold 3.0):
ring       vs fill        vs surface    result
#2a7de1             1.57          4.10  FAILED
#16202a             2.56         16.48  FAILED
#ffffff             6.44          1.00  FAILED
#000000             3.26         21.00  passed

grays passing both sides at once: 14 / 256
  permitted range: 0–13

two-band ring:
  inner band #ffffff — vs fill (#1f5fa8) 6.44
  outer band #16202a — vs surface (#ffffff) 16.48
  between bands 16.48

measurement attribute indicators (1.4.1):
  threshold exceeded  channels: color                  FAILED
  device error        channels: color, icon, text      passed
  manually entered    channels: color                  FAILED
  verified            channels: color, text            passed
```

The hover state passes, because when the fill darkens, the white text's contrast
increases. An emphasis in the opposite direction — a hover state that lightens the fill —
would miss the threshold with the same text; this is the reason the list of states is
audited.

#### Three Failures

**The disabled button** fails with a ratio of 2.23. The criterion excludes non-enabled
components from scope, so this row is not a conformance failure. But the rule established
in the Keyboard Access lesson adds a second cost here: a disabled control does not take
focus either. The user can neither read the button nor navigate to it; there is no way
left to learn why it does not work. Even though the criterion permits it, the right
decision is to keep the button enabled and report the reason when it is pressed.

**The field border** fails with a ratio of 1.62, and this is a 1.4.11 failure. The border
is the only thing that shows where the search field in the filter panel begins and ends:
when the field is empty it has no text inside, and its background matches the surface. If
the border is not visible, the field is not visible either.

**The sort indicator** fails with a ratio of 1.74. The arrow that reports which direction
a column is sorted in is non-text visual information, and it is required to identify the
state. In the previous lesson this information was written into the tree with the
`aria-sort` declaration; for a sighted user on screen, its counterpart is the arrow
itself, and the two are two channels of the same information.

#### The Focus Ring's Two Neighbors

The focus indicator raises a problem unlike anything else: it is drawn **around** an
element and sits on the boundary between two different colors. Inside is the component's
fill, outside is the page's surface. Because the criterion requires contrast against
adjacent colors, the test is two-sided.

The output's second section runs four candidate rings through this test. A blue ring
related to the component's own primary color scores 1.57 against the fill — invisible on
top of the button. A dark ring scores 16.48 against the surface but falls below the
threshold at 2.56 against the fill. A white ring is the reverse. Only pure black passes
both sides at once.

The gray sweep shows how narrow the margin is: of 256 shades of gray, only 14 — the ones
from 0 to 13 — provide 3:1 against both the button fill and the white surface. This means
a single-color focus ring practically has to be close to black — and if the palette has
more than one fill color, the range narrows further, and often empties out entirely.

The solution is not leaving the ring as a single color. In a two-band ring, the inner
band faces the component's fill and the outer band faces the page's surface; each band
only has to meet the threshold against its own neighbor. The output's last section shows
this: the inner band scores 6.44 against the fill, the outer band 16.48 against the
surface. The ring is now visible on both sides, and when the component's fill color
changes, only the inner band needs to be re-audited.

#### Color Cannot Be a Single Channel

The last section applies criterion 1.4.1, and it is independent of the contrast
computation: if a piece of information is conveyed through a distinction carried by color
alone, that information does not reach a user who cannot distinguish that color. Two of
the four attribute indicators in the measurement table are reported by row color alone.

There are three ways to add a second channel: text (writing "threshold exceeded" inside
the badge), shape (placing a mark at the start of the row), and position (grouping
flagged measurements into a separate section). Text is the most reliable, because it also
reaches the accessibility tree — the second visual channel and the declaration in the
tree are provided in a single move.

An audit rule follows from this: alongside every color indicator, there must be a channel
that can convey the same information in a colorless document. The test is simple — when
the interface's screenshot is converted to grayscale, which information disappears?

#### Assuming Text Will Scale

Two more criteria are measurable and fall under the same audit as contrast. Criterion
1.4.4 requires that text be resizable up to 200 percent without losing content or
function in the process. Criterion 1.4.12 requires that the user be able to increase line
height, paragraph spacing, and letter and word spacing to specific values without text
being clipped.

Both have the same counterpart on the implementation side: the height of containers that
hold text is not fixed. A fixed-height badge clips text at double the font size; a
declaration that hides overflow makes the clipped text unreachable. The content-based
sizing from the Layout Systems and Responsive Design course is the direct counterpart of
these two criteria.

#### Summary

- Contrast auditing cannot stop at the default state; every state of a component is a new
  color pair and is audited as a separate match.
- Disabled components are exempt from the contrast criterion, but because they also
  cannot take focus, the exemption is usually a cover for the wrong decision.
- Non-text visual information like a field border or a sort indicator is subject to the
  3:1 threshold; these are the only signal that identifies a component's presence or
  state.
- The focus ring sits on the boundary between two neighboring colors; a single-color ring
  passing both sides at once is possible only in a very narrow range, and a two-band ring
  tests each band against its own neighbor.
- Color cannot be the sole carrier; a second channel is text, shape, or position, and
  choosing text reaches the accessibility tree in the same move.
- Text being resizable to double size and its spacing being increasable are measurable
  requirements; their counterpart is avoiding fixed-height containers.

#### Next Step

Every audit in this lesson ran through a script: color pairs went in, passing and failing
matches came out. The same was true for part of the previous lessons — the accessible
name computation, tab order, and plane defects were also computable. This raises an
unavoidable question: how much of accessibility can be audited automatically? The next
lesson gives a numeric answer to this question, names the classes of criteria an
automated checker cannot see by structure, and establishes the order in which manual
verification is done.

### Accessibility Testing

Source: https://academia.sh/en/courses/frontend-quality/accessibility-testing

The rule classes an automated checker can decide, the automatability distribution of criteria, the rate that is missed on real findings, and the cost-ordered sequence of manual verification.

Most of the audits in the previous lessons ran through a script: the accessible name
computation, tab order, plane defects, and contrast ratios were all computations that
took input and produced output. This raises an unavoidable question. How much of
accessibility can be delegated to a program, and what is left over?

The answer is two numbers, and both are computed in this lesson: how much of the criteria
can be decided by a machine, and how much of an interface's real defects are found by
automated auditing. The two numbers are different, and the second one matters more.

#### What an Automated Checker Does

An **automated accessibility checker** reads the rendered document and the accessibility
tree derived from it, looks at computed style values, and applies a set of rules. The
rules it can apply fall into four classes.

**Presence rules.** They check whether a declaration exists at all: does the image have
an alternative text attribute, is the interactive element's accessible name empty, does
the document declare a language.

**Validity rules.** They check whether a written declaration is defined: is the role name
a defined role, is the state used valid for that role, does `aria-labelledby` reference an
id that exists, does the same id appear twice in the document.

**Computable-value rules.** They compute a numeric threshold directly: the contrast ratio
between two flat-colored layers, the tab order derived from `tabindex` values, the size of
a touch target.

**Uniqueness and consistency rules.** They count at the scale of the document: is there
more than one `main` landmark, are heading levels skipped, is there more than one
unlabeled landmark of the same kind.

The shared property of these four classes is that the decision is **readable from the
document**. Where the decision depends on intent, meaning, or a sequence of interaction
over time, no rule can be written.

#### Criteria and Findings

The script below performs two computations together: the automatability distribution of
criteria, and the detection rate of automated auditing on the real defects found in the
North Slope interface across previous lessons.

```js
// coverage.mjs — automatability classes of criteria and coverage on real findings

// class: "automatic" (machine-decidable) | "partial" (part of it) | "manual"
const CRITERION = [
  ["1.1.1", "Non-text Content", "partial"],
  ["1.3.1", "Info and Relationships", "partial"],
  ["1.3.5", "Identify Input Purpose", "partial"],
  ["1.4.1", "Use of Color", "manual"],
  ["1.4.3", "Contrast (Minimum)", "partial"],
  ["1.4.4", "Resize Text", "manual"],
  ["1.4.11", "Non-text Contrast", "partial"],
  ["1.4.12", "Text Spacing", "partial"],
  ["2.1.1", "Keyboard", "manual"],
  ["2.1.2", "No Keyboard Trap", "manual"],
  ["2.4.1", "Bypass Blocks", "partial"],
  ["2.4.2", "Page Titled", "automatic"],
  ["2.4.3", "Focus Order", "manual"],
  ["2.4.4", "Link Purpose", "partial"],
  ["2.4.6", "Headings and Labels", "manual"],
  ["2.4.7", "Focus Visible", "partial"],
  ["2.5.3", "Label in Name", "partial"],
  ["3.1.1", "Language of Page", "automatic"],
  ["3.2.2", "On Input", "manual"],
  ["3.3.1", "Error Identification", "partial"],
  ["3.3.2", "Labels or Instructions", "partial"],
  ["4.1.2", "Name, Role, Value", "partial"],
  ["4.1.3", "Status Messages", "manual"],
];

const counts = {};
for (const [, , c] of CRITERION) counts[c] = (counts[c] ?? 0) + 1;
console.log("criterion class  count  share");
for (const c of ["automatic", "partial", "manual"]) {
  console.log(`${c.padEnd(13)} ${String(counts[c]).padStart(4)}  ${((100 * counts[c]) / CRITERION.length).toFixed(1)}%`);
}
console.log(`total         ${String(CRITERION.length).padStart(4)}`);

// Real defects found in the North Slope interface across previous lessons
// [finding, criterion, does the automated checker catch it]
const FINDING = [
  ["empty alternative text on an icon button", "4.1.2", true],
  ["aria-labelledby references an invalid id", "4.1.2", true],
  ["aria-label suppresses the visible label", "2.5.3", true],
  ["role=button not taking focus", "2.1.1", true],
  ["focusable element carries aria-hidden", "4.1.2", true],
  ["role=presentation on a data table", "1.3.1", false],
  ["positive tabindex value", "2.4.3", true],
  ["tab order does not match visual order", "2.4.3", false],
  ["cannot exit the dialog by keyboard", "2.1.2", false],
  ["focus does not return when the dialog closes", "2.4.3", false],
  ["unlabeled second navigation landmark", "1.3.1", true],
  ["heading level skipped", "1.3.1", true],
  ["link text disconnected from context", "2.4.4", true],
  ["same text goes to two different targets", "2.4.4", false],
  ["assertive announcement drops what is pending", "4.1.3", false],
  ["field border below the 3:1 threshold", "1.4.11", false],
  ["text contrast below the 4.5:1 threshold", "1.4.3", true],
  ["attribute reported by color alone", "1.4.1", false],
];

const caught = FINDING.filter(([, , y]) => y).length;
console.log(`\ntotal findings: ${FINDING.length}, caught automatically: ${caught}, missed: ${FINDING.length - caught}`);
console.log(`automated audit detection rate: ${((100 * caught) / FINDING.length).toFixed(1)}%`);

console.log("\nwhat automated auditing misses:");
for (const [b, c, y] of FINDING) {
  if (!y) console.log(`  ${c.padEnd(7)} ${b}`);
}

// Which manual test finds each miss
const TEST = {
  "role=presentation on a data table": "markup reading",
  "tab order does not match visual order": "keyboard pass",
  "cannot exit the dialog by keyboard": "keyboard pass",
  "focus does not return when the dialog closes": "keyboard pass",
  "same text goes to two different targets": "navigation plane review",
  "assertive announcement drops what is pending": "task walkthrough",
  "field border below the 3:1 threshold": "state matrix check",
  "attribute reported by color alone": "grayscale image test",
};
const testCounts = {};
for (const [b, , y] of FINDING) {
  if (y) continue;
  const t = TEST[b];
  testCounts[t] = (testCounts[t] ?? 0) + 1;
}
console.log("\nmanual tests that find the misses:");
for (const [t, n] of Object.entries(testCounts).sort((a, b) => b[1] - a[1])) {
  console.log(`  ${t.padEnd(26)} ${n} finding${n > 1 ? "s" : ""}`);
}
```

```
criterion class  count  share
automatic        2  8.7%
partial         13  56.5%
manual           8  34.8%
total           23

total findings: 18, caught automatically: 10, missed: 8
automated audit detection rate: 55.6%

what automated auditing misses:
  1.3.1   role=presentation on a data table
  2.4.3   tab order does not match visual order
  2.1.2   cannot exit the dialog by keyboard
  2.4.3   focus does not return when the dialog closes
  2.4.4   same text goes to two different targets
  4.1.3   assertive announcement drops what is pending
  1.4.11  field border below the 3:1 threshold
  1.4.1   attribute reported by color alone

manual tests that find the misses:
  keyboard pass              3 findings
  markup reading             1 finding
  navigation plane review    1 finding
  task walkthrough           1 finding
  state matrix check         1 finding
  grayscale image test       1 finding
```

The first table gives the criteria side: only two of the twenty-three criteria can be
decided by a machine from beginning to end. The majority are in the **partial** class,
and this class's meaning is easy to misread. For a partial criterion, automated auditing
can only report **failure**; it cannot report success. The presence of an alternative
text attribute does not show that criterion 1.1.1 is met, only that the attribute exists.

The second table is the more concrete one: of the eighteen real defects found across the
six lessons in this course, ten are caught by automated auditing and eight are missed.
The rate varies by interface; what stays constant is **which kind** the misses belong to.

#### What the Misses Have in Common

The eight missed findings split into three groups, and all three are structurally
impossible to automate.

**Those that require a judgment of meaning.** Whether a table carries data or exists for
layout cannot be read from the document; on a table with the presentational role written,
the checker cannot know the author's intent. The same text going to two different
destinations is the same kind of case: it is a defect if the two links go to separate
things, and not a defect if they go to two copies of the same thing.

**Those that require a sequence over time.** A keyboard trap, tab order not matching visual order, focus
not returning when the dialog closes, and an assertive announcement dropping what is pending cannot be
seen by looking at a single document state; they do not surface without running a sequence of interaction.
These are auditable — the state machine in the Keyboard Access lesson did exactly that — but auditing them
requires modeling the component's behavior or running an actual flow.

**Those that depend on the rendered result.** A border's contrast, measured under layers
of shadow and transparency, stops being the ratio of two flat colors; text over a
gradient fill or an image has no single contrast value. Whether color is a single channel
is a question of which channels the information is given through, and it does not exist
as a declaration in the document.

#### The Order of Manual Verification

Manual verification is not an unbounded review; it is five steps ordered by cost. The
ordering rests on the cheap steps producing the most findings first.

**Keyboard pass.** The tab key is used to go from the beginning of the page to the end.
Four things are checked: is focus visible at every stop, does the order match the order
on screen, can every opened component be exited by keyboard, and where does focus return
on close. This step alone finds three of the eight missed findings and requires no
tooling.

**Resize check.** Text is enlarged to double size and the interface is used to complete
the same task. Clipped text, overlapping boxes, and controls that disappear show up at
this step.

**Markup reading.** The markup a component produces is read: is the role correct, is the
state updated, which source does the name come from. This step finds defects that require
a judgment of meaning.

**Navigation plane review.** The landmark list, heading outline, and link list are each
extracted separately and read. Does the list make sense stripped of its surrounding
context?

**Task walkthrough.** A task is completed end to end without seeing the interface and
without using a mouse — filtering the measurement list, reporting a correction, confirming
the result. This is the most expensive step, and it finds what the other four cannot:
whether the information is **sufficient**.

#### Tying the Audit to the Process

A one-time audit becomes stale at the next change. The way the audit ties into the
process has three layers.

At the component scale, the role and accessible name a component produces are locked in
with a test: when the component renders, the button role and the expected name must be
present in the tree. These tests look at the counterpart in the tree, not at the markup
structure; they stay valid even when the component's internals are rewritten. The same
approach is the foundation of the user-centric queries in the Testing and Monitoring
topic.

At the page scale, the automated checker runs before release, and every failure it finds
blocks the release. The budget form from the Accessibility Standards lesson applies here:
the count of unmet A and AA criteria must be zero, and known exceptions sit in an open
list with the criterion number and a date.

At the release scale, the five steps of manual verification are applied not to every
release but to the parts of the interface that change. When a new component is added, the
keyboard pass and markup reading are mandatory; the task walkthrough is done less often.

#### What Zero Findings Means

Automated auditing producing no findings does not show that the interface is accessible.
This sentence is this lesson's conclusion, and it has two sides.

First, the computed rate: on this interface, 55.6 percent of findings were caught, and
the rest were not. Second, the checker can also produce a **false positive**. Text over a
gradient fill can be reported below the threshold by assuming a single background color;
a correctly built component can be flagged because of a pattern the checker does not
recognize. Every finding needs to be confirmed by a person.

The value of automated auditing is in catching regressions. It cheaply prevents a
once-fixed defect from coming back, and it reserves manual verification time for the
defects that can only be found by hand.

#### Summary

- An automated checker applies four classes of rule: presence, validity, computable
  value, and uniqueness-consistency; in all of them, the decision must be readable from
  the document.
- Most criteria are in the partial class, and a partial criterion's automated audit can
  only report failure, never success.
- Of the eighteen real defects found in this course, ten are caught automatically and
  eight are missed; the misses require a judgment of meaning, a sequence over time, or
  the rendered result.
- Manual verification is ordered by cost: keyboard pass, resize check, markup reading,
  navigation plane review, and task walkthrough.
- The audit ties into the process at three scales: role and name testing at the component
  scale, an automated check that blocks release at the page scale, and manual
  verification applied to changed parts at the release scale.
- Zero findings from automated auditing is not proof of conformance; the checker also
  produces false positives, and its real value is catching regressions.

#### Next Step

Two axes of quality are now measurable. Performance is tied to a budget, accessibility to
a set of criteria; both are countable, and both carry a definition of regression. A third
axis remains, and it asks a different question than the previous two.

Accessibility makes sure that the information an interface carries reaches the user. The
next topic takes up the flow in the opposite direction: text coming from the user
entering the interface. Text written into the description field on the measurement entry
form is shown to other people in the measurement list. When that text is written onto the
page, is it processed **as data** or **as code**? Where that distinction is lost, the
user has to be protected from the interface itself. The first lesson of the Client-Side
Security topic, Cross-Site Scripting, takes up how that distinction gets lost and how
output escaping restores it.

## Client-Side Security

The layers that protect the user from the interface itself; output escaping, the content security policy, cross-origin resource sharing rules, tokens and cookie attributes against request forgery, security headers, third-party script risk, and the client-side projection of common vulnerability classes.

### Cross-Site Scripting

Source: https://academia.sh/en/courses/frontend-quality/cross-site-scripting

The problem of text entering a page and running as code; how the reflected, stored, and DOM-based types arise, context-sensitive output escaping, and a scheme allowlist for link targets.

The first two topics in this course treated quality along two measurable axes. The
Performance topic tied how quickly the page becomes usable to a budget; the Accessibility
topic, in the Accessibility Testing lesson, showed how far automated auditing can go and
where manual verification is required. Both axes aimed to improve the user's relationship
with the interface.

The third axis runs in the opposite direction: protecting the user from the interface
itself. In the North Slope Measurement Station application, every measurement record
accepts a free-text **measurement note**. This note is written to the database, appears on
other users' screens, and shows up in exported reports. Even when the person writing the
note has no bad intent, how that text enters the page is a security decision. This lesson
builds the layer that keeps that text data.

#### The Moment Text Becomes Code

A browser interprets text according to context while parsing a document. The distinction
built in the Web Fundamentals and HTML course is decisive here: the `<` character is the
start of a tag to the parser, not an ordinary letter. When an application places a
user-supplied string into the document as-is, it never tells the parser "there is data
here"; the parser reads the characters it sees by its own rules and treats any markup
inside the string as structure.

**Cross-site scripting** is the name for this boundary slip: code the application never
wrote runs inside a document belonging to some origin, with that origin's authority. The
word authority is critical. Because the running code shares an origin with the document, it
can read that origin's local storage, reach cookies that lack the `HttpOnly` flag, and send
requests with the user's session. The same-origin policy does not treat this code as
foreign, because the code lives inside the document.

The root of the problem is not a parser bug. It is that the application places data
somewhere it can be interpreted as code without applying a transform that blocks that
interpretation.

#### Three Paths to the Same Result

The same outcome is reached by three different paths, and where the defense belongs
depends on which path is in play.

In the **reflected** form, the input comes from the request itself and is written back
into the same response. A search field on the measurement list is written into the query
string, and the server places that value into the page while producing a "search term: …"
heading. The damage occurs in a single response, and the victim has to open a crafted
address.

In the **stored** form, the input is saved once and served again on every later read. The
measurement note is exactly this class: the user who saves the note and the user who is
affected by it are different people, and the affected person just browses normally. Because
it persists, this type has the widest reach.

In the **DOM-based** form, the data never reaches the server at all. Client code reads the
address fragment, a record in local storage, or the content of a message event, and writes
it into the document. No trace is left in server logs; the defense can only be built into
client code. The storage APIs introduced in the Browser and the Web Platform course are
therefore not trusted sources for this purpose: whatever writes to local storage is also
code running on the same page.

#### Where the Defense Belongs: Output, Not Input

The intuitive fix is to filter input: drop characters that look dangerous at save time.
This approach fails for two reasons. First, which character is dangerous depends on where
the input will be written, and that is not known at save time; the same note can appear in
an HTML body, in an attribute, and in an exported address. Second, filtering corrupts the
data: "the 12-inch anemometer read &lt; 3 m/s" is a valid measurement note and must be
stored exactly as written.

The right place is the output. The raw text stays in the data store; it is transformed
according to the rules of its destination context at the moment it is written into the
document. This transform is called **escaping**: replacing meaningful characters with
representations that lose their meaning in that context while keeping their appearance.

Escaping is not context-independent. Four separate contexts have four separate rules, and
applying one context's rule in another context provides no protection.

#### Four Contexts, Four Rules

The file below escapes the same measurement note separately for four contexts. The note is
a harmless marker string carrying a meaningful character for each of the four contexts.

```js
// escaping.mjs — escapes the same measurement note for four contexts
const BODY = { '&': '&amp;', '<': '&lt;', '>': '&gt;' };
const ATTRIBUTE = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };

function bodyEscape(text) {
  return String(text).replace(/[&<>]/g, (c) => BODY[c]);
}

function attributeEscape(text) {
  return String(text).replace(/[&<>"']/g, (c) => ATTRIBUTE[c]);
}

function urlEscape(text) {
  return encodeURIComponent(String(text));
}

function scriptEscape(text) {
  return JSON.stringify(String(text))
    .replace(/</g, '\\u003c')
    .replace(/>/g, '\\u003e')
    .replace(/&/g, '\\u0026');
}

const note = `12's <mark> & "north" slope`;

const contexts = [
  ['HTML body', bodyEscape(note)],
  ['Attribute value', attributeEscape(note)],
  ['URL component', urlEscape(note)],
  ['Script literal', scriptEscape(note)],
];

console.log('input:', note);
for (const [name, result] of contexts) {
  console.log(name.padEnd(18), result);
}
```

```
$ node escaping.mjs
input: 12's <mark> & "north" slope
HTML body          12's &lt;mark&gt; &amp; "north" slope
Attribute value    12&#39;s &lt;mark&gt; &amp; &quot;north&quot; slope
URL component      12's%20%3Cmark%3E%20%26%20%22north%22%20slope
Script literal     "12's \u003cmark\u003e \u0026 \"north\" slope"
```

The four lines produce four different outputs, and each difference corresponds to a rule.

In the body context, converting only three characters is enough; quote marks carry no
meaning inside a text node. In the attribute context, quotes are also converted, because a
quote marks the boundary of an attribute value. Mixing up these two rules is a common
defect: writing a value escaped with the body rule into a quoted attribute lets the value
close the attribute early.

The rule in the URL context is entirely different. The `&` character is not converted to
an HTML entity but to percent-encoding, because the parser at work here is not an HTML
parser but a URL resolver. In the script context, the string delimiter and backslash
characters are converted; turning `<` into `<` keeps the text inside the string from
ending the enclosing script element early.

This nesting produces a rule of its own: if a value passes through more than one context,
escaping must also be applied in a nested order. For an address written inside a script,
URL encoding comes first and script escaping second; reversing the order lets the second
transform corrupt the characters the first one produced.

#### Where Escaping Falls Short: Scheme Validation

Some attributes interpret their value not as text but as an address. Even if a link's
target has been escaped, if it points to an executable scheme, escaping changes nothing:
none of the characters are meaningful to HTML. Code that places user-supplied links from
the measurement note into the page therefore checks the scheme separately.

```js
// scheme.mjs — validates a link target's scheme against an allowlist
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'mailto:']);

function linkTarget(raw, base) {
  let url;
  try {
    url = new URL(raw, base);
  } catch {
    return { allowed: false, reason: 'unresolvable' };
  }
  if (!ALLOWED_SCHEMES.has(url.protocol)) {
    return { allowed: false, reason: `scheme not in list: ${url.protocol}` };
  }
  return { allowed: true, target: url.href };
}

const base = 'https://measurements.example.test/notes/';
const candidates = [
  '/station/north-slope',
  'https://data.example.test/report.csv',
  'mailto:registration@example.test',
  'data-scheme:text/html;base64,SSBhbSB0ZXh0',
  'measurements://north',
  '::broken::',
];

for (const candidate of candidates) {
  const result = linkTarget(candidate, base);
  console.log(String(result.allowed).padEnd(6), candidate.padEnd(44), result.target ?? result.reason);
}
```

```
$ node scheme.mjs
true   /station/north-slope                         https://measurements.example.test/station/north-slope
true   https://data.example.test/report.csv         https://data.example.test/report.csv
true   mailto:registration@example.test             mailto:registration@example.test
false  data-scheme:text/html;base64,SSBhbSB0ZXh0    scheme not in list: data-scheme:
false  measurements://north                         scheme not in list: measurements:
true   ::broken::                                   https://measurements.example.test/notes/::broken::
```

The last line shows how the rule has to be written. `::broken::` is not a valid absolute
address, but once resolved against a base address it counts as a relative path and inherits
the page's own scheme. The check is applied to the **resolved address**, not the raw
string, and the allowlist counts what is accepted rather than what is rejected. Counting
what is rejected silently lets through every scheme not yet on the list.

#### Not Escaping by Hand

The functions above were written to show the rule; they are not expected to be called by
hand at every write site in production code. This is the most important security property
of the declarative rendering model introduced in the Component-Based Interface Development
course: values placed into an expression slot are treated as text nodes by default and
escaped according to their context. Escaping is the default; adding raw markup is an
explicitly requested escape hatch.

What these hatches have in common is that their names or documentation announce the
danger: a property that assigns raw HTML, a method that parses and inserts markup, helpers
that build a component from a string. The audit rule is simple: the places these hatches
are used must be countable, and each one needs a written answer to "where does the data
reaching this point come from." If writing text is enough, `textContent` is used;
`innerHTML` is not a necessity but a decision.

If a user genuinely needs to enter formatted text, the problem stops being an escaping
problem and becomes a **sanitization** problem: a layer is needed that parses the markup
and keeps only the elements, attributes, and schemes on an allowlist. Sanitization is
noticeably harder than escaping, because it is sensitive to differences in parsing
behavior. As a rule, a sanitizer is not written by hand; a maintained implementation is used,
and its output is defended together with this lesson's next layer, policy enforcement.

#### Summary

- Cross-site scripting arises when data is placed, without a transform, somewhere it can be
  interpreted as code; the running code inherits the authority of the document's origin.
- There are three paths of origin: reflected, stored, and DOM-based. In the last one, the
  data never reaches the server, so the defense can only be built into client code.
- The defense is not filtering at input but context-appropriate escaping at output; the
  rules for the HTML body, attribute, URL, and script contexts do not substitute for one
  another.
- Escaping is not enough for attributes that interpret an address; the scheme of the
  resolved address is checked against an allowlist.
- Declarative rendering makes escaping the default; escape hatches that insert raw markup
  must be countable and justified.

#### Next Step

Escaping depends on every write site the application itself writes behaving correctly. A
single missed spot invalidates the whole defense, and assuming this will not go unnoticed
in a large interface is not realistic. The next lesson places a second layer beneath
escaping: a policy that tells the browser which origin's scripts may run, and that makes
this decision independent of the document itself — the content security policy.

### Content Security Policy

Source: https://academia.sh/en/courses/frontend-quality/content-security-policy

Writing the policy that tells the browser which origins it may load from; directives and source expressions, nonce and hash-based allowance, report-only mode and violation reports, and the areas the policy does not cover.

The previous lesson placed the defense at the output: context-appropriate escaping at
every write site. This defense's weakness is its scope. Being correct requires every path
in the application to be correct; a single raw-markup write site, a single third-party
component, a single legacy page template is enough. Assuming this scope holds continuously
in a large interface is an assumption that is hard to audit.

This lesson builds a second layer. What matters about this layer is that it does not
depend on the correctness of the application's own code. The decision is declared to the
browser, and the browser enforces it at load time no matter what the document says. The
policy set across the North Slope Measurement Station interface fixes, this way, which
scripts the page showing a measurement note may run and whether the embedded map frame is
allowed.

#### What the Policy Does

**Content security policy** is a declaration carried by a response's
`Content-Security-Policy` header. The declaration lists what kind of resource the document
may load from where, and under what condition inline code may run. Before fetching a
subresource or running a script, the browser checks the policy; if there is no match in
the expression list, it does not carry out the action.

This is not a defense that replaces escaping. Escaping stops data from turning into code;
the policy narrows what code that runs anyway, past the boundary, can do. The two are
independent layers, and because they are independent, they work together.

The policy is sent as an HTTP header. It can also be declared with a `meta` element in the
document, but this route does not support some directives and only takes effect after the
document has already started parsing. Defining it as a header in server configuration is
the route with the wider reach.

#### Directives and Source Expressions

The policy consists of **directives** separated by semicolons. Each directive names a
resource type and is followed by a list of **source expressions** valid for that type.

`default-src` is the fallback for the other directives: when the policy carries no
directive for a given type, the browser falls back to this list. `script-src` covers
scripts, `style-src` covers stylesheets, `img-src` covers images, `connect-src` covers
network connections opened from script, and `font-src` covers fonts. Alongside these are
directives that decide things other than loading: `frame-ancestors` decides who may frame
the document, `form-action` decides where a form submission may go, and `base-uri` decides
whether the document's base address can be changed.

Source expressions come in a few forms. `'none'` allows nothing. `'self'` denotes the document's own
origin. A scheme expression (`https:`, `data:`) covers every address from that scheme. A host expression
names a domain, and, with a leading asterisk, also covers its subdomains. `'nonce-…'` and `'sha256-…'`
allow inline code one at a time. `'unsafe-inline'` allows all inline code, and the warning in its name is
warranted: this expression largely cancels out the protection the policy provides against script
execution. When the same directive also carries a nonce or hash expression, the browser ignores
`'unsafe-inline'`; this exception is what lets the expression be written purely as a fallback for older
implementations.

#### Turning the Policy into a Decision

The most direct way to see how a policy is read is to write the code that reads it. The
resolver below splits a policy string into directives, tests a given request against the
relevant list, and applies the fallback-to-default behavior.

```js
// csp.mjs — resolves a policy string and decides whether a resource is allowed
function resolvePolicy(policy) {
  const directives = new Map();
  for (const part of policy.split(';')) {
    const words = part.trim().split(/\s+/).filter(Boolean);
    if (words.length === 0) continue;
    directives.set(words[0].toLowerCase(), words.slice(1));
  }
  return directives;
}

function hostMatches(expression, url) {
  const [schemePart, rest] = expression.includes('://') ? expression.split('://') : [null, expression];
  const [host, ...path] = rest.split('/');
  if (schemePart && `${schemePart}:` !== url.protocol) return false;
  if (host.startsWith('*.')) {
    if (!url.hostname.endsWith(host.slice(1))) return false;
  } else if (host !== url.host && host !== url.hostname) {
    return false;
  }
  if (path.length > 0 && path[0] !== '') return url.pathname.startsWith('/' + path.join('/'));
  return true;
}

function expressionMatches(expression, request, documentOrigin) {
  if (expression === "'none'") return false;
  if (expression === "'self'") return request.url !== null && new URL(request.url).origin === documentOrigin;
  if (expression === "'unsafe-inline'") return request.inline === true && !request.nonce;
  if (expression.startsWith("'nonce-")) return request.nonce === expression.slice(7, -1);
  if (expression.startsWith("'sha256-")) return request.hash === expression.slice(1, -1);
  if (expression.endsWith(':')) return request.url !== null && new URL(request.url).protocol === expression;
  return request.url !== null && hostMatches(expression, new URL(request.url));
}

function decide(policy, request, documentOrigin) {
  const directives = resolvePolicy(policy);
  const name = directives.has(request.directive) ? request.directive : 'default-src';
  const list = directives.get(name);
  if (!list) return { allowed: true, directive: null };
  const matched = list.find((expression) => expressionMatches(expression, request, documentOrigin));
  return { allowed: Boolean(matched), directive: name, matched: matched ?? null };
}

function reportEntry(request, result, policy, disposition) {
  return {
    'document-uri': 'https://measurements.example.test/station/north-slope',
    'violated-directive': result.directive,
    'blocked-uri': request.url ?? 'inline',
    'disposition': disposition,
    'original-policy': policy,
  };
}

const POLICY =
  "default-src 'none'; script-src 'self' 'nonce-r4k2' https://measurements-cdn.example.test; " +
  "img-src 'self' data:; connect-src 'self' https://*.data.example.test; frame-ancestors 'none'";
const ORIGIN = 'https://measurements.example.test';

const requests = [
  { name: 'own script', directive: 'script-src', url: 'https://measurements.example.test/js/measurements.js' },
  { name: 'distribution script', directive: 'script-src', url: 'https://measurements-cdn.example.test/chart.js' },
  { name: 'foreign script', directive: 'script-src', url: 'https://other.example.test/tracking.js' },
  { name: 'inline with nonce', directive: 'script-src', url: null, inline: true, nonce: 'r4k2' },
  { name: 'inline without nonce', directive: 'script-src', url: null, inline: true },
  { name: 'embedded data image', directive: 'img-src', url: 'data:image/png;base64,iVBOR' },
  { name: 'subdomain call', directive: 'connect-src', url: 'https://measurements1.data.example.test/record' },
  { name: 'font', directive: 'font-src', url: 'https://measurements.example.test/f/a.woff2' },
];

for (const request of requests) {
  const result = decide(POLICY, request, ORIGIN);
  const line = [
    (result.allowed ? 'allow' : 'block').padEnd(6),
    request.name.padEnd(22),
    (result.directive ?? '-').padEnd(14),
    result.matched ?? '',
  ].join(' ');
  console.log(line.trimEnd());
}

console.log('\nreport entry:');
const blocked = requests[2];
console.log(JSON.stringify(reportEntry(blocked, decide(POLICY, blocked, ORIGIN), POLICY, 'enforce'), null, 2));
```

```
$ node csp.mjs
allow  own script             script-src     'self'
allow  distribution script    script-src     https://measurements-cdn.example.test
block  foreign script         script-src
allow  inline with nonce      script-src     'nonce-r4k2'
block  inline without nonce   script-src
allow  embedded data image    img-src        data:
allow  subdomain call         connect-src    https://*.data.example.test
block  font                   default-src

report entry:
{
  "document-uri": "https://measurements.example.test/station/north-slope",
  "violated-directive": "script-src",
  "blocked-uri": "https://other.example.test/tracking.js",
  "disposition": "enforce",
  "original-policy": "default-src 'none'; script-src 'self' 'nonce-r4k2' https://measurements-cdn.example.test; img-src 'self' data:; connect-src 'self' https://*.data.example.test; frame-ancestors 'none'"
}
```

The last line shows the policy's most instructive behavior. The font request falls into
the `default-src 'none'` list, because there is no `font-src` directive, and gets blocked.
This is the recommended way to write a strict policy: start with `default-src 'none'` and
add a directive explicitly for each type the application actually uses. A missing
directive turns into a visible failure instead of silently allowing anything through.

#### Inline Code with Nonces and Hashes

If inline script is genuinely needed, there are two routes, and neither uses
`'unsafe-inline'`.

A **nonce** is an unpredictable value the server generates for each response. It is
declared in the policy as `'nonce-r4k2'`, and the same value is written on the script
element as the `nonce` attribute. The browser runs only the matching element. Three rules
are binding: the value is generated with cryptographic-quality randomness, it is renewed
on every response, and it is not reused with a cached page. A fixed nonce reduces the
policy to the level of `'unsafe-inline'`.

**Hash-based allowance** suits inline scripts whose content does not change. A digest of
the script's body is taken and written into the policy as `'sha256-…'`. It requires no
per-response generation, but the policy must be updated the moment one character of the
script text changes, which means the hash has to be computed at build time.

As host lists grow, a policy's protective value drops, because every domain on the list
becomes a trusted load point. The `'strict-dynamic'` expression, defined to reduce this
problem, delegates trust to whatever a nonce-run script loads, making the host list
unnecessary. For an implementation that does not understand this expression to keep
working, it is written alongside the host list: old behavior reads the list, new behavior
ignores it.

#### Report-Only Mode and Rollout

Applying a strict policy directly to a working application breaks functioning features.
This is why the `Content-Security-Policy-Report-Only` header exists: the browser evaluates
the policy and reports violations, but blocks nothing. In the report entry above, the
`disposition` field carries this distinction — `enforce` under enforce mode, `report`
under report-only mode.

Violation reports are sent to a reporting endpoint declared in the policy. The entry's
fields say which document the violation occurred in, which directive was violated, and
what was blocked. For inline code, the `blocked-uri` field carries the value `inline`
instead of an address.

The rollout order is this: start with report-only mode, collect incoming reports for a
while, add legitimate load points to the policy, and fix remaining violations in code —
only then move to enforce mode once the flow of reports has settled. The reporting
endpoint itself must be resilient to noise: browser extensions that inject code into the
page on the user side produce violations unrelated to the application, and if these
reports are not filtered out, genuine findings become invisible.

#### What the Policy Does Not Cover

The policy governs loading and execution decisions; it does not govern what legitimate
code running inside the document does. The DOM-based class introduced in the previous
lesson falls into this gap: if the application's own script writes a value from its own
origin as raw markup, the policy does not count that as an external load.

The mechanism defined to narrow this gap makes insertion points that accept raw markup
writable only through an audited type, turning a plain string assignment into an error. It
attaches to the policy through a directive and is measurable in report-only mode. Because
support is not available in every environment, it is tested with feature detection, and
where it is absent the application falls back on the escaping layer built in the previous
lesson.

#### Summary

- Content security policy tells the browser which origin may load and which code may run,
  and makes the decision independent of the application's own code.
- The policy consists of directives; `default-src` is the fallback for the rest, so
  starting with `default-src 'none'` and adding each needed type one at a time makes gaps
  visible.
- For inline code, a nonce renewed per response or a hash computed at build time replaces
  `'unsafe-inline'`.
- Report-only mode evaluates the policy without blocking; rollout starts in report-only
  mode and moves to enforce mode once the flow of reports has settled.
- The policy governs external load decisions; the document's own code writing raw markup
  is constrained by a separate mechanism.

#### Next Step

The policy says where a script may load from. Once the script has loaded and sends a
request to another origin to read data, a different set of rules takes over: the
same-origin policy does not allow the response to that request to be read by default. For
the measurement interface to read from a data endpoint at a separate domain, the server
has to declare explicit permission. The next lesson builds this permission mechanism, the
constraints the browser applies, and the correct server configuration.

### Cross-Origin Resource Sharing

Source: https://academia.sh/en/courses/frontend-quality/cross-origin-resource-sharing

What the same-origin policy blocks, the distinction between simple requests and preflight, what the sharing headers mean, the rules for credentialed requests, and common misconfigurations.

The previous lesson established that the content security policy decides which origin a
script may load from. Once the script has loaded, a separate boundary takes over. The
North Slope Measurement Station interface is served from `https://measurements.example.test`,
while the measurement data comes from an endpoint at a separate domain. When the
interface's script sends a request to that endpoint, it is not the policy but the
same-origin policy that decides whether the script may read the response.

This lesson builds that decision, and the audited way of relaxing it.

#### What the Same-Origin Policy Blocks

As defined in the Browser and the Web Platform course, an **origin** is the triple formed
by scheme, host name, and port. If any component of the triple differs, the origin
differs: `https://measurements.example.test` and `https://data.example.test` are separate
origins, and so is `http://measurements.example.test`.

The most common misconception about what the same-origin policy does is the assumption
that it blocks the request from being sent. What is blocked is **reading the response**.
A script can send a request to another origin; the request reaches the server, the server
processes it and returns a response; the browser just does not deliver that response to
the script. If the request produced a side effect, that side effect has already happened.

This distinction is what drives the next lesson's topic, and it should be noted here: the
policy restricts reading, not writing.

The policy applies only to connections opened from script. Image, stylesheet, font, and
script elements can load from other origins; the document embeds them but cannot read
their content. The **opaque response** concept, introduced in the Service Workers lesson
of the Browser and the Web Platform course, is the name for this state: the response can
be used, but its body and headers are not visible to the program.

#### Simple Requests and Preflight

Some cross-origin requests are sent directly; others ask for permission before being sent.

Requests sent directly are the ones forms have long been able to make across origins: the
`GET`, `HEAD`, or `POST` method, a limited set of content types, and no headers the
application has added on its own. These requests get no extra round trip; the browser
sends the request and decides whether the response can be read by checking the headers
that come back.

Every other request requires **preflight**. If the method is `PUT` or `DELETE`, if the
content type is `application/json`, or if the application is adding a header that starts
with `X-`, the browser first sends a separate request with the `OPTIONS` method. In this
request, the `Access-Control-Request-Method` and `Access-Control-Request-Headers` headers
tell the server what the real request is about to do. If the server allows it, the real
request is sent; if not, the real request never happens.

The reason preflight exists is backward compatibility. The requests a browser can send
directly are already the requests forms have always been able to send; new capabilities
are not opened up without the server's knowledge.

#### Server-Side Configuration

The server below serves measurement data and makes the sharing decision through a single
allowlist.

```js
// cors-server.mjs — cross-origin resource sharing configuration for the measurement endpoint
import { createServer } from 'node:http';

const ALLOWED_ORIGINS = new Set(['https://measurements.example.test']);
const ALLOWED_METHODS = 'GET, POST, OPTIONS';
const ALLOWED_HEADERS = 'content-type, x-measurement-key';

const server = createServer((request, response) => {
  const origin = request.headers.origin;
  const allowed = origin !== undefined && ALLOWED_ORIGINS.has(origin);

  response.setHeader('Vary', 'Origin');
  if (allowed) {
    response.setHeader('Access-Control-Allow-Origin', origin);
    response.setHeader('Access-Control-Allow-Credentials', 'true');
  }

  if (request.method === 'OPTIONS') {
    if (allowed) {
      response.setHeader('Access-Control-Allow-Methods', ALLOWED_METHODS);
      response.setHeader('Access-Control-Allow-Headers', ALLOWED_HEADERS);
      response.setHeader('Access-Control-Max-Age', '600');
    }
    response.writeHead(204).end();
    return;
  }

  response.setHeader('Content-Type', 'application/json');
  response.end(JSON.stringify({ station: 'north-slope', temperature: -3.4 }));
});

server.listen(8791, '127.0.0.1', () => {
  console.log('listening: http://127.0.0.1:8791');
});
```

The server is started in the background, three requests are sent, and it is stopped at the
end. The `-D -` flag writes the response headers to standard output; `tr -d '\r'` strips
the transport line-ending character, and `grep` filters out the date line, which changes
on every run.

```bash
node cors-server.mjs &
server=$!
sleep 1

echo '--- 1. preflight from an allowed origin ---'
curl -s -o /dev/null -D - -X OPTIONS http://127.0.0.1:8791/measurements \
  -H 'Origin: https://measurements.example.test' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type' | tr -d '\r' | grep -v '^Date:'

echo '--- 2. preflight from an origin not on the list ---'
curl -s -o /dev/null -D - -X OPTIONS http://127.0.0.1:8791/measurements \
  -H 'Origin: https://other.example.test' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type' | tr -d '\r' | grep -v '^Date:'

echo '--- 3. real request from an allowed origin ---'
curl -s -D - http://127.0.0.1:8791/measurements \
  -H 'Origin: https://measurements.example.test' | tr -d '\r' | grep -v '^Date:'

kill $server
```

```
listening: http://127.0.0.1:8791
--- 1. preflight from an allowed origin ---
HTTP/1.1 204 No Content
Vary: Origin
Access-Control-Allow-Origin: https://measurements.example.test
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: content-type, x-measurement-key
Access-Control-Max-Age: 600
Connection: keep-alive
Keep-Alive: timeout=5

--- 2. preflight from an origin not on the list ---
HTTP/1.1 204 No Content
Vary: Origin
Connection: keep-alive
Keep-Alive: timeout=5

--- 3. real request from an allowed origin ---
HTTP/1.1 200 OK
Vary: Origin
Access-Control-Allow-Origin: https://measurements.example.test
Access-Control-Allow-Credentials: true
Content-Type: application/json
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 44

{"station":"north-slope","temperature":-3.4}
```

The second case shows what a refusal looks like. The server does not return an error
status; it just does not write the permission headers. The decision belongs to the
browser, and after a preflight response with no permission headers, the browser never
sends the real request at all. This also explains why the refused case does not show up as
an error in the server log.

#### What the Headers Mean

`Access-Control-Allow-Origin` declares the single origin allowed to read the response. Its
value is either a full origin string or an asterisk denoting all origins.

`Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` are meaningful only in a
preflight response; they list which method and which headers the real request may use.
`Access-Control-Max-Age` says how many seconds the preflight result may be cached for,
which keeps another round trip from being made before every request.

`Access-Control-Expose-Headers` extends which headers on the response the script may read.
By default only a small set of headers can be read; an endpoint carrying pagination
information in a custom header has to list that header here, or the script never sees it.

`Vary: Origin` is not a permission header, but its absence is a real defect. Because the
response's content changes based on the request's `Origin` header, every cache along the
path has to include this header in the cache key. If it does not, permission headers
produced for one allowed origin can be served to a different origin.

#### Credentialed Requests

Attaching cookies and identity headers to a cross-origin request also has to be requested
explicitly. On the client side this is done by marking that the request should carry
credentials; on the server side, two rules apply at once.

First, the `Access-Control-Allow-Credentials` header must carry the value `true`. Second,
`Access-Control-Allow-Origin` cannot be an asterisk; a full origin string must be written.
This rule keeps a "publicly open" configuration from unintentionally covering requests
that carry credentials.

Whether the cookie is actually sent depends on one more condition: the cookie's
`SameSite` attribute has to allow cross-origin sending. This attribute is the subject of
the next lesson, and the two mechanisms have to be considered together; even if the
sharing headers are correct, if the cookie attribute blocks it, the request goes out
without credentials.

#### Common Misconfigurations

**Reflecting the incoming origin as-is.** A server that writes the request's `Origin`
header back into the response without validating it looks like it is maintaining an
allowlist but has actually allowed every origin. The example above does reflect, but only
for a value already on the list.

**Trying to combine an asterisk with credentials.** The browser rejects this; switching to
reflecting the origin to get around this rejection reproduces the first defect.

**Writing the domain match loosely.** A check that accepts any name ending in
`example.test` also accepts `example.test.other.test`. The match has to be made against
the full origin, not through the end of a string.

**Allowing the `null` origin.** Some contexts send the `Origin` header as `null`; putting
this value on the allowlist means allowing all of those contexts.

#### Sharing Is Not Authorization

Cross-origin resource sharing protects the user's browser from having another site's
script read data. It does not protect the server. A client that is not a browser does not
observe these headers at all; writing an origin name that is not on the allowlist does not
form a barrier either, because nothing outside a browser is required to honor the `Origin`
header.

The rule that follows is clear: an endpoint is not considered protected without
authentication and authorization. A sharing configuration does not stand in for these
checks; it is layered on top of them.

#### Summary

- The same-origin policy does not block a cross-origin request from being sent; it blocks
  reading its response. A request that produces a side effect still happens.
- Requests that fail to meet the simple-request conditions go through a preflight round
  with the `OPTIONS` method; if the server does not allow it, the real request never
  happens.
- A refusal shows up as the absence of permission headers, not as an error status.
- For credentialed requests, `Access-Control-Allow-Origin` cannot be an asterisk and
  `Access-Control-Allow-Credentials` is required; the cookie's `SameSite` attribute must
  also allow it.
- Sharing configuration is for the browser; the server's authentication and authorization
  are still required independently of it.

#### Next Step

An open question is left at the start of this lesson: if the same-origin policy blocks
reading and a write request still reaches the server, can another site's page delete a
measurement record through the user's session? The response not being readable does not
mean the operation did not happen. The next lesson takes up this class of attack and the
defense built from cookie attributes and request tokens.

### Request Forgery

Source: https://academia.sh/en/courses/frontend-quality/request-forgery

The attack class born from cookies being attached automatically; the decision table of the SameSite and Secure attributes, a session-bound request token, origin header checks, and method discipline.

The previous lesson ended on a question: if the same-origin policy blocks reading the
response, what happens to the write request? The answer is that it is not blocked. A
cross-origin request reaches the server, the server processes it, the side effect happens;
only the response body is not delivered to the page that started the request.

This gap is not a problem on its own. The problem arises when the user's identity is
attached to the request automatically. If the North Slope Measurement Station interface
carries its session in a cookie, the browser attaches that cookie without looking at where
the request was started from. This lesson builds the consequence of that behavior and two
independent layers of defense.

#### Ambient Authority

As defined in the How the Internet Works course, a cookie travels with a request
automatically. This is the behavior that keeps a session alive: the user does not restate
their identity on every page. The same behavior produces a side effect. Who started the
request does not affect whether the cookie gets attached; the browser looks at the target
address, not the origin.

This is called **ambient authority**: the authority is attached not to the code making the
request but to where the request is going. **Cross-site request forgery** is this
authority being triggered from another site's page. While the user is signed into the
measurement station, a page opens in another tab; that page starts a write request to an
endpoint address on the measurement station. Because the request carries the user's
cookie, it looks legitimate on the server side.

The attack does not require script. A form element can be submitted; an image element can
call an address. The response not being readable is not the attacker's concern; the goal
is not to read, it is to make something happen.

#### First Layer: Cookie Attributes

The first defense sits in the cookie itself. The `SameSite` attribute determines whether
the cookie is attached to cross-origin requests, and it takes three values. The code below
computes the decision table for these values across request types.

```js
// samesite.mjs — cookie attributes produce a decision table by request type
const SAFE_METHODS = new Set(['GET', 'HEAD']);

function isSent(cookie, request) {
  if (cookie.secure && request.scheme !== 'https') return ['no', 'a Secure cookie is not sent over an unencrypted scheme'];
  if (request.sameSite) return ['yes', 'same-site request'];
  switch (cookie.sameSite) {
    case 'Strict':
      return ['no', 'Strict excludes every cross-origin request'];
    case 'Lax':
      return request.topLevelNavigation && SAFE_METHODS.has(request.method)
        ? ['yes', 'Lax passes a safe method on top-level navigation']
        : ['no', 'Lax only passes top-level, safe navigation'];
    case 'None':
      return cookie.secure ? ['yes', 'None sends cross-origin'] : ['no', 'None requires Secure'];
    default:
      return ['no', 'unrecognized value'];
  }
}

const requests = [
  { name: 'same-site call', sameSite: true, topLevelNavigation: false, method: 'GET', scheme: 'https' },
  { name: 'outside link (GET)', sameSite: false, topLevelNavigation: true, method: 'GET', scheme: 'https' },
  { name: 'outside form (POST)', sameSite: false, topLevelNavigation: true, method: 'POST', scheme: 'https' },
  { name: 'outside frame', sameSite: false, topLevelNavigation: false, method: 'GET', scheme: 'https' },
  { name: 'outside script call', sameSite: false, topLevelNavigation: false, method: 'POST', scheme: 'https' },
];

const cookies = [
  { name: 'Strict+Secure', sameSite: 'Strict', secure: true },
  { name: 'Lax+Secure', sameSite: 'Lax', secure: true },
  { name: 'None+Secure', sameSite: 'None', secure: true },
  { name: 'None, no Secure', sameSite: 'None', secure: false },
];

const header = ['request type'.padEnd(24), ...cookies.map((c) => c.name.padEnd(18))].join('').trimEnd();
console.log(header);
for (const request of requests) {
  const cells = cookies.map((cookie) => isSent(cookie, request)[0].padEnd(18));
  console.log(request.name.padEnd(24) + cells.join('').trimEnd());
}

console.log('\nreasoning example (Lax+Secure):');
for (const request of requests) {
  const [decision, reason] = isSent(cookies[1], request);
  console.log(' ', request.name.padEnd(24), decision.padEnd(6), reason);
}
```

```
$ node samesite.mjs
request type            Strict+Secure     Lax+Secure        None+Secure       None, no Secure
same-site call          yes               yes               yes               yes
outside link (GET)      no                yes               yes               no
outside form (POST)     no                no                yes               no
outside frame           no                no                yes               no
outside script call     no                no                yes               no

reasoning example (Lax+Secure):
  same-site call           yes    same-site request
  outside link (GET)       yes    Lax passes a safe method on top-level navigation
  outside form (POST)      no     Lax only passes top-level, safe navigation
  outside frame            no     Lax only passes top-level, safe navigation
  outside script call      no     Lax only passes top-level, safe navigation
```

The table makes three rules visible. `Strict` excludes every cross-origin request; its
protection is the broadest, but a user who arrives through a link from another site meets
the page without a session. `Lax` passes the cookie only on top-level navigation and only
with a safe method; it filters out an outside form's `POST` request and a frame load.
`None` sends the cookie in every case and therefore requires the `Secure` attribute; if the
attribute is missing, the cookie is never sent at all.

Alongside this table, two more attributes are an inseparable part of a session cookie.
`HttpOnly` keeps the cookie from being read by script; once the script-execution defense
built in the first lesson is bypassed, it makes stealing the session directly harder.
`Secure` keeps the cookie from being sent over an unencrypted connection.

#### Where SameSite Falls Short

The attribute is a strong mitigation, but it does not stand on its own as a sufficient
defense.

First, "same site" and "same origin" are different measures. The same-site measure is
computed over the registrable domain; `report.example.test` and
`measurements.example.test` are different origins but the same site. Losing control of one
subdomain means requests sent through that domain count as same-site. To narrow this risk,
the cookie name is given a `__Host-` prefix: this prefix requires the cookie to belong only
to the exact host it was set for and keeps its domain scope from being widened.

Second, the behavior applied when the attribute is not declared depends on the
implementation. Some implementations treat an undeclared cookie as if it carried a
restrictive value; others do not. This is why the attribute is not left to the default; it
is written explicitly.

Third, the `Lax` value passes a top-level `GET` navigation. If a state-changing operation
is done with `GET`, this defense does not cover that operation. Method discipline is
therefore part of the defense.

#### Second Layer: Session-Bound Token

The second defense proves that the request was really started from the application's own
page. The server generates an unpredictable **request token** for each session, embeds it
in the page, and expects it back on submission. Binding the token to the session is
critical: without that binding, an attacker could place a token valid for their own
session into another user's request.

```js
// token.mjs — generation and validation of a session-bound request token
import { createHmac, timingSafeEqual } from 'node:crypto';

const SERVER_SECRET = Buffer.from('7b2a0f5c9d1e4a8b6c3f0d2e5a7b9c1d', 'utf8');

function generateToken(sessionId, salt) {
  const signature = createHmac('sha256', SERVER_SECRET).update(`${sessionId}.${salt}`).digest('base64url');
  return `${salt}.${signature}`;
}

function validateToken(sessionId, token) {
  if (typeof token !== 'string' || !token.includes('.')) return false;
  const [salt] = token.split('.');
  const expected = Buffer.from(generateToken(sessionId, salt));
  const given = Buffer.from(token);
  return expected.length === given.length && timingSafeEqual(expected, given);
}

function originCheck(headers, expected) {
  if (headers['sec-fetch-site'] === 'same-origin') return true;
  if (headers.origin) return headers.origin === expected;
  return false;
}

const SESSION = 'session-4417';
const valid = generateToken(SESSION, 'a1b2c3');
console.log('generated token:', valid);

const attempts = [
  ['correct session, correct token', SESSION, valid],
  ["another session's token", 'session-9002', valid],
  ['signature altered', SESSION, `${valid.slice(0, -1)}X`],
  ['no token', SESSION, undefined],
];

console.log('\ntoken validation:');
for (const [name, session, value] of attempts) {
  console.log(' ', name.padEnd(32), validateToken(session, value) ? 'valid' : 'rejected');
}

const EXPECTED_ORIGIN = 'https://measurements.example.test';
const headerSets = [
  ['from own page', { origin: EXPECTED_ORIGIN, 'sec-fetch-site': 'same-origin' }],
  ['no header, signal present', { 'sec-fetch-site': 'same-origin' }],
  ['sent from outside', { origin: 'https://other.example.test', 'sec-fetch-site': 'cross-site' }],
  ['neither present', {}],
];

console.log('\norigin check:');
for (const [name, headers] of headerSets) {
  console.log(' ', name.padEnd(30), originCheck(headers, EXPECTED_ORIGIN) ? 'accepted' : 'rejected');
}
```

```
$ node token.mjs
generated token: a1b2c3.aGkJnbGB2GshtSxoyRjHtnj6VB-7HH0c9Fm7K5ARq_I

token validation:
  correct session, correct token   valid
  another session's token          rejected
  signature altered                rejected
  no token                         rejected

origin check:
  from own page                  accepted
  no header, signal present      accepted
  sent from outside              rejected
  neither present                rejected
```

The token carries a signature generated with the server secret, and the signature covers
the session ID; when another session's token is presented, the signature does not match.
The comparison is done with `timingSafeEqual`: a character-by-character short-circuiting
comparison turns the length of a correct prefix into a measurable timing difference.

How the token is carried is also part of the rule. If it is carried only in a cookie, it
has no value against the attack, because the cookie is also attached automatically. The
token has to live somewhere other than a cookie: a hidden form field, or a request header
the application adds explicitly. The Authentication in the Browser lesson in the
Application Architecture course built the other side of this distinction; in a design that
carries the session in memory with a token rather than a cookie, and attaches it to the
request explicitly, ambient authority never arises in the first place, so this attack class
disappears on its own. The cost is that the session has to be re-established on every page
reload.

#### Third Layer: Origin Check

The last section of the output shows the third layer. The server checks the origin of
every state-changing request. If the `Origin` header is present, it is compared against
the expected origin. For cases where this header is missing, the fetch metadata the
browser adds is used: the `Sec-Fetch-Site` header states whether the request was started
from the same origin, the same site, or from outside.

The last line of the check matters: a request where neither header is present is not
accepted. Treating the absence as "no problem" makes the check something that can be
bypassed by deleting a header. These are headers the browser writes, not application code,
and the browser cannot be made to not write them; there is no reason to interpret their
absence as safe.

#### Method Discipline

Underneath all these layers sits one assumption: state-changing operations are not done
with safe methods. The **safe method** concept defined in the How the Internet Works
course turns into a security requirement here. If an endpoint that deletes a measurement
record can be called with `GET`, even an image element triggers that endpoint; the `Lax`
cookie behavior and most origin checks do not cover this case.

As a rule, write operations are done with the `POST`, `PUT`, `PATCH`, or `DELETE` methods,
and the server explicitly enforces method checking on write endpoints.

#### Summary

- Request forgery arises from a cookie being attached without regard to who started the
  request; the response not being readable does not stop the side effect from happening.
- The `SameSite` attribute is the first layer: `Strict` excludes every cross-origin
  request, `Lax` excludes every request outside top-level, safe navigation, and `None`
  requires `Secure`.
- The same-site measure is broader than the same-origin measure; the subdomain risk is
  narrowed with the `__Host-` prefix and a second layer.
- The request token is bound to the session, carried somewhere other than a cookie, and
  validated with a constant-time comparison.
- The `Origin` and `Sec-Fetch-Site` headers are the third layer; a request where neither is
  present is rejected, and state-changing operations are not exposed to safe methods.

#### Next Step

This lesson's defenses were built one at a time around specific headers and attributes. On
the surface of that same response, there is one more group that is not specific to any
single attack class but reduces the impact of all of them: headers that keep the
connection from staying unencrypted, that limit whether the document can be framed by
another page, and that narrow the address information that leaks out. The next lesson
takes up this group, its gaps, and a configuration check that finds them.

### Security Headers

Source: https://academia.sh/en/courses/frontend-quality/security-headers

Enforcing transport security, restricting framing, narrowing referrer information, turning off MIME sniffing, and cross-origin isolation; a configuration checker that finds what is missing.

The previous three lessons built specific layers against specific attack classes. This
lesson takes up a group that is not tied to a single class but reduces the impact of all
of them: constraints declared through response headers and enforced by the browser.

What these headers have in common is that they live in server configuration, not
application code. It is easy for them to go unnoticed during a release, because they never
break any functionality; the only observable consequence of their absence appears when
something else goes wrong. The checker at the end of this lesson removes that invisibility.

#### Enforcing Transport Security

The Role of HTTPS lesson in the How the Internet Works course established that encrypted
transport provides confidentiality, integrity, and authentication. Even when an
application is served entirely over an encrypted connection, one gap remains: when a user
types the address without a scheme, the first request can go out unencrypted and be
intercepted before the redirect happens.

The `Strict-Transport-Security` header closes this gap. It tells the browser to connect to this host only
over an encrypted connection for the declared duration; an address typed without a scheme is converted
before it ever hits the network. It has three components. `max-age` is the duration in seconds, and it is
kept long for the protection to be meaningful. The `includeSubDomains` rule spreads it across subdomains;
without it, a subdomain can stay unencrypted and affect the application through cookie scope. The third
component is a preload declaration that registers the host in a list built into browsers; it covers even
the first request, but reversing it is slow, and it permanently commits every subdomain to being served
encrypted. Unlike the first two, this third one is not defined in the header's specification: it is a
separate list operated by browser vendors.

Sending the header on an unencrypted response has no effect, and should not: otherwise
someone intercepting the connection could get a record written that makes the host
unreachable.

#### Restricting Framing

The North Slope Measurement Station interface embeds a map frame showing the station's
location. That is one direction of framing. The other direction is the application itself
being framed by another page, and that is the direction that needs defending.

A page placed inside a frame that has been made invisible sits underneath another
interface the user sees. The user thinks they are clicking a button they see, but actually
clicks a control belonging to the page inside the frame; because the session belongs to
them, the action goes through. This class is called **clickjacking**, and its defense is
declaring who is allowed to frame the document.

There are two mechanisms. The `X-Frame-Options` header takes the values `DENY` or
`SAMEORIGIN` and declares a single decision. The content security policy's
`frame-ancestors` directive accepts a list of origins and is more detailed: framing can be
allowed for specific origins only. In an implementation that understands the directive,
the directive wins; writing both together provides protection in implementations that
understand only one of them.

#### Narrowing Referrer Information

When a link is clicked or a subresource is loaded, the browser adds a header telling the destination which
page the request came from. The behavior browsers once treated as the default sent the full address to
other origins as well. On the measurement station, that meant the path and query components of an address
in the form `.../station/north-slope?measurement=4417` leaked to a third party.

The `Referrer-Policy` header narrows this behavior. Its values sit on an axis, from an end that sends
nothing to an end that sends the full address. The balanced choice is a value that sends the full address
to the same origin, sends only the origin string to other origins, and sends nothing on a transition from
encrypted to unencrypted — and in current browsers that is also the default when no header is written. The
header is written explicitly when a narrower behavior is wanted, or when the default is not something to
rely on. If analysis needs this information, it is drawn from the application's own measurement, not from
the header.

#### Turning Off MIME Sniffing

If a response's content type is missing or wrong, the browser can look at the body and
guess the type. In an application where users upload files, this guess turns into a
vulnerability: an upload meant to be served as plain text, if interpreted as markup, runs
as a document within the same origin.

The `X-Content-Type-Options: nosniff` header turns off the guess and forces the browser to
honor the declared type. The header alone is not enough; the declared type being correct,
user uploads being served from a separate origin, and being marked for download are all
part of the same defense.

#### Cross-Origin Isolation and Permission Policy

The `Cross-Origin-Resource-Policy` header declares whether a resource may be embedded by
other origins. When its value is `same-origin`, the resource can only be used in documents
from its own origin. This is a constraint that limits data being pulled into another
document's memory space.

Two more headers belong to the same family: one that cuts browsing-context sharing with
windows the document opens and windows that open it, and one that requires embedded
resources to declare explicit permission. Together the two establish a state called
**cross-origin isolation**. This state is a precondition for some capabilities and is not
required in an application that does not use those capabilities; turning it on
unnecessarily breaks legitimate embedding.

**Permission policy**, introduced in the Web Fundamentals and HTML course, is a separate
axis: it lists which device capabilities the document and the frames it embeds may use. If
the map frame has no need for the location capability, that capability is not granted to
the frame.

#### Auditing the Configuration

Every one of these headers can be written correctly one at a time, and one can drop a
release later. Turning the audit into a rule set makes a dropped header visible.

```js
// headers.mjs — audits response headers against a rule set
const ONE_YEAR = 31536000;

const RULES = [
  {
    name: 'transport security',
    check: (h) => {
      const value = h['strict-transport-security'];
      if (!value) return 'Strict-Transport-Security missing';
      const duration = Number(/max-age=(\d+)/.exec(value)?.[1] ?? 0);
      if (duration < ONE_YEAR) return `max-age ${duration} seconds, shorter than a year`;
      if (!/includeSubDomains/i.test(value)) return 'includeSubDomains missing, subdomains excluded';
      return null;
    },
  },
  {
    name: 'content security policy',
    check: (h) => {
      const value = h['content-security-policy'];
      if (!value) return 'Content-Security-Policy missing';
      if (/script-src[^;]*'unsafe-inline'/.test(value)) return "script-src contains 'unsafe-inline'";
      if (!/frame-ancestors/.test(value)) return 'frame-ancestors directive missing';
      return null;
    },
  },
  {
    name: 'framing',
    check: (h) =>
      h['x-frame-options'] || /frame-ancestors/.test(h['content-security-policy'] ?? '')
        ? null
        : 'framing not restricted',
  },
  {
    name: 'MIME sniffing',
    check: (h) => (h['x-content-type-options'] === 'nosniff' ? null : 'X-Content-Type-Options: nosniff missing'),
  },
  {
    name: 'referrer policy',
    check: (h) => {
      const value = h['referrer-policy'];
      if (!value) return 'Referrer-Policy missing';
      const loose = ['unsafe-url', 'no-referrer-when-downgrade', 'origin-when-cross-origin'];
      return loose.includes(value) ? `loose value: ${value}` : null;
    },
  },
  {
    name: 'cross-origin resource policy',
    check: (h) => (h['cross-origin-resource-policy'] ? null : 'Cross-Origin-Resource-Policy missing'),
  },
  {
    name: 'session cookie',
    check: (h) => {
      const value = h['set-cookie'];
      if (!value) return null;
      const missing = ['HttpOnly', 'Secure', 'SameSite'].filter((n) => !new RegExp(n, 'i').test(value));
      return missing.length ? `cookie attributes missing: ${missing.join(', ')}` : null;
    },
  },
];

function audit(name, headers) {
  const findings = RULES.map((r) => [r.name, r.check(headers)]).filter(([, f]) => f !== null);
  console.log(`\n${name}: ${findings.length} findings`);
  for (const [rule, finding] of findings) console.log('  -', `${rule}:`, finding);
  if (findings.length === 0) console.log('  - no findings');
}

const initialRelease = {
  'content-type': 'text/html; charset=utf-8',
  'strict-transport-security': 'max-age=86400',
  'content-security-policy': "default-src 'self'; script-src 'self' 'unsafe-inline'",
  'referrer-policy': 'unsafe-url',
  'set-cookie': 'measurement_session=abc; Path=/',
};

const fixed = {
  'content-type': 'text/html; charset=utf-8',
  'strict-transport-security': 'max-age=31536000; includeSubDomains',
  'content-security-policy': "default-src 'self'; script-src 'self' 'nonce-r4k2'; frame-ancestors 'none'",
  'x-frame-options': 'DENY',
  'x-content-type-options': 'nosniff',
  'referrer-policy': 'strict-origin-when-cross-origin',
  'cross-origin-resource-policy': 'same-origin',
  'set-cookie': 'measurement_session=abc; Path=/; HttpOnly; Secure; SameSite=Lax',
};

audit('initial release', initialRelease);
audit('fixed configuration', fixed);
```

```
$ node headers.mjs

initial release: 7 findings
  - transport security: max-age 86400 seconds, shorter than a year
  - content security policy: script-src contains 'unsafe-inline'
  - framing: framing not restricted
  - MIME sniffing: X-Content-Type-Options: nosniff missing
  - referrer policy: loose value: unsafe-url
  - cross-origin resource policy: Cross-Origin-Resource-Policy missing
  - session cookie: cookie attributes missing: HttpOnly, Secure, SameSite

fixed configuration: 0 findings
  - no findings
```

Each defect in the first configuration is small on its own and none of them breaks the
application: the transport rule's duration is short, the policy allows inline code,
framing is not restricted, the session cookie carries none of its three attributes. The
findings list makes all of this visible in a single run.

The value of this audit is in running it continuously. Run as a pre-release step, a
configuration change that drops a header shows up the same day, not at the next security
review. The rule set also changes over time; when a new rule is added, the findings list
getting longer is the expected behavior.

#### Summary

- Security headers live in server configuration and go unnoticed because their absence
  breaks no function silently.
- `Strict-Transport-Security` closes the first-request gap; without `includeSubDomains`,
  subdomains remain unprotected.
- Framing is restricted with `X-Frame-Options` or the `frame-ancestors` directive; the
  latter is more detailed because it accepts a list of origins.
- `Referrer-Policy` narrows path and query information leaking out; `X-Content-Type-Options: nosniff`
  turns off MIME sniffing.
- A rule resolver that audits the header set makes a dropped header visible the same day;
  the audit is run as a step in the release pipeline.

#### Next Step

The defenses built so far rested on the application's own code and its own server. The
measurement interface also runs code it did not write: the map frame, the script that
draws the measurement chart, the hundreds of packages in the dependency tree. Every line of
this code runs with the authority of the application's origin. The next lesson takes up the
risk third-party code creates, where it arises across the supply chain, and how subresource
integrity narrows it.

### Third-Party Script Risk

Source: https://academia.sh/en/courses/frontend-quality/third-party-script-risk

Code that was not written in-house running with the page's own origin authority; the links of the supply chain, generating and validating a subresource integrity hash, what the integrity check does not cover, and runtime isolation.

The previous lessons covered the application's own code and its own server's
configuration. The North Slope Measurement Station interface also runs code it did not
write: the external script that draws the measurement chart, the map frame embedded in the
page, and the dependency tree bundled at build time.

This lesson builds the risk that code carries and the layers that narrow it. Its starting
point is a single observation: a third-party script running on the page is no less
privileged than the application's own script.

#### Inherited Authority

The concept of authority built in the first lesson applies again here. Code brought into
the page with a script element runs inside the document's origin. The same-origin policy
does not treat it as foreign: it can read and change the entire document tree, reach
cookies that are not marked `HttpOnly`, read local storage, and send requests with the
user's session. It can see what is typed into the measurement note field before the user
even presses submit.

This transfer of authority is not a defect; it is what a script element is. The defect is
granting that authority without being aware of it. The sentence "a script that draws
charts" does not say the script will only draw charts; it says only that it behaves that
way right now.

The chart script may be well-intentioned on its own. The risk lies in the assumption that
its content today will stay the same tomorrow, and that assumption has to be checked
separately at every link in the chain.

#### Links in the Supply Chain

Working backward to the source of code running on the page reveals several distinct trust
points.

**Direct dependencies** are packages the developer knowingly added; their number stays at
a reviewable scale. **Transitive dependencies** are those packages' own dependencies, and
their number grows fast. Most of the code that ends up in a production bundle usually
comes from this second set and has never been read line by line.

The **package registry** is where versions get distributed. Publishing a version is
something anyone with access to the maintainer's account can do. **Build tools** come with
their own plugins, and code that runs at build time determines what ends up in the
production bundle.

Scripts loaded at runtime through a **content delivery network** are the most fragile link
in the chain: the code never lives in the application's own repository, it can differ on
every page load if the version is not pinned, and a change never shows up in any build
record.

The length of the chain produces a rule: for every link, there has to be an answer to "what
tells us when this content changes." The mechanism below answers that question for the
runtime link.

#### Subresource Integrity

**Subresource integrity** gives a script or stylesheet element a way to declare, ahead of
time, the digest of the content it is about to load. The browser fetches the content,
computes its digest, and compares it against the declared value; if they do not match, it
does not use the resource.

```js
// integrity.mjs — generates and validates a subresource integrity hash
import { createHash } from 'node:crypto';

const SCRIPT_ORIGINAL = 'export function measurementChart(data) { return data.map((n) => n.temperature); }\n';
const SCRIPT_CHANGED = 'export function measurementChart(data) { return data.map((n) => n.temperature ?? 0); }\n';

function integrityValue(content, algorithm = 'sha384') {
  const hash = createHash(algorithm).update(content, 'utf8').digest('base64');
  return `${algorithm}-${hash}`;
}

function validate(content, expectedList) {
  const list = expectedList.trim().split(/\s+/);
  for (const expected of list) {
    const [algorithm] = expected.split('-');
    if (integrityValue(content, algorithm) === expected) return { valid: true, matched: expected };
  }
  return { valid: false, matched: null };
}

const declared = `${integrityValue(SCRIPT_ORIGINAL)} ${integrityValue(SCRIPT_ORIGINAL, 'sha512')}`;
console.log('declared integrity value:');
console.log(' ', declared.split(' ')[0]);
console.log(' ', declared.split(' ')[1]);

console.log('\ndelivered content evaluation:');
for (const [name, content] of [['published version', SCRIPT_ORIGINAL], ['changed version', SCRIPT_CHANGED]]) {
  const result = validate(content, declared);
  console.log(' ', name.padEnd(22), result.valid ? 'loaded' : 'rejected');
}

console.log('\nhash comparison (sha384):');
console.log(' ', 'published'.padEnd(14), integrityValue(SCRIPT_ORIGINAL).slice(0, 34) + '...');
console.log(' ', 'changed'.padEnd(14), integrityValue(SCRIPT_CHANGED).slice(0, 34) + '...');
```

```
$ node integrity.mjs
declared integrity value:
  sha384-LVWTQyA7w7fDnBfx1c0H+qcKSnAFNkBgJUkohgl42LsIDnFIixQReAGIJ77hm49H
  sha512-/BpUuICYtzAvDjEMyZ20SfGMdtz4aOEPfsR3lJ8ZM+1H9nJN1ZvbUX1X+mvzrFaipIwbq0DQ3/iQK8mKZAlg+Q==

delivered content evaluation:
  published version      loaded
  changed version        rejected

hash comparison (sha384):
  published      sha384-LVWTQyA7w7fDnBfx1c0H+qcKSnA...
  changed        sha384-SpoaUzkpz3NBC1t/wDYp21IsQ2Q...
```

The difference between the two versions is a single operator; the hashes do not even
share a common prefix. This behavior of the digest function carries the whole mechanism: a
single character changing in the content makes the hash unrecognizable, and validation
fails.

The declared value can carry more than one hash. The browser picks the strongest algorithm
it supports; if one algorithm is ever considered untrustworthy in the future, the list
allows a transition without changing the element.

Two implementation rules are binding. First, an element loaded cross-origin needs the
`crossorigin` attribute; an integrity check cannot be performed on an opaque response whose
content cannot be read. Second, for the hash to mean anything, the address has to be
versioned. An address that means "the latest version" breaks validation the moment the
provider updates the content; that break is not a defect, it is proof the mechanism is
working — but if the application is expected to keep working, the address has to be fixed.

#### What Integrity Does Not Cover

The integrity check validates that the loaded bytes are the expected bytes. It does not
validate what those bytes do. The provider publishes a new version, the application
declares the new hash, and if the new version does something the first version did not do,
the check raises no warning at all.

The check also only covers the declared element. If the loaded script itself fetches other
scripts at runtime, those fetches are not bound to this element. The content security
policy's source lists close part of this gap: integrity fixes "what will come from this
address," while the policy limits "which addresses loading may happen from." The two
mechanisms do not substitute for each other.

#### Code Added at Runtime

One class of tool separates adding a script to the page from a code change: pieces of code
defined through a management console are brought onto the page at runtime by the
application's loader. On the measurement station, a usage-tracking script added this way
never appears in the application's repository at all.

This arrangement has two security consequences. First, the set of code running on the page
can no longer be read from the repository; the inventory can only be extracted from the
running page. Second, the authority to add code to the page has been opened up to a group
that does not go through code review, and that group's account security becomes part of
the application's security.

There are three ways to narrow this. The content security policy's source lists limit the
addresses the loader can fetch from; this does not stop the tool from being a load point,
but it stops it from being an unbounded one. Violation reports collected in report-only
mode make an unexpected load point visible. The third is administrative: the list of
third-party code running on the page is kept in writing, each entry matched to an owner and
a justification, and an entry with no remaining justification is removed. On a page with no
inventory, none of this lesson's measures count as complete.

#### Isolation and Build-Time Measures

If a third-party component needs to show interface on the page, using a frame loaded from
a separate origin instead of a script element cuts off the transfer of authority. The
**sandboxing** attributes introduced in the Web Fundamentals and HTML course list which
capabilities the frame may use; the permission policy header narrows device access.
Communication is set up over a narrow message interface between the main document and the
frame, and incoming messages are validated against the origin they came from. The map
frame is a typical example of this structure: there is no reason for the map to read
measurement notes, and the frame blocks that structurally.

On the build-time side, four measures work together. The **lockfile** writes resolved
versions and content digests into the repository; a setup that ignores the lockfile
disables this entire defense. **Version pinning** ensures the next install fetches the same
code. **Vulnerability scanning** matches published vulnerability records against the
installed set. **Measuring bundle content** makes the size and composition of code entering
the production bundle visible; a dependency growing unexpectedly is often the first signal.

One last measure is administrative: adding a new dependency is a code change and goes
through the same review. The choice between writing a function in-house and adding a
package is not a line-count comparison; it is a trust-surface decision.

#### Summary

- Third-party code brought into the page with a script element inherits every authority
  the application's origin has; its access to the document tree, cookies, and the network
  is no different from the application's own code.
- The supply chain consists of direct and transitive dependencies, the registry, build
  tools, and the delivery network; every link needs a mechanism that reports when it
  changes.
- Subresource integrity declares ahead of time the digest of content to be loaded; a
  single-character change makes the hash unrecognizable and the resource unusable.
- Integrity validates bytes, not behavior, and covers only the declared element; it is used
  together with the policy's source lists.
- A third-party component that shows interface is moved to an isolated frame loaded from a
  separate origin; on the build side, the lockfile, version pinning, vulnerability
  scanning, and bundle measurement work together.

#### Next Step

The six lessons in this topic focused on specific mechanisms: escaping, policy, sharing
headers, request tokens, security headers, and integrity. Most vulnerabilities encountered
in practice fall into the classes these mechanisms cover, but not all of them do. The next
lesson takes up common vulnerability classes through their client-side projection:
authorization decisions made in the interface, address redirects, and how much trust to
place in data reaching the client.

### Common Web Risks

Source: https://academia.sh/en/courses/frontend-quality/common-web-risks

The client-side counterparts of common vulnerability classes; authorization decisions made in the interface, open redirect, trust in data reaching the client, and secrets leaking into the bundle.

The six lessons in this topic built specific mechanisms: escaping at output, the content
security policy, cross-origin resource sharing rules, request tokens, security headers,
and subresource integrity. Each one corresponded to an attack class that already has a
name.

Part of the problems encountered in practice fall outside these classes and share a common
root: assuming the client is a security boundary. This lesson takes up common
vulnerability classes through their client-side projection and gives a measurable rule for
each.

#### Hiding in the Interface Is Not the Same as Granting Authority

In the North Slope Measurement Station interface, the button for editing a measurement
record is shown only to users with the editor role. This is a correct interface decision
and an incorrect security decision. The button not being visible does not mean the request
it triggers cannot be sent; a request is just an address and a body.

```js
// authorization.mjs — the independence of client-side hiding from server-side authorization
function uiShowsButton(user) {
  return user.roles.includes('editor');
}

function serverAllows(session, record) {
  if (!session.verified) return false;
  if (!session.roles.includes('editor')) return false;
  return record.station === session.station;
}

const record = { station: 'north-slope', measurementNo: 4417 };
const cases = [
  ['editor, own station', { verified: true, roles: ['editor'], station: 'north-slope' }],
  ['editor, another station', { verified: true, roles: ['editor'], station: 'south-ridge' }],
  ['reader only', { verified: true, roles: ['reader'], station: 'north-slope' }],
  ['unverified', { verified: false, roles: ['editor'], station: 'north-slope' }],
];

console.log('case'.padEnd(30), 'button visible', 'request accepted');
for (const [name, session] of cases) {
  const visible = uiShowsButton({ roles: session.roles }) ? 'yes' : 'no';
  const accepted = serverAllows(session, record) ? 'yes' : 'no';
  console.log(name.padEnd(30), visible.padEnd(15), accepted);
}
```

```
$ node authorization.mjs
case                           button visible request accepted
editor, own station            yes             yes
editor, another station        yes             no
reader only                    no              no
unverified                     yes             no
```

The two columns being computed separately was deliberate. The second and fourth rows are
cases where the interface says "yes" but the server says "no"; the interface-side decision
only sees the role, while the server-side decision also sees which station the record
belongs to and whether the session has been verified.

This distinction is the root of two subclasses. **Broken object-level access control** is
the server not performing an ownership check when a record's identifier is changed; the
problem is caught late because the interface never shows that record at all. **Vertical
privilege escalation** is an administrative operation, hidden only in the interface, that
can be called directly. Both share the same defense: every request is re-evaluated on the
server, based on who is making the request and what they are accessing.

The same rule applies to validation. A form's client-side constraint validation is a
usability feature; it surfaces an error to the user early. The check that guarantees data
integrity is the one on the server, and the client-side rule's presence does not make it
unnecessary.

#### Open Redirect

The sign-in flow sends the user back to the page they came from after verification. The
target address is usually carried in the query string, and if it is not validated, the
application turns into a tool that redirects to any address. The harm is not direct: the
application's own domain gets used to send the user somewhere else.

```js
// redirect.mjs — validates the return-navigation target against an allowlist
const OWN_ORIGIN = 'https://measurements.example.test';
const ALLOWED_ORIGINS = new Set([OWN_ORIGIN, 'https://report.example.test']);
const DEFAULT = OWN_ORIGIN + '/station';

function returnTarget(raw) {
  if (typeof raw !== 'string' || raw === '') return DEFAULT;
  let url;
  try {
    url = new URL(raw, OWN_ORIGIN);
  } catch {
    return DEFAULT;
  }
  if (!['http:', 'https:'].includes(url.protocol)) return DEFAULT;
  if (!ALLOWED_ORIGINS.has(url.origin)) return DEFAULT;
  return url.href;
}

const candidates = [
  '/station/north-slope?day=12',
  'https://report.example.test/summary',
  'https://other.example.test/login',
  '//other.example.test/login',
  'https://measurements.example.test.other.test/login',
  'measurement-app://open',
  '',
];

for (const candidate of candidates) {
  console.log(returnTarget(candidate).padEnd(52), '<-', candidate === '' ? '(empty)' : candidate);
}
```

```
$ node redirect.mjs
https://measurements.example.test/station/north-slope?day=12 <- /station/north-slope?day=12
https://report.example.test/summary                  <- https://report.example.test/summary
https://measurements.example.test/station            <- https://other.example.test/login
https://measurements.example.test/station            <- //other.example.test/login
https://measurements.example.test/station            <- https://measurements.example.test.other.test/login
https://measurements.example.test/station            <- measurement-app://open
https://measurements.example.test/station            <- (empty)
```

The fourth and fifth lines show why this check cannot be written as a string comparison.
An address that starts with two slashes and no scheme goes to a different host while
inheriting the page's scheme; a "starts with our own domain name" criterion, meanwhile,
lets `measurements.example.test.other.test` through. The comparison is made on the
`origin` component of the resolved address, and any input that cannot be validated falls
through to a fixed default, not to the allowlist.

#### Trust in Data Reaching the Client

A preference read from local storage, a state decoded from an address fragment, or a
configuration object from an endpoint is not data the application produced itself. Code
that merges this data into application objects has to assume the key names are data too.

```js
// merge.mjs — merges externally sourced settings without touching the prototype chain
const DANGEROUS_KEYS = new Set(['__proto__', 'prototype', 'constructor']);

function safeMerge(base, incoming) {
  const result = Object.create(null);
  for (const [key, value] of Object.entries(base)) result[key] = value;
  for (const [key, value] of Object.entries(incoming)) {
    if (DANGEROUS_KEYS.has(key)) {
      console.log('  skipped key:', key);
      continue;
    }
    result[key] = value;
  }
  return result;
}

const defaults = { unit: 'celsius', threshold: -5, autoRefresh: true };
const fromRecord = JSON.parse('{"threshold": -12, "__proto__": {"admin": true}, "unit": "kelvin"}');

console.log('merging:');
const settings = safeMerge(defaults, fromRecord);

console.log('result:', JSON.stringify(settings));
console.log('prototype chain:', Object.getPrototypeOf(settings));
console.log('admin field on an unrelated object:', ({}).admin);
```

```
$ node merge.mjs
merging:
  skipped key: __proto__
result: {"unit":"kelvin","threshold":-12,"autoRefresh":true}
prototype chain: null
admin field on an unrelated object: undefined
```

The prototype chain introduced in the Objects and Functions in JavaScript course turns here into the
attack surface known as **prototype pollution**: a field written to a shared prototype becomes visible on
every object descending from that prototype. The defense has two parts. Keys that reach the chain are
skipped during the merge, and the target object is created as a **null-prototype object**; the last line
confirms that an unrelated object was not affected.

Another application of the same principle is validating external data against a schema.
The approach built in the Validation Schemas lesson of the Application Architecture course
gains security value here: a schema that lists the expected fields structurally filters
out unexpected keys.

#### Secrets Leaking into the Bundle

Everything sent to the client is public. This sentence is not a warning; it is a
definition: the bundle file can be downloaded, the network panel can be opened, and a
source map can be read.

Three rules follow from this. A secret key for an external service does not go into the
client bundle; calls the client needs go through its own server. Configuration meaningful
only on the client and secrets meaningful only on the server are kept in separate places,
and which variables the build tool embeds into the client bundle is checked explicitly.
Whether source maps are served publicly in production is a conscious decision; if they are
needed for error tracking, they are handed to the error-collection layer, not to the
browser.

The same rule applies to the error surface. Error messages returned to the client do not
carry internal structure information; error records collected from the client can carry
user data and have to be scrubbed before collection.

#### Reading the Classes Together

The common thread across the seven lessons in this topic comes down to a single measure:
where a check runs determines how much it can be trusted. Every check that runs on the
client serves the user experience and is under the user's control. The checks the browser
enforces — the same-origin policy, the content security policy, cookie attributes,
integrity validation — rest on rules the server declared and protect the user's browser.
Checks that run on the server protect the application's data, and none of the others
substitute for them.

Mixing up these three layers is the common cause behind most of the vulnerability classes
covered in this topic.

#### Summary

- Hiding a check in the interface is not authorization; every request is re-evaluated on
  the server, based on who is making it and what record it reaches.
- Client-side constraint validation is for usability; data integrity is guaranteed by
  server-side validation.
- A redirect target is checked against an allowlist through the origin of the resolved
  address; input that cannot be validated falls through to a fixed default.
- Key names on objects coming from external sources are data too; the merge skips keys
  that reach the prototype chain and builds the target as a null-prototype object.
- Everything sent to the client is public; secret keys do not go into the bundle, and
  publishing source maps is a conscious decision.
- How much a check can be trusted depends on where it runs: the client, browser, and
  server layers do not substitute for one another.

#### Next Step

The defenses built across this topic share a common weakness: every one of them can break
silently. An escape point gets missed, a policy directive gets loosened, a cookie attribute
drops, an integrity value is left unupdated — and the interface keeps working in every
case. The last axis of quality is therefore measurement. The next topic opens with tests
that verify behavior stays as expected: the Unit and Component Tests lesson builds the
rules for testing what components actually do, through their behavior.

## Testing and Monitoring

Behavior-focused component testing, selecting elements by accessible role, end-to-end flow verification, visual regression comparison, the causes of flaky tests, and reading error records alongside field data returning from production.

### Unit and Component Tests

Source: https://academia.sh/en/courses/frontend-quality/unit-and-component-tests

The boundary that separates a component test from a unit test, the split between behavior contract and implementation detail, and how the two test forms behave differently under the same refactor.

The security topic closed with a sequence of defense layers: output escaping, content
security policy, origin restrictions, tokens against request forgery, security headers, and
third-party script auditing. The performance topic left behind a budget, the accessibility
topic a set of criteria. All of these decisions ask the same question: when what was built
breaks, what will report it?

Manual review does not answer this question. Noticing that a defense was removed, a role
was dropped, or a budget was exceeded requires a mechanism that sees that change. This topic
builds that mechanism from two directions: tests that run before a change and monitoring
that runs after a release. The topic's first question is the narrowest one — how is a
component's correct behavior tested?

#### The Boundary Under Test

Test types are distinguished not by what they test, but by **how far they extend**.

A **unit test** tests a single function or module without touching the outside world. A
pure function that applies a filter criterion to a measurement list falls into this
category. The format set up in the Test lesson of the Node.js Runtime course applies
unchanged here.

A **component test** sets up a component with its props, sends it a user event, and
examines the rendered result. Its boundary is the component's own tree: the network
outside, the address bar, and storage lie beyond that boundary.

An **integration test** tests several components together, or a component together with the
data layer. An **end-to-end test**, in turn, runs the application in a real browser; the
third lesson covers it.

What distinguishes a component test is that both its input and its output are in the user's
vocabulary: the input is an event, the output is what appears on screen. The test does not
need to look inside the component — and not looking is the actual subject of this lesson.

#### A Small Component Model

Seeing the logic of component testing does not require a framework. The following
twenty-five lines give the essence of a component model: state, the tree produced from that
state, and the event that advances the state.

```js
// component-model.mjs — a small component model: state, render, event
export function o(type, props = {}, ...children) {
  return { type, props, children: children.flat() };
}

export function mount(component) {
  let state = component.init();
  const instance = {
    state: () => state,
    tree: () => component.render(state),
    dispatch(event) {
      state = component.reduce(state, event);
      return instance;
    },
  };
  return instance;
}

export function* walk(node) {
  if (node === null || typeof node !== 'object') return;
  yield node;
  for (const child of node.children ?? []) yield* walk(child);
}

export function text(node) {
  if (typeof node === 'string') return node;
  return (node.children ?? []).map(text).join(' ').trim();
}
```

`mount` brings a component up for testing: it holds the state, calls the reducer when an
event arrives, and produces the current tree whenever it is asked for. The declarative
model from the Component-Based Interface Development course is at its plainest here;
actually writing the tree to the document is not necessary for testing.

The component under test is the measurement table of the North Slope interface: a filter
field and a table showing the matching measurements.

```js
// measurement-table.mjs — version 1
import { o } from './component-model.mjs';

const MEASUREMENTS = [
  { station: 'north-slope-01', value: 21.4 },
  { station: 'north-slope-02', value: 19.8 },
  { station: 'east-ridge-01', value: 24.1 },
  { station: 'valley-floor-03', value: 17.2 },
];

export const MeasurementTable = {
  init: () => ({ filterText: '', measurements: MEASUREMENTS }),

  reduce(state, event) {
    if (event.type === 'filter-changed') return { ...state, filterText: event.value };
    return state;
  },

  render(state) {
    const visible = state.measurements.filter((x) => x.station.includes(state.filterText));
    return o('section', { class: 'measurement-table' },
      o('label', { for: 'filter' }, 'Station filter'),
      o('input', { id: 'filter', type: 'search', value: state.filterText }),
      o('table', { class: 'table' },
        ...visible.map((x) =>
          o('tr', { class: 'row' },
            o('td', {}, x.station),
            o('td', {}, x.value.toFixed(1)))),
      ),
      o('p', { class: 'summary' }, `${visible.length} measurements`),
    );
  },
};
```

#### Two Test Forms

The same component can be tested in two different ways. The first form looks inside the
component: it depends on the state object's field name and the order of children in the
tree. The second form verifies only what is visible from outside.

```js
// measurement-table.test.mjs
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { mount, walk, text } from './component-model.mjs';
import { MeasurementTable } from './measurement-table.mjs';

function rowTexts(tree) {
  return [...walk(tree)].filter((d) => d.type === 'tr').map(text);
}

describe('depends on implementation detail', () => {
  test('filter state holds the typed value', () => {
    const instance = mount(MeasurementTable);
    instance.dispatch({ type: 'filter-changed', value: 'north' });
    assert.equal(instance.state().filterText, 'north');
  });

  test('second child is the input element', () => {
    const instance = mount(MeasurementTable);
    assert.equal(instance.tree().children[1].type, 'input');
  });
});

describe('depends on behavior', () => {
  test('typing a filter leaves only matching stations', () => {
    const instance = mount(MeasurementTable);
    instance.dispatch({ type: 'filter-changed', value: 'north' });
    assert.deepEqual(rowTexts(instance.tree()), [
      'north-slope-01 21.4',
      'north-slope-02 19.8',
    ]);
  });

  test('no match empties the table and the summary reads zero', () => {
    const instance = mount(MeasurementTable);
    instance.dispatch({ type: 'filter-changed', value: 'summit' });
    assert.deepEqual(rowTexts(instance.tree()), []);
    assert.match(text(instance.tree()), /0 measurements/);
  });
});
```

The three files sit in the same directory, and the test runner is part of the runtime
itself:

```sh
node --test measurement-table.test.mjs
```

```
▶ depends on implementation detail
  ✔ filter state holds the typed value (0.240208ms)
  ✔ second child is the input element (0.089667ms)
✔ depends on implementation detail (0.887ms)
▶ depends on behavior
  ✔ typing a filter leaves only matching stations (0.324792ms)
  ✔ no match empties the table and the summary reads zero (0.081ms)
✔ depends on behavior (0.462792ms)
ℹ tests 4
ℹ suites 2
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 32.395708
```

The duration fields are environment-dependent and change on every run; what is meaningful
is the counters. All four pass. At this point, no difference is visible between the two
forms.

#### The Refactoring Test

The difference shows up when the code changes. The version below makes two changes: the
state field's name became `query` instead of `filterText`, and the filter field and its
label were wrapped in a container. The filtering logic moved into a separate function.
Nothing the user sees changed.

```js
// measurement-table.mjs — version 2: field renamed, filter panel wrapped
import { o } from './component-model.mjs';

const MEASUREMENTS = [
  { station: 'north-slope-01', value: 21.4 },
  { station: 'north-slope-02', value: 19.8 },
  { station: 'east-ridge-01', value: 24.1 },
  { station: 'valley-floor-03', value: 17.2 },
];

function filter(measurements, query) {
  return measurements.filter((x) => x.station.includes(query));
}

export const MeasurementTable = {
  init: () => ({ query: '', measurements: MEASUREMENTS }),

  reduce(state, event) {
    if (event.type === 'filter-changed') return { ...state, query: event.value };
    return state;
  },

  render(state) {
    const visible = filter(state.measurements, state.query);
    return o('section', { class: 'measurement-table' },
      o('div', { class: 'filter-panel' },
        o('label', { for: 'filter' }, 'Station filter'),
        o('input', { id: 'filter', type: 'search', value: state.query })),
      o('table', { class: 'table' },
        ...visible.map((x) =>
          o('tr', { class: 'row' },
            o('td', {}, x.station),
            o('td', {}, x.value.toFixed(1)))),
      ),
      o('p', { class: 'summary' }, `${visible.length} measurements`),
    );
  },
};
```

The test file is unchanged and rerun with the same command. Some counter lines and the
stack-trace lines inside the failure bodies have been removed from the output below.

```
▶ depends on implementation detail
  ✖ filter state holds the typed value (0.918916ms)
  ✖ second child is the input element (0.426417ms)
✖ depends on implementation detail (1.713958ms)
▶ depends on behavior
  ✔ typing a filter leaves only matching stations (0.33275ms)
  ✔ no match empties the table and the summary reads zero (0.087541ms)
✔ depends on behavior (0.495792ms)
ℹ tests 4
ℹ pass 2
ℹ fail 2
✖ filter state holds the typed value
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  + actual - expected
  + undefined
  - 'north'
✖ second child is the input element
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  'table' !== 'input'
```

Two tests failed, and neither reports a real defect. What they report is that the code was
refactored. This is the most expensive false signal a test can give: red even though the
change is safe, so every refactor also brings test maintenance. After a while, the team
gives up on refactoring instead of fixing the tests.

The two tests that depend on behavior stayed silent through the same change — because what
they test had not changed. The distinction is this: a component's **behavior contract** is
what the user can observe. The text visible on screen, the roles in the accessibility tree,
the external calls it triggers. The **implementation detail** is the path chosen to fulfill
that contract: the state field's name, the wrappers in the tree, how helper functions are
split.

As a rule: a test should not depend on anything the person who wrote the component should be
free to change.

#### Changing What Lies Beyond the Boundary

A component test also has places that open onto the outside: network requests, the clock,
randomness, storage. These lie beyond the boundary under test, and a **test double** is put
in their place. Distinguishing two forms is enough: a **stub** returns a fixed response, a
**mock** additionally records the calls made to it, and the test verifies those records.

The method set up in the Test lesson of the Node.js Runtime course applies here too: the
dependency is supplied from outside. If the component reads the clock as a prop instead of
from within itself, the test supplies a fixed clock and the output becomes deterministic.
The same rule works for the network — when the function that fetches the measurement list is
supplied from outside, the component test never touches the network.

A mock has a cost: what it verifies is not the real dependency's behavior, but an assumption
about that behavior. If the assumption drifts, the test stays green while the application
breaks. This is why a test's value rises as the number of mocks falls; pushing the boundary
as far out as possible is the reason integration tests exist.

#### Summary

- Test types are distinguished not by what they test but by the boundary they extend to; a
  component test's boundary is the component's own tree.
- A component test's input is an event, its output is what appears on screen; both are in
  the user's vocabulary.
- The behavior contract is what is observable; the implementation detail is the path that
  fulfills it. A test should depend only on the former.
- In a refactor that changed a field name and the tree structure, the two tests that
  depended on implementation detail failed and the two that depended on behavior passed;
  the failures did not report a real defect.
- Dependencies beyond the boundary, such as the network, the clock, and randomness, are
  supplied from outside; a stub returns a fixed response, a mock also records the calls.

#### Next Step

Writing a behavior-dependent test leaves one question open: how will the element under test
be found inside the test? The helper function in this lesson searched by node type, which
still depended on an implementation detail — the tree's choice of tag. A selector based on a
class name carries the same problem. Neither is how the user recognizes the element: the
user finds the button because it is a button and because it reads "Save". The next lesson
turns these two pieces of information — the accessible role and the accessible name — into a
query criterion.

### User-Centric Queries

Source: https://academia.sh/en/courses/frontend-quality/user-centric-queries

Turning the accessible role and accessible name into a query criterion, querying the same document by role and by class selector, and how the two criteria behave under refactoring and an accessibility regression.

The previous lesson showed that a behavior-dependent test is more resilient than an
implementation-detail-dependent one, but it still looked at the tree's choice of tag to find
the element under test. A selector based on a class name is the same kind of dependency:
both depend on something the author should be free to change.

The user does not find the element this way. A user working with a mouse looks for a button
that reads "Save"; a user working with a screen reader reaches the element announced as
"Save, button". What the two have in common is two pieces of information: the element's
**role** and **accessible name**. This lesson turns those two pieces of information into a
query criterion.

#### The Source of the Criterion

The Web Fundamentals and HTML course defined the accessibility tree: a second tree the
browser derives from the document tree and presents to assistive technology. Every node in
this tree has a role, and most have a name. The role says what the element is (button, link,
search box, row), the name says which one.

Querying by these two pieces of information has two consequences for the test. The first is
resilience: class names, wrapper containers, and styling architecture decisions do not
change the role. The second, and more important, is that the query itself becomes an
accessibility check. A role query cannot find an element that has no role; the test turns
red, and what it reports is a real defect.

#### How the Role and Name Are Computed

The following module builds an instructive subset of the computation: the implicit roles of
common elements, the precedence of the explicit `role` attribute, and the order of name
computation.

```js
// query.mjs — element selection by accessible role and name
export function o(type, props = {}, ...children) {
  return { type, props, children: children.flat() };
}

export function* walk(node) {
  if (node === null || typeof node !== 'object') return;
  yield node;
  for (const child of node.children ?? []) yield* walk(child);
}

function text(node) {
  if (typeof node === 'string') return node;
  return (node.children ?? []).map(text).join(' ').replace(/\s+/g, ' ').trim();
}

const IMPLICIT_ROLE = {
  button: () => 'button',
  a: (props) => (props.href === undefined ? null : 'link'),
  table: () => 'table',
  tr: () => 'row',
  td: () => 'cell',
  th: (props) => (props.scope === 'row' ? 'rowheader' : 'columnheader'),
  ul: () => 'list',
  li: () => 'listitem',
  nav: () => 'navigation',
  main: () => 'main',
  h1: () => 'heading',
  h2: () => 'heading',
  h3: () => 'heading',
  form: (props) => (props['aria-label'] === undefined ? null : 'form'),
  input: (props) => ({
    search: 'searchbox',
    text: 'textbox',
    number: 'spinbutton',
    checkbox: 'checkbox',
    submit: 'button',
  })[props.type ?? 'text'] ?? null,
};

export function role(node) {
  if (node.props?.role) return node.props.role;
  const rule = IMPLICIT_ROLE[node.type];
  return rule ? rule(node.props ?? {}) : null;
}

export function accessibleName(tree, node) {
  const props = node.props ?? {};
  if (props['aria-labelledby']) {
    const target = [...walk(tree)].find((d) => d.props?.id === props['aria-labelledby']);
    if (target) return text(target);
  }
  if (props['aria-label']) return props['aria-label'];
  if (props.id) {
    const label = [...walk(tree)].find((d) => d.type === 'label' && d.props?.for === props.id);
    if (label) return text(label);
  }
  if (['button', 'a', 'h1', 'h2', 'h3', 'td', 'th'].includes(node.type)) return text(node);
  if (props.title) return props.title;
  if (props.value !== undefined && node.type === 'input' && props.type === 'submit') return props.value;
  return '';
}

export function byRole(tree, wanted, { name } = {}) {
  return [...walk(tree)].filter(
    (d) => role(d) === wanted && (name === undefined || accessibleName(tree, d) === name),
  );
}

export function byClass(tree, className) {
  return [...walk(tree)].filter((d) => (d.props?.class ?? '').split(' ').includes(className));
}
```

Three details match the real computation. An explicit `role` attribute overrides the implicit role. Name
computation is a priority sequence, and the first one found wins: a reference to another element, an
explicitly given label string, a native label element, the element's own content, and `title` last. Some
elements get a role conditioned on an attribute — a link is a link only when it carries `href`, a form
element counts as a landmark only once it has an accessible name.

The real computation is broader than this: the exclusion of hidden elements, the recursion
of name computation, and the full role mapping are all defined in the standard. The subset
here is enough to show what the query depends on.

#### Querying the Same Document by Two Criteria

The document under test is the measurement entry form. There are three versions: the initial
markup, a version where only the class names changed, and a version where the save button
turned into a container without a role.

```js
// entry-form.mjs — three versions of the measurement entry form
import { o } from './query.mjs';

// Version 1: initial markup
export const version1 = () =>
  o('form', { 'aria-label': 'Measurement entry' },
    o('h2', { class: 'form__title' }, 'New measurement'),
    o('label', { for: 'station', class: 'form__label' }, 'Station'),
    o('input', { id: 'station', type: 'text', class: 'form__input' }),
    o('label', { for: 'value', class: 'form__label' }, 'Temperature'),
    o('input', { id: 'value', type: 'number', class: 'form__input' }),
    o('button', { type: 'submit', class: 'button button--primary' }, 'Save'));

// Version 2: only class names and wrappers changed
export const version2 = () =>
  o('form', { 'aria-label': 'Measurement entry' },
    o('h2', { class: 'stack__title' }, 'New measurement'),
    o('div', { class: 'field' },
      o('label', { for: 'station', class: 'field__name' }, 'Station'),
      o('input', { id: 'station', type: 'text', class: 'field__value' })),
    o('div', { class: 'field' },
      o('label', { for: 'value', class: 'field__name' }, 'Temperature'),
      o('input', { id: 'value', type: 'number', class: 'field__value' })),
    o('button', { type: 'submit', class: 'action action--primary' }, 'Save'));

// Version 3: the save button turned into a container without a role
export const version3 = () =>
  o('form', { 'aria-label': 'Measurement entry' },
    o('h2', { class: 'stack__title' }, 'New measurement'),
    o('div', { class: 'field' },
      o('label', { for: 'station', class: 'field__name' }, 'Station'),
      o('input', { id: 'station', type: 'text', class: 'field__value' })),
    o('div', { class: 'field' },
      o('label', { for: 'value', class: 'field__name' }, 'Temperature'),
      o('input', { id: 'value', type: 'number', class: 'field__value' })),
    o('div', { class: 'action action--primary', onclick: 'submit()' }, 'Save'));
```

```js
// query.test.mjs
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { byRole, byClass, accessibleName } from './query.mjs';
import { version1, version2, version3 } from './entry-form.mjs';

describe('version 1', () => {
  test('role query finds the save button', () => {
    const tree = version1();
    assert.equal(byRole(tree, 'button', { name: 'Save' }).length, 1);
  });

  test('class query finds the same button', () => {
    assert.equal(byClass(version1(), 'button').length, 1);
  });

  test('the accessible name of a labeled input is the label text', () => {
    const tree = version1();
    const [input] = byRole(tree, 'spinbutton');
    assert.equal(accessibleName(tree, input), 'Temperature');
  });
});

describe('version 2 — class names changed', () => {
  test('role query still finds it', () => {
    const tree = version2();
    assert.equal(byRole(tree, 'button', { name: 'Save' }).length, 1);
  });

  test('class query no longer finds it', () => {
    assert.equal(byClass(version2(), 'button').length, 0);
  });
});

describe('version 3 — button turned into a roleless container', () => {
  test('class query keeps finding it', () => {
    assert.equal(byClass(version3(), 'action').length, 1);
  });

  test('role query cannot find it', () => {
    const tree = version3();
    assert.equal(byRole(tree, 'button', { name: 'Save' }).length, 0);
  });
});
```

```sh
node --test query.test.mjs
```

```
▶ version 1
  ✔ role query finds the save button (0.628959ms)
  ✔ class query finds the same button (0.075542ms)
  ✔ the accessible name of a labeled input is the label text (0.072583ms)
✔ version 1 (1.13875ms)
▶ version 2 — class names changed
  ✔ role query still finds it (0.083208ms)
  ✔ class query no longer finds it (0.048042ms)
✔ version 2 — class names changed (0.192292ms)
▶ version 3 — button turned into a roleless container
  ✔ class query keeps finding it (0.084083ms)
  ✔ role query cannot find it (0.067042ms)
✔ version 3 — button turned into a roleless container (0.223083ms)
ℹ tests 7
ℹ suites 3
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 34.047208
```

The behavior of the two criteria across the three versions can be summarized in a table.

| Change | Role-and-name query | Class selector |
|---|---|---|
| Class names and wrappers changed | finds it | does not find it |
| Button turned into a roleless container | does not find it | finds it |

The two rows give two different kinds of information. In the first row, the class selector
produces a **false positive**: the test turns red even though behavior has not changed. In
the second row, the role query cannot find it, because **there is no longer a button to
find** — that container cannot be focused with the keyboard, cannot be activated with the
space bar, and is not announced to a screen reader as a button. The class selector still
"finding" it in this version is not good news; it shows that the test does not see a real
regression.

#### The Priority of Criteria

In practice, an order can be established. At the top are **role and accessible name**: this
is the primary criterion for every interactive element. Next comes **label text**; in form
fields, it already gives the same information together with the role. Third, for content
that is not interactive, comes **visible text** — such as the message announced by an alert
region.

At the very bottom stands the **test attribute**: a data attribute added only for testing.
Its cost is that it creates a contract the user never sees; the test stays green even if the
element drops out of the accessibility tree entirely. There are two justified cases: a
structural container that has no accessible name by nature, and the need to pick out one
specific element among many that share the same role and the same name.

A query finding more than one element is also information. If the same page has two buttons
named "Save", the test's ambiguity is the user's ambiguity; the fix is not to narrow the
query but to differentiate the names.

#### Summary

- The user recognizes an element by its role and its name; when the query's criteria are
  these two, the test carries the user's point of view.
- The implicit role derives from the element type and its attributes; an explicit `role`
  attribute overrides it. Name computation is a priority sequence, and the first one found
  wins.
- When class names and wrappers changed, the role query kept finding the element and the
  class selector produced a false positive.
- When the button turned into a roleless container, the role query could not find it; this
  is a real accessibility regression showing up in the test.
- The order of criteria is role and name, label text, visible text, and the test attribute
  last; the test attribute is a last resort because it creates a contract the user does not
  see.

#### Next Step

A component test is limited to the component's own tree. The measurement list actually
coming from the server, the address bar updating, the form being submitted, and the list
refreshing all lie outside that boundary. What the user experiences is exactly this chain:
while the parts work correctly one by one, the flow can break somewhere. The next lesson
covers tests that run the application from start to finish in a real browser, modeling the
flow as a state machine, and why these tests are kept few in number.

### End-to-End Tests

Source: https://academia.sh/en/courses/frontend-quality/end-to-end-tests

The risk covered by tests that run in a real browser, modeling and verifying the flow as a state machine, decisions about waiting and test data isolation, and why these tests are kept few in number.

A component test is limited to the component's own tree. The measurement list coming from
the server, the address updating, the form being submitted, and the list refreshing all lie
outside that boundary. While the parts work correctly one by one, it is common for the flow
to break somewhere: a module has dropped out of the build output, a routing rule has
changed, the field name the server returns differs from what the client expects.

An end-to-end test closes this gap. It starts the application in a real browser, sends real
input events, and verifies what the user sees.

#### The Risk Covered

A **browser driver** runs the test: a program that launches the browser under control,
navigates to an address, produces click and key events, and can read the document and the
accessibility tree. From the application's point of view, the events the driver produces are
real user events.

This setup covers a set of risks no other test type can see. Whether the build output
actually works — code that passes in development mode can break in the production build
because tree shaking dropped an export. Routing and deep linking. The data contract between
server and client. Session and cookie behavior. Whether security headers are actually
enforced: the policy written in the Content Security Policy lesson is tested only once a
real browser actually rejects a script.

Its cost is just as large. An end-to-end test takes hundreds of times as long as a component
test. When it fails, it does not say which layer is at fault; diagnosis requires separate
work. And because it depends on the outside world, it is exposed to flakiness — the fifth
lesson will enumerate the reasons for that.

#### Modeling the Flow First

The efficient way to write an end-to-end test is to think of the flow not as a list of steps
but as a **state machine**. The same concept used for the parser in the Web Fundamentals and
HTML course is applied here to the interface flow: a finite number of states, with
transitions between them defined by events.

The measurement entry flow of the North Slope interface carries the following states: page
idle, list loading, list ready, form open, submitting, list error.

```js
// flow.mjs — the measurement entry flow's state machine
export const TRANSITIONS = {
  idle: { 'page-opened': 'listLoading' },
  listLoading: { 'list-arrived': 'listReady', 'request-failed': 'listError' },
  listReady: { 'new-measurement': 'formOpen', 'filter-changed': 'listLoading' },
  formOpen: { 'submit': 'submitting', 'cancel': 'listReady' },
  submitting: { 'record-accepted': 'listLoading', 'validation-error': 'formOpen' },
  listError: {},
};

export function runScenario(transitions, start, steps) {
  let state = start;
  const trace = [state];
  for (const step of steps) {
    const next = transitions[state]?.[step];
    if (next === undefined) {
      throw new Error(`no '${step}' step defined in state '${state}'`);
    }
    state = next;
    trace.push(state);
  }
  return { state, trace };
}

export function reachable(transitions, start) {
  const seen = new Set([start]);
  const queue = [start];
  while (queue.length > 0) {
    const current = queue.shift();
    for (const target of Object.values(transitions[current] ?? {})) {
      if (seen.has(target)) continue;
      seen.add(target);
      queue.push(target);
    }
  }
  return seen;
}

export function unrecoverable(transitions, safeState) {
  return Object.keys(transitions).filter(
    (s) => s !== safeState && !reachable(transitions, s).has(safeState),
  );
}
```

The `reachable` function is a direct application of breadth-first search from the Data
Structures course: the transition table is a graph, states are nodes, events are edges.

Once the model is built, two kinds of verification can be written. The first is scenario
verification: a specific sequence of steps must produce the expected state trace. The second
is model verification: propositions about the table itself that must stay true across every
scenario.

```js
// flow.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { TRANSITIONS, runScenario, reachable, unrecoverable } from './flow.mjs';

test('happy path: measurement is entered and the list refreshes', () => {
  const { trace } = runScenario(TRANSITIONS, 'idle', [
    'page-opened', 'list-arrived', 'new-measurement', 'submit', 'record-accepted', 'list-arrived',
  ]);
  assert.deepEqual(trace, [
    'idle', 'listLoading', 'listReady', 'formOpen',
    'submitting', 'listLoading', 'listReady',
  ]);
});

test('an invalid measurement stays in the form', () => {
  const { state } = runScenario(TRANSITIONS, 'idle', [
    'page-opened', 'list-arrived', 'new-measurement', 'submit', 'validation-error',
  ]);
  assert.equal(state, 'formOpen');
});

test('an undefined step stops with a clear error', () => {
  assert.throws(
    () => runScenario(TRANSITIONS, 'idle', ['page-opened', 'submit']),
    /no 'submit' step defined in state 'listLoading'/,
  );
});

test('every state is reachable from the start', () => {
  const seen = reachable(TRANSITIONS, 'idle');
  assert.deepEqual([...seen].sort(), Object.keys(TRANSITIONS).sort());
});

test('every state can return to listReady', () => {
  assert.deepEqual(unrecoverable(TRANSITIONS, 'listReady'), []);
});
```

```sh
node --test flow.test.mjs
```

The stack-trace lines inside the failure body have been removed from the output below.

```
✔ happy path: measurement is entered and the list refreshes (0.495709ms)
✔ an invalid measurement stays in the form (0.054958ms)
✔ an undefined step stops with a clear error (0.132709ms)
✔ every state is reachable from the start (0.065583ms)
✖ every state can return to listReady (0.638792ms)
ℹ tests 5
ℹ pass 4
ℹ fail 1
✖ every state can return to listReady
  AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
  + actual - expected
  + [
  +   'listError'
  + ]
  - []
```

The model found a design gap before a single line of interface code ran: once the state
reached by a failed list request, there is no way back. The user gets stuck on the error
screen. The fix is to add an edge to the transition table:

```js
// flow.mjs — fixed version of the TRANSITIONS table; only the last line changed
export const TRANSITIONS = {
  idle: { 'page-opened': 'listLoading' },
  listLoading: { 'list-arrived': 'listReady', 'request-failed': 'listError' },
  listReady: { 'new-measurement': 'formOpen', 'filter-changed': 'listLoading' },
  formOpen: { 'submit': 'submitting', 'cancel': 'listReady' },
  submitting: { 'record-accepted': 'listLoading', 'validation-error': 'formOpen' },
  listError: { 'retry': 'listLoading' },
};
```

```
✔ happy path: measurement is entered and the list refreshes (0.566708ms)
✔ an invalid measurement stays in the form (0.060625ms)
✔ an undefined step stops with a clear error (0.136541ms)
✔ every state is reachable from the start (0.077917ms)
✔ every state can return to listReady (0.386875ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 34.57375
```

The model's value runs in two directions. While writing tests, it shows which scenarios are
actually different — the happy path and the validation-error path use different transitions,
so they are two separate tests. And a transition missing from the table is a button missing
from the interface.

#### Steps, Waiting, and Data

Every step of a test running in the browser consists of two parts: a user action and that
action's observable result. If the next action begins before the result is verified, the
test starts running faster than the application and becomes dependent on timing.

Waiting is therefore not done for a fixed duration. A fixed wait is wrong in two ways: it is
not enough on a slow run, and it is wasted on a fast one. The correct form is a **conditional
wait** — "wait until at least one row appears in the measurement table, up to this much
time". The condition itself is built with the criterion from the previous lesson: an element
found by role and name.

Test data isolation is the second rule. Two tests working on a shared account see each
other's data; while one adds a measurement, the other's count verification breaks. Each run
sets up its own station and its own measurements, then cleans them up at the end.

The sign-in flow is a separate decision. Running authentication through the interface in
every test adds a few seconds to each one and breaks the whole suite when the sign-in screen
changes. Setting up the session directly — placing the token or cookie without going through
the interface — removes that cost. The sign-in flow itself is tested separately, once.

On the network side, there are two options. Connecting to the real backend catches contract
mismatches but is slow and depends on the backend's state. Stubbing the responses makes the
test fast and deterministic but cannot see contract drift. The common solution is to split
the two: a few flows against the real backend, the rest with stubbed responses.

#### How Much End-to-End

The shape of the test suite is a budget question. Unit and component tests give feedback
within seconds and point to the location of the defect; end-to-end tests take minutes and
say only "something broke somewhere". This is why the majority sits at the bottom and the
minority at the top.

The selection criterion is not coverage percentage but **the cost of failure**. In the North
Slope interface, three flows meet this criterion: the measurement list loading and being
filtered, a new measurement being entered and saved, and recovery when the list request
fails. Everything else is tested at a cheaper layer whenever it can be.

#### Summary

- An end-to-end test covers the build output, routing, the data contract, the session, and
  security headers together; no lower layer can see this set.
- Modeling the flow as a state machine shows which scenarios are different; reachability and
  recoverability can be verified on the transition table with breadth-first search.
- The model reported that there was no way back from the error state before any interface
  code ran; a missing transition corresponds to a missing button in the interface.
- Every step consists of an action and an observable result; waiting is done by condition,
  not by a fixed duration.
- Each run sets up its own data, establishes the session without going through the
  interface, and tests part of the flows against the real backend and the rest with stubbed
  responses.

#### Next Step

An end-to-end test verifies that the flow works: the row appeared, the record was accepted,
recovery from the error screen happened. What it does not verify is how those rows look.
When a style rule is applied to the wrong container, a spacing value doubles, or a button
becomes invisible, the flow tests keep passing — because the element still sits in the tree
and can still be clicked. The next lesson covers the method that closes this gap: regression
testing that compares two images pixel by pixel.

### Visual Regression Testing

Source: https://academia.sh/en/courses/frontend-quality/visual-regression-testing

Pixel-level comparison of the baseline image against the candidate image, the separate roles of pixel tolerance and ratio threshold, the masking decision, and the determinism conditions that make the comparison meaningful.

A flow test verifies that the measurement row appears; it does not verify how it appears.
When a style rule is applied to the wrong container, a spacing value doubles, or the save
button becomes the same color as the background, the tests from the previous two lessons
keep passing. The element is in the tree, its role is in place, it can be clicked — but it
is wrong on screen.

Visual regression testing fills this gap: it compares a screenshot against an accepted
baseline and counts the difference.

#### What Gets Compared

There are three images. The **baseline image** is the version previously reviewed and
approved, and it is kept in the repository. The **candidate image** is the version produced
from the current code. The **diff image** marks the pixels where the two disagree.

Comparison can be done at three scales. Full-page comparison gives the widest coverage but
is the noisiest: a change anywhere on the page fails the test. Component comparison captures
only the frame of the relevant component and ties the defect to a narrower area.
Element-level comparison is the narrowest scope.

A separate method from this is the **snapshot test**: the rendered tree is dumped into text
form and saved. It catches a change in structure, not a change in appearance — color,
spacing, and font decisions are not visible in the tree. The two cover different risks.

#### The Diff Computation

The core of the comparison is a short computation: walk the corresponding pixels of two
grids, compare the difference against a tolerance, and count the ones that disagree.

```js
// image-diff.mjs — compares two grayscale grids
export function diff(baseline, candidate, { tolerance = 0, mask = [] } = {}) {
  if (baseline.width !== candidate.width || baseline.height !== candidate.height) {
    return { sizeMismatch: true, diffPixels: NaN, ratio: NaN };
  }
  const masked = (x, y) =>
    mask.some((m) => x >= m.x && x < m.x + m.w && y >= m.y && y < m.y + m.h);
  let diffCount = 0;
  let counted = 0;
  for (let y = 0; y < baseline.height; y += 1) {
    for (let x = 0; x < baseline.width; x += 1) {
      if (masked(x, y)) continue;
      counted += 1;
      const i = y * baseline.width + x;
      if (Math.abs(baseline.pixels[i] - candidate.pixels[i]) > tolerance) diffCount += 1;
    }
  }
  return { sizeMismatch: false, diffPixels: diffCount, ratio: diffCount / counted };
}

export function decision(result, threshold) {
  if (result.sizeMismatch) return 'failed (size mismatch)';
  return result.ratio > threshold ? 'failed' : 'passed';
}
```

Testing this does not need a real screenshot; a forty-column grid that deterministically
draws the measurement table screen is enough. The following module places the header bar,
the clock field, four measurement rows, and the save button in shades of gray.

```js
// screen.mjs — deterministic grayscale rendering of the measurement table screen
const WIDTH = 40;
const HEIGHT = 20;

function blank() {
  return { width: WIDTH, height: HEIGHT, pixels: new Uint8Array(WIDTH * HEIGHT).fill(255) };
}

function box(g, x, y, w, h, tone) {
  for (let py = y; py < y + h; py += 1) {
    for (let px = x; px < x + w; px += 1) {
      if (px >= 0 && px < g.width && py >= 0 && py < g.height) g.pixels[py * g.width + px] = tone;
    }
  }
}

export function screen({ buttonTone = 60, clockPattern = 0, shift = 0 } = {}) {
  const g = blank();
  box(g, 0, 0 + shift, 40, 2, 200);               // header bar
  box(g, 30, 0 + shift, 8, 1, 120 + clockPattern); // clock field
  for (let s = 0; s < 4; s += 1) {                 // measurement rows
    box(g, 2, 4 + s * 3 + shift, 34, 2, 230);
    box(g, 3, 5 + s * 3 + shift, 10, 1, 90);
  }
  box(g, 26, 17 + shift, 12, 2, buttonTone);       // save button
  return g;
}

// a small deviation resembling the rendering engine's anti-aliasing
export function addNoise(g, seed = 12345, amplitude = 3) {
  const copy = { ...g, pixels: Uint8Array.from(g.pixels) };
  let s = seed;
  for (let i = 0; i < copy.pixels.length; i += 1) {
    s = (s * 1103515245 + 12345) % 2147483648;
    const deviation = (s % (2 * amplitude + 1)) - amplitude;
    copy.pixels[i] = Math.min(255, Math.max(0, copy.pixels[i] + deviation));
  }
  return copy;
}
```

```js
// report.mjs — comparing candidates against the baseline
import { diff, decision } from './image-diff.mjs';
import { screen, addNoise } from './screen.mjs';

const baseline = screen();
const THRESHOLD = 0.005;
const CLOCK_MASK = [{ x: 30, y: 0, w: 8, h: 1 }];

const candidates = [
  ['identical rendering', screen(), {}],
  ['anti-aliasing deviation', addNoise(screen()), {}],
  ['same deviation, tolerance 5', addNoise(screen()), { tolerance: 5 }],
  ['clock field changed', screen({ clockPattern: 40 }), {}],
  ['clock field masked', screen({ clockPattern: 40 }), { mask: CLOCK_MASK }],
  ['button color changed', screen({ buttonTone: 30 }), {}],
  ['content shifted 1 pixel', screen({ shift: 1 }), {}],
];

console.log('status                          diff  ratio  decision');
for (const [label, candidate, options] of candidates) {
  const result = diff(baseline, candidate, options);
  const ratio = (result.ratio * 100).toFixed(2).padStart(6);
  console.log(`${label.padEnd(29)} ${String(result.diffPixels).padStart(6)} ${ratio}%  ${decision(result, THRESHOLD)}`);
}
```

```sh
node report.mjs
```

```
status                          diff  ratio  decision
identical rendering                0   0.00%  passed
anti-aliasing deviation          502  62.75%  failed
same deviation, tolerance 5        0   0.00%  passed
clock field changed                8   1.00%  failed
clock field masked                 0   0.00%  passed
button color changed              24   3.00%  failed
content shifted 1 pixel          424  53.00%  failed
```

Seven rows contain every decision point of the method.

#### Tolerance, Threshold, and Mask

The second row shows why zero tolerance cannot be used. When a deviation of at most three
units was added to every pixel, five hundred two of the eight hundred pixels were counted as
"different": 62.75%. A deviation invisible to the eye rendered the entire comparison
meaningless. In the third row, the same image was compared with a tolerance of five units,
and the difference dropped to zero.

Two separate clamps follow from this, and the two should not be confused. **Pixel tolerance**
says how far a single pixel is allowed to deviate; it absorbs the rendering engine's
anti-aliasing noise. **Ratio threshold** says how many pixels are allowed to deviate; it is
the total accepted amount of change. Tolerance guards against noise, threshold against small
and inconsequential shifts.

The fourth and fifth rows show dynamic content. The clock field in the header carries a
different value on every run and changes eight pixels: one percent. This is a difference
unrelated to the code, and it is excluded from the comparison with a mask. The mask
rectangle drops out of the count entirely; the ratio is computed over the remaining pixels.

The sixth row is a real regression: the save button's tone changed, twenty-four pixels
differ, three percent. Because it is above the threshold, the test fails.

The seventh row shows the method's most important limitation. When the content shifted one
pixel down, four hundred twenty-four pixels changed — 53%. Visual comparison is **not
local**: a one-unit change in a container's margin shifts everything beneath it and spreads
the difference across the whole page. This is why the diff ratio does not measure the size
of the defect; it only says "something changed". Finding the location of the defect requires
looking at the diff image.

The last detail is the size check. If the images have different dimensions, pixel comparison
is undefined; the comparison is invalidated before it even begins.

#### Putting It Into a Test

Once the decision criterion is settled, it turns into a test.

```js
// visual.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { diff, decision } from './image-diff.mjs';
import { screen, addNoise } from './screen.mjs';

const THRESHOLD = 0.005;
const CRITERIA = { tolerance: 5, mask: [{ x: 30, y: 0, w: 8, h: 1 }] };

test('rendering deviation and clock field are not counted as a regression', () => {
  const candidate = addNoise(screen({ clockPattern: 40 }));
  assert.equal(decision(diff(screen(), candidate, CRITERIA), THRESHOLD), 'passed');
});

test('a change in the button tone is reported as a regression', () => {
  const result = diff(screen(), screen({ buttonTone: 30 }), CRITERIA);
  assert.equal(decision(result, THRESHOLD), 'failed');
  assert.equal(result.diffPixels, 24);
});

test('a size mismatch invalidates the comparison', () => {
  const narrow = { width: 39, height: 20, pixels: new Uint8Array(39 * 20) };
  assert.equal(decision(diff(screen(), narrow, CRITERIA), THRESHOLD), 'failed (size mismatch)');
});
```

```sh
node --test visual.test.mjs
```

```
✔ rendering deviation and clock field are not counted as a regression (0.825291ms)
✔ a change in the button tone is reported as a regression (0.121292ms)
✔ a size mismatch invalidates the comparison (0.04775ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 30.034792
```

#### Determinism Conditions

Visual comparison only works when the candidate image is produced deterministically. Five
conditions must be met.

The viewport and pixel ratio are fixed; a different width produces a different layout.
Animation and transitions are stopped, otherwise the image depends on which frame it was
captured at — the reduced motion preference from the Layout Systems and Responsive Design
course can also be used for this purpose. Waiting happens until the font has loaded; a frame
drawn with the fallback font changes the width of every line. The clock, random identifiers,
and live data are fixed or masked. The scroll position is reset.

There is one more condition, and it comes from outside: the rendering engine and the
operating system. The same code renders anti-aliasing differently in a different
environment. This is why baseline images are tied to the environment that produced them, and
are produced and compared in a single environment.

The baselines themselves are an asset that must be managed. They sit in the repository as
binary files, and, as described in the Introduction to Version Control course, a binary
file's diff cannot be read. Every accepted change grows the repository. This is why a
baseline update is never automatic: a person sees the change, approves it, and the new
baseline is committed with that approval. An unapproved update turns the regression test
into a mechanism that justifies itself.

#### Summary

- Visual regression testing compares the baseline and candidate images at the pixel level; a
  snapshot test, by contrast, captures structure, not appearance.
- Pixel tolerance limits a single pixel's deviation, ratio threshold limits the total amount
  of change; the two guard against different problems and should not be confused.
- Dynamic areas are masked; a masked region falls entirely outside the count.
- The diff ratio is not local: a one-pixel shift made half the page appear different, which
  is why the ratio does not measure the size of the defect.
- The comparison is meaningful only once the viewport, animation, fonts, clock, and scroll
  are fixed; baselines are tied to the environment that produced them, and their updates
  require approval.

#### Next Step

All the tests built across four lessons rested on one assumption: the same code gives the
same result. In practice, this assumption breaks often. A test passes in nineteen of twenty
runs and fails in one; no one changed anything. The team first tries rerunning it, then
starts ignoring that test, and the signal the suite gives becomes worthless. The next lesson
makes the causes of this behavior — timing, order dependence, and shared state — countable,
and shows the fix for each.

### Test Stability

Source: https://academia.sh/en/courses/frontend-quality/test-stability

The definition and cost of a flaky test, counting timing, order dependence, and shared state with a deterministic simulation, the fix for each cause, and the place of retries and quarantine.

The tests in the previous four lessons rested on one assumption: the same code gives the
same result. This assumption breaks often. A test passes in nineteen of twenty runs and
fails in one; no one changed anything. It turns green on the second run and the work
continues.

This behavior looks harmless but erodes the signal the suite gives. Once the meaning of a
red result becomes uncertain, the team first tries rerunning it, then reruns without even
looking, and eventually mistakes a real defect for flakiness and lets it through. The entire
value of a test suite lies in red meaning something.

#### Flaky Tests and Their Measure

A **flaky test** is a test that gives a different result from run to run even though the
code it tests has not changed. The defect is in the test itself: the test's result depends
on something outside the behavior it tests.

This should be treated as a number, not an impression. **Flake rate** is how many times a
test fails across a given number of runs. If a test fails at unknown intervals, running it
many times under the same conditions and measuring the rate proves both that the problem
exists and that a fix works.

#### Counting the Three Causes

The following simulation models three causes of flakiness separately and counts, with a
pseudo-random but deterministic generator, how many times each fails across two hundred
runs. The generator is a linear congruential method: the same seed always gives the same
sequence, so the results are reproducible.

```js
// flakiness.mjs — a deterministic simulation of three flakiness causes
export function generator(seed) {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

function shuffle(array, random) {
  const copy = [...array];
  for (let i = copy.length - 1; i > 0; i -= 1) {
    const j = Math.floor(random() * (i + 1));
    [copy[i], copy[j]] = [copy[j], copy[i]];
  }
  return copy;
}

export function runSuite({ seed = 7, runs = 200, fixed = false, shuffling = true }) {
  const random = generator(seed);
  const names = ['tableLoads', 'filterNarrows', 'formSubmits'];
  const failed = { tableLoads: 0, filterNarrows: 0, formSubmits: 0 };
  let draft = null; // shared state living at module scope

  for (let n = 0; n < runs; n += 1) {
    const order = shuffling ? shuffle(names, random) : names;
    const setUp = new Set();
    if (fixed) draft = null; // each run starts with its own state

    for (const name of order) {
      let passed;
      if (name === 'tableLoads') {
        const responseTime = 40 + 80 * random();
        passed = fixed ? responseTime <= 500 : responseTime <= 100;
        setUp.add('table');
      } else if (name === 'filterNarrows') {
        if (fixed) setUp.add('table'); // the test sets up its own data
        passed = setUp.has('table');
      } else {
        passed = draft === null; // the form must open with an empty draft
        const submitSucceeded = random() < 0.5;
        draft = submitSucceeded ? null : { station: 'north-slope-01' };
      }
      if (passed === false) failed[name] += 1;
    }
  }
  return failed;
}
```

```js
// report.mjs — how many times each test failed across 200 runs
import { runSuite } from './flakiness.mjs';

const scenarios = [
  ['fixed order, unfixed', { shuffling: false }],
  ['shuffled order, unfixed', { shuffling: true }],
  ['shuffled order, fixed', { shuffling: true, fixed: true }],
];

console.log('scenario                       tableLoads  filterNarrows  formSubmits');
for (const [label, options] of scenarios) {
  const result = runSuite({ seed: 7, runs: 200, ...options });
  console.log(
    label.padEnd(30) +
      String(result.tableLoads).padStart(9) +
      String(result.filterNarrows).padStart(16) +
      String(result.formSubmits).padStart(16),
  );
}
```

```sh
node report.mjs
```

```
scenario                       tableLoads  filterNarrows  formSubmits
fixed order, unfixed                 50               0              95
shuffled order, unfixed              48             102             104
shuffled order, fixed                 0               0               0
```

Three rows give three separate lessons.

#### Timing

The `tableLoads` test waits a fixed hundred milliseconds for the list to arrive. In the
simulation, the response time varies between forty and a hundred twenty milliseconds; the
test fails when the duration exceeds the wait. It failed in fifty and forty-eight of the two
hundred runs — a rate close to one in four, independent of run order.

A fixed wait is wrong in two ways. It is not enough on a slow run; it is wasted on a fast
one. Adding a hundred milliseconds for each of a hundred tests adds ten seconds to the
suite's duration and guarantees nothing.

The correct form is a **conditional wait**: checking at short intervals and waiting until an
observable result exists, with a generous upper bound. In the fixed version, the upper bound
is five hundred milliseconds, and it was not exceeded in any of the two hundred runs. A
generous upper bound does not slow the test down — when the condition is met early, the wait
ends early. Removing the upper bound entirely is not acceptable: in a genuinely frozen
application, the test would wait forever.

The microtask queue from the Asynchronous JavaScript and the Runtime course applies here
too. A promise settling and the screen updating may not happen in the same turn; what should
be waited for is not the promise itself but the result the user will see.

#### Order Dependence

The `filterNarrows` test expects the table to have already loaded — a different test sets up
the data. In the first row, this test never failed: in the fixed order, `tableLoads` always
runs first. In the second row, the order was shuffled, and it failed in a hundred two of the
two hundred runs.

The real finding here is this: **a fixed order hides the defect.** The test suite can stay
green for months, then suddenly turn red once a file name changes or the runner
parallelizes. Deliberately shuffling the order is a diagnostic tool; once the seed used for
shuffling is written to the output, the failing order can be reproduced exactly.

The fix is not to fix the order but to remove the dependency. Every test sets up the data it
needs itself. In the fixed version, `filterNarrows` adds its own measurement and becomes
independent of the order.

#### Shared State

The `formSubmits` test expects the form to open with an empty draft. The draft lives at
module scope and is cleared only when the submission succeeds; on a failed submission, it
carries over to the next run. It failed in ninety-five and a hundred four of the two hundred
runs.

This cause is different from the other two in one way: on its own, in a clean environment,
the test always passes. For it to fail, some earlier run first has to have contaminated the
environment. This is exactly the test that passes when run alone on a developer's machine
but fails inside the suite — and it is the behavior that makes diagnosis hardest, because
there is no defect in the failing test itself.

The sources of shared state can be enumerated: values held at module scope, local storage
and session storage, cookies, program-controlled cache, backend data, and the file system.
The fix is a fixture that is set up before every test and torn down after. In the fixed
version, the draft is reset at the start of every run and the failure rate drops to zero.

Parallel execution magnifies this problem. Two runners sharing the same backend record or
the same port corrupt each other's environment; isolation is achieved with a separate data
set per run.

#### What to Do About Flakiness

Retrying is the first solution that comes to mind, and on its own it is wrong. Silently
rerunning a failing test hides the flakiness; hidden flakiness grows over time and hides the
real defects mixed in among it. The only acceptable form is retrying that is **recorded**,
with the flake rate tracked as a measure.

If a test is too complex to fix, it is put into **quarantine**: it is separated from the
suite, its result does not block a release, but it has an owner and a deadline on the list.
Ownerless quarantine is the long way of silently deleting the test.

There are also general measures that increase determinism. The clock is fixed, the locale
and time zone are given explicitly, randomness is seeded as in this lesson, and the seed is
written to the output. The goal is the same: leaving a test's result dependent on nothing
outside the behavior it tests.

#### Summary

- A flaky test is a test that gives a different result from run to run even though the code
  it tests has not changed; its cost is that a red result loses its meaning.
- A fixed wait fails at the tail of the response-time distribution; a conditional wait with
  a generous upper bound zeroed out this cause.
- A fixed run order hides order dependence: the same test never failed in the fixed order
  but failed in a hundred two of two hundred runs in the shuffled order.
- The signature of shared state is a test that passes alone but fails inside the suite;
  every test setting up and tearing down its own fixture zeroed out this cause.
- Retrying hides flakiness; the acceptable form is retrying that is recorded and tracked as
  a flake rate, while quarantine must be owned and time-boxed.

#### Next Step

A stable test suite says that expected behavior is preserved in a controlled environment.
What it does not say is how the application behaves in the hands of real users. The test
machine is fast, its network is clean, its cache is in a known state; an observer in the
field works over a weak connection, with an aging device, and a crowded screen. The next
lesson places these two data sources — lab data from a controlled run and field data from
real sessions — side by side and measures the difference between them with a percentile
computation.

### Real User Monitoring

Source: https://academia.sh/en/courses/frontend-quality/real-user-monitoring

The different questions answered by lab measurement and field data, how metrics are collected and sent in the field, reading a distribution with percentile computation, and the effect of segmentation and sample size.

A stable test suite says that expected behavior is preserved in a controlled environment.
What it does not say is how the application behaves in the hands of real users. The test
machine is fast, its network is clean, its cache is in a known state. An observer using the
North Slope interface in the field, by contrast, works over a weak connection, with an
aging device, and other tabs open in the background.

This lesson places the two data sources side by side and makes the difference between them
measurable.

#### Two Sources, Two Questions

**Lab data** comes from a controlled run: a fixed device profile, fixed network conditions,
a clean cache. The measurements in the performance topic are of this kind. Its valuable
property is that it is deterministic — the difference between two runs can be attributed to
a change made to the code. The question it answers is: what did this change break?

**Field data** is measurements collected from real sessions; the collection method is called
**real user monitoring**. It gives not a single number but a distribution. The question it
answers is different: what do users actually experience?

Neither substitutes for the other. Lab measurement is used for regression detection, field
data for setting targets.

#### What Gets Collected in the Field

The browser accumulates metrics on its own and exposes them through an observer. The
following code collects paint entries and interaction entries that exceed the threshold, and
sends them as a single bundle when the page is hidden.

```js
// metrics-collector.js — collects field metrics and sends them as one bundle
const bundle = { path: location.pathname, version: '2.4.1', metrics: {} };

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    bundle.metrics[entry.name] = Math.round(entry.startTime ?? entry.duration);
  }
});
observer.observe({ type: 'paint', buffered: true });
observer.observe({ type: 'event', buffered: true, durationThreshold: 40 });

addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden') return;
  observer.takeRecords();
  navigator.sendBeacon('/metrics', JSON.stringify(bundle));
}, { once: true });
```

Three decisions are embedded in this code. The `buffered` option also retrieves entries that
occurred before the observer was set up, so the first paint is not lost when the measurement
script loads late. Sending happens not at page close but when the page is **hidden**: a tab
can be pushed to the background without closing and never come back to the foreground, in
which case the close event never fires at all. Sending is done with a beacon request that
also completes while the page is being unloaded.

Context fields are added to the bundle: path, application version, device class, connection
type, navigation type. Segmentation is done with these fields, and, as the next section will
show, what a single number hides is revealed here.

Collection does not have to happen on every session. **Sampling** is taking data from a
portion of sessions; lowering the rate lowers transmission and storage cost. The selection
must be made once per session — if it is made per page, some pages of the same session enter
the sample and others do not, and session-level questions go unanswered.

#### Reading the Distribution

Field data is a distribution, and there is more than one way to reduce a distribution to a
single number. The following module produces a deterministic field sample — three device
classes, with different shares and different duration ranges — and gives the percentile
computation.

```js
// field-data.mjs — deterministic simulation of field measurements and percentile computation
export function generator(seed) {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

export const CLASSES = [
  { name: 'fast', share: 0.60, base: 300, spread: 200 },
  { name: 'mid', share: 0.30, base: 700, spread: 600 },
  { name: 'slow', share: 0.10, base: 1800, spread: 2200 },
];

export function fieldSample(count, seed = 42) {
  const random = generator(seed);
  const samples = [];
  for (let i = 0; i < count; i += 1) {
    const pick = random();
    let cumulative = 0;
    const chosen = CLASSES.find((c) => (cumulative += c.share) >= pick) ?? CLASSES.at(-1);
    samples.push({ class: chosen.name, duration: Math.round(chosen.base + chosen.spread * random()) });
  }
  return samples;
}

// nearest-rank method: the share of samples at or below the p percentile is at least p
export function percentile(values, p) {
  if (values.length === 0) return NaN;
  const sorted = [...values].sort((a, b) => a - b);
  const rank = Math.ceil((p / 100) * sorted.length);
  return sorted[Math.min(sorted.length, Math.max(1, rank)) - 1];
}

export function average(values) {
  return Math.round(values.reduce((t, d) => t + d, 0) / values.length);
}
```

```js
// report.mjs — comparing lab measurement with the field distribution
import { fieldSample, percentile, average, CLASSES } from './field-data.mjs';

const field = fieldSample(5000);
const durations = field.map((o) => o.duration);

// lab run: fast device, stable network, middle of the distribution
const fast = CLASSES[0];
const lab = Math.round(fast.base + fast.spread * 0.5);

console.log(`lab measurement      ${String(lab).padStart(5)} ms`);
console.log(`field average        ${String(average(durations)).padStart(5)} ms`);
for (const p of [50, 75, 90, 95]) {
  console.log(`field p${String(p).padEnd(2)}             ${String(percentile(durations, p)).padStart(5)} ms`);
}

console.log('\np75 by device class');
for (const c of CLASSES) {
  const subset = field.filter((o) => o.class === c.name).map((o) => o.duration);
  console.log(`  ${c.name.padEnd(6)} ${String(subset.length).padStart(4)} sessions   p75 = ${percentile(subset, 75)} ms`);
}

console.log('\nsample size and p75 stability');
for (const n of [20, 100, 1000, 5000]) {
  const p75s = [0, 1, 2, 3, 4].map((t) => percentile(fieldSample(n, 100 + t).map((o) => o.duration), 75));
  console.log(`  n=${String(n).padStart(4)}  ${p75s.join('  ')}`);
}
```

```sh
node report.mjs
```

```
lab measurement        400 ms
field average          848 ms
field p50               471 ms
field p75              1014 ms
field p90              1853 ms
field p95              2971 ms

p75 by device class
  fast   2918 sessions   p75 = 452 ms
  mid    1561 sessions   p75 = 1143 ms
  slow    521 sessions   p75 = 3473 ms

sample size and p75 stability
  n=  20  775  930  837  1073  900
  n= 100  1054  1027  967  1082  1049
  n=1000  978  968  1006  1017  986
  n=5000  985  1001  996  1016  992
```

#### What the Numbers Say

The lab measurement is 400 milliseconds. The field distribution's median is 471, its 75th
percentile (p75) 1014. So the controlled run represents the better half of users and shows
nothing of the reality of the remaining half. This does not mean the lab measurement is
wrong; it means its coverage is narrow.

The average is 848 milliseconds, and it describes no group of users. When a distribution has
a long tail, the average is pulled up by a minority of very slow sessions; the result is
neither the majority's experience nor a measure of the worst case. In duration
distributions, the average is not read.

Choosing a percentile is a trade-off. The median hides half the users. p95 and above start
to move with a handful of outlier sessions — a tab left in the background, an overloaded
device — and are noisy. **p75** sits between the two: it gives the value that three quarters
of users fall under, and is sensitive enough to the tail. Field metrics are therefore
commonly read from the 75th percentile.

The segmentation table shows what the single number hides. The same application's p75 is
452 milliseconds on fast devices, 1143 on the mid class, 3473 on the slow class. The overall
value of 1014 corresponds to none of these three realities. An improvement decision can only
be made with segments: the 521 sessions in the slow class carry more gain than any effort
spent trying to fix the overall average.

The last table is the effect of sample size. The p75 values of five separate twenty-session
samples range between 775 and 1073 — despite coming from the same distribution. At a
thousand sessions, the range narrows to between 968 and 1017. An "improvement" measured with
a small sample is, most of the time, only sample noise. Splitting a metric into segments
also splits the sample size; each segment must have enough sessions on its own.

#### Tying It to the Budget

The budget defined in the performance topic gains meaning once it is verified with field
data. The budget check can be turned into a test.

```js
// budget.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fieldSample, percentile } from './field-data.mjs';

const BUDGET = { p75: 1200, slowClassP75: 4000 };

test('overall p75 is under budget', () => {
  const durations = fieldSample(5000).map((o) => o.duration);
  assert.ok(percentile(durations, 75) <= BUDGET.p75, `p75 = ${percentile(durations, 75)} ms`);
});

test('the slow device class is under its own budget', () => {
  const slow = fieldSample(5000).filter((o) => o.class === 'slow').map((o) => o.duration);
  assert.ok(slow.length >= 300, 'not enough samples');
  assert.ok(percentile(slow, 75) <= BUDGET.slowClassP75, `slow p75 = ${percentile(slow, 75)} ms`);
});
```

```sh
node --test budget.test.mjs
```

```
✔ overall p75 is under budget (2.774917ms)
✔ the slow device class is under its own budget (0.715333ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 33.845541
```

The two tests having separate budgets is deliberate. A single overall threshold could let a
regression in the slow class be masked by an improvement in the fast class. A budget per
segment prevents this trade-off.

The scope of the collected data is also a decision. The metrics bundle must not carry a
field that identifies the user; path information is stripped of any identifying segments,
and free text is never sent. The measurement code itself also has a cost — the observer
callbacks run on the main thread and slow down, by some amount, the very thing they measure.

#### Summary

- Lab data is deterministic and serves regression detection; field data is a distribution
  and tells what users actually experience.
- Metrics are collected with the browser's observer and sent as a single bundle when the
  page is hidden; the sampling decision is made once per session.
- In duration distributions, the average is not read; the median hides half, very high
  percentiles are noisy, and the 75th percentile sits between the two.
- While the lab measurement was 400 milliseconds, the field p75 came out to 1014; once split
  into segments, the same metric became 452, 1143, and 3473 milliseconds.
- In a small sample, p75 ranges widely; a budget per segment prevents a regression in one
  class from being masked by an improvement in another.

#### Next Step

Metrics say how fast the application is; they do not say whether it works. If an observer
throws an exception while trying to record a measurement, that session's duration metrics
keep looking perfectly fine. The user, meanwhile, is looking at a blank screen and, most of
the time, tells no one. The last lesson covers the mechanism that makes these silent
failures visible: catching and sending client errors, and reading thousands of events not
one by one but in groups that trace back to the same origin.

### Error Tracking

Source: https://academia.sh/en/courses/frontend-quality/error-tracking

The points where client errors are caught, making the stack trace readable with a source map, grouping by generating a fingerprint, measuring a group's impact, and decisions about noise and privacy.

Metrics say how fast the application is; they do not say whether it works. If an exception
is thrown while the measurement table is rendering, that session's duration metrics keep
looking fine. The user looks at a blank screen and, most of the time, tells no one.

This lesson builds the mechanism that makes those silent failures visible: catching the
error, sending it, and reading thousands of events not one by one but in groups that trace
back to the same origin.

#### Points of Capture

Client errors do not come from a single place. There are four separate sources, and each has
its own boundary.

Uncaught exceptions are reported through the document-level `error` event; the event object
is an `ErrorEvent` instance and carries the message, the source, the location, and the error
object. Unhandled rejected promises come through the `unhandledrejection` event — this is the
browser's counterpart to the unhandled rejection concept defined in the Asynchronous
JavaScript and the Runtime course. Resource loading errors do not bubble; they can be seen
only by listening during the capture phase. The fourth source is errors the application
reports itself: states that were caught but could not be recovered from.

```js
// error-collector.js — collecting client errors
const endpoint = '/errors';
const common = { version: '2.4.1', path: location.pathname };

function report(type, message, stackTrace) {
  navigator.sendBeacon(endpoint, JSON.stringify({ ...common, type, message, stackTrace }));
}

addEventListener('error', (event) => {
  if (event.target !== window) {           // resource loading error
    report('resource', `${event.target.tagName} failed to load`, event.target.src ?? '');
    return;
  }
  report('exception', event.message, event.error?.stack ?? '');
}, true);

addEventListener('unhandledrejection', (event) => {
  const reason = event.reason;
  report('rejection', String(reason?.message ?? reason), reason?.stack ?? '');
});
```

One boundary must be known from the start. An error thrown from a script loaded from a
different origin is given by the browser without detail: the message only says it is a
script error, the stack trace is empty. The reason is cross-origin isolation — the rules
from the Cross-Origin Resource Sharing lesson apply here too. Opening up the detail requires
the script to be requested cross-origin and the server to send the header that allows it.

The second boundary is at the component level. The error boundary in the Component-Based
Interface Development course catches an error thrown during rendering and shows a fallback
interface; these errors never reach the document-level listener and must be reported
separately.

#### Making the Stack Trace Readable

The code in the production build is minified: function names have shrunk to single letters,
lines have been merged. A stack trace like this cannot be read. The fix is the **source
map** defined in the Modules, Tooling, and the Ecosystem course: a table that maps a
minified location back to the original file, line, and name.

The mapping is done not on the client but on the side that collects errors. The source map
is not shipped together with the published assets — it makes the original source readable.
It is produced separately during the build and uploaded only to the collection side.

#### Grouping

A single defect produces thousands of events. An error on the same line fires once for every
user who opens the list; for this to be readable, events that trace back to the same origin
need to be gathered into a single group. The key for this is called a **fingerprint**.

The fingerprint is produced from the stable parts of the event, and the unstable parts are
left out. A file name carrying a build digest, line and column numbers, query strings, and
identity and numeric values in the message change with every release or every event; if
these enter the key, the same defect scatters across dozens of groups.

```js
// fingerprint.mjs — produces a grouping key from a stack trace
const FRAME = /^\s*at\s+(?:(.+?)\s+\()?([^()]+?)\)?$/;

export function frames(stackTrace) {
  return stackTrace
    .split('\n')
    .slice(1)
    .map((line) => line.match(FRAME))
    .filter(Boolean)
    .map((m) => ({ func: m[1] ?? '<anonymous>', source: m[2] }));
}

export function normalizeSource(source) {
  if (source === '<anonymous>' || source.startsWith('node:')) return source;
  return source
    .replace(/^[a-z]+:\/\/[^/]+/, '')           // origin
    .replace(/[?#][^:]*$/, '')                  // query and fragment
    .replace(/:\d+:\d+$/, '')                   // line and column
    .replace(/\.[0-9a-f]{6,}\.(m?js)$/, '.$1'); // build digest
}

export function normalizeMessage(message) {
  return message
    .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<id>')
    .replace(/\b\d+\b/g, '<number>');
}

function digest(text) {
  let h = 0x811c9dc5;
  for (const ch of text) {
    h ^= ch.codePointAt(0);
    h = Math.imul(h, 0x01000193) >>> 0;
  }
  return h.toString(16).padStart(8, '0');
}

export function fingerprint(event, { depth = 3 } = {}) {
  const [firstLine] = event.stackTrace.split('\n');
  const [type, ...rest] = firstLine.split(': ');
  const key = [
    type,
    normalizeMessage(rest.join(': ')),
    ...frames(event.stackTrace)
      .slice(0, depth)
      .map((f) => `${f.func}@${normalizeSource(f.source)}`),
  ].join('|');
  return { key, digest: digest(key) };
}

export function group(events) {
  const groups = new Map();
  for (const event of events) {
    const { key, digest: id } = fingerprint(event);
    const bucket = groups.get(id) ?? { id, key, count: 0, sessions: new Set(), versions: new Set() };
    bucket.count += 1;
    bucket.sessions.add(event.session);
    bucket.versions.add(event.version);
    groups.set(id, bucket);
  }
  return [...groups.values()].sort((a, b) => b.sessions.size - a.sessions.size);
}
```

The following six events contain examples of the same defect coming from different releases
and different sessions.

```js
// report.mjs — grouping six error events
import { group } from './fingerprint.mjs';

const EVENTS = [
  { session: 's-1', version: '2.4.0', stackTrace:
`TypeError: Cannot read properties of undefined (reading 'value')
    at drawRow (https://north-slope.example/assets/table.4f2a9c.js:12:31)
    at Array.map (<anonymous>)
    at MeasurementTable (https://north-slope.example/assets/table.4f2a9c.js:40:22)` },
  { session: 's-2', version: '2.4.1', stackTrace:
`TypeError: Cannot read properties of undefined (reading 'value')
    at drawRow (https://north-slope.example/assets/table.9b1d70.js:12:47)
    at Array.map (<anonymous>)
    at MeasurementTable (https://north-slope.example/assets/table.9b1d70.js:41:22)` },
  { session: 's-3', version: '2.4.1', stackTrace:
`TypeError: Cannot read properties of undefined (reading 'value')
    at drawRow (https://north-slope.example/assets/table.9b1d70.js:12:47?v=3)
    at Array.map (<anonymous>)
    at MeasurementTable (https://north-slope.example/assets/table.9b1d70.js:41:22)` },
  { session: 's-2', version: '2.4.1', stackTrace:
`Error: measurement 84213 could not be saved (400)
    at submit (https://north-slope.example/assets/form.1c8e55.js:88:11)` },
  { session: 's-4', version: '2.4.1', stackTrace:
`Error: measurement 91007 could not be saved (400)
    at submit (https://north-slope.example/assets/form.1c8e55.js:88:11)` },
  { session: 's-5', version: '2.4.1', stackTrace:
`TypeError: contract.submit is not a function
    at save (https://north-slope.example/assets/form.1c8e55.js:120:8)` },
];

for (const g of group(EVENTS)) {
  console.log(`${g.id}  events=${g.count}  sessions=${g.sessions.size}  versions=${[...g.versions].join(',')}`);
  console.log(`          ${g.key}`);
}
```

```sh
node report.mjs
```

```
f76aa486  events=3  sessions=3  versions=2.4.0,2.4.1
          TypeError|Cannot read properties of undefined (reading 'value')|drawRow@/assets/table.js|Array.map@<anonymous>|MeasurementTable@/assets/table.js
9581ca18  events=2  sessions=2  versions=2.4.1
          Error|measurement <number> could not be saved (<number>)|submit@/assets/form.js
d3fd0a38  events=1  sessions=1  versions=2.4.1
          TypeError|contract.submit is not a function|save@/assets/form.js
```

Six events came down to three groups. The first group's three examples carried different
build digests, different line and column numbers, and one of them a query string;
normalization eliminated these. The second group's two examples contained different
measurement numbers; because the numbers were converted to a placeholder, they appeared as a
single defect.

Grouping can be miscalibrated in two directions. **Over-grouping** collects independent
defects into a single row: discarding the message entirely and looking only at the error
type leads to this. **Under-grouping**, on the other hand, splits a single defect into
separate groups per release, and none of their impact becomes visible. The measure of
calibration is usefulness: a group is drawn correctly if it can be closed with a single fix.

#### Reading a Group

The numbers a group carries determine prioritization. **Event count** says how often the
defect fires, **affected session count** says how many people it touches. The two must be
kept separate: an error that fires thousands of times in a loop within a single session
tops the list by event count but concerns one user. Sorting is done by session count.

Version distribution is the second piece of information. If a group appears only in the
latest version, the defect arrived with that release and rollback is an option. First-seen
and last-seen times answer the same question on the time axis.

Noise is a separate matter. Errors from browser extensions and external scripts are not in
the application's code; they can be filtered out if the stack trace's first frame does not
belong to the application's domain. Errors produced by requests canceled while the user is
leaving the page are not real defects either.

Volume must be controlled. An error thrown inside a loop can send thousands of events from a
single session; a per-session rate limit and sampling cut this off. When sampling is
applied, the count of events not sent is also transmitted, otherwise the impact measure is
thrown off.

Privacy is the final decision. An error message can contain text the user typed, and the
address can carry an identifier. The collector masks known identifier formats and free text
before sending. Personal data is data that is hard to delete once it has been collected.

#### Summary

- Client errors come from four points: uncaught exceptions, unhandled promise rejections,
  resource loading errors, and errors the application reports itself.
- An error from a script loaded from a different origin is given without detail; detail
  requires a cross-origin request and a permission header. The stack trace is resolved on
  the collection side with a source map.
- The fingerprint is produced from the error type, the normalized message, and the first few
  frames; build digest, line and column number, query string, and numbers in the message are
  left out.
- Six events came down to three groups: examples from different releases and different
  measurement numbers appeared as a single defect.
- Prioritization is done by affected session count, not event count; noise is filtered,
  volume is limited, and personal data is masked before sending.

#### Course Wrap-Up

The Frontend Quality course tied four topics to the same principle: **there is no quality
claim without measurement.** The performance topic defined user-centric metrics and tied
them to a budget. The accessibility topic addressed criteria at the standard's level and
showed where automated auditing ends and manual verification begins. The security topic
turned client-side risks into layers of defense. This last topic answered the question
common to all three: when what was built breaks, what reports it? The answer ran in two
directions — tests that run before a change and monitoring that runs after a release. There
is a loop between the two: a defect seen in the field turns into a test that will never let
it through again; a case the test missed comes back in the field as an error group.

With this course, the Frontend Development curriculum also comes to a close. The path
started from the document — building structure with HTML and how the browser processes that
structure. Then it moved to styling: cascading, the box model, and visual properties. Layout
systems and responsive design addressed adapting that structure to different screen and
input conditions. The browser and the web platform turned the page from a document into an
application with behavior. Component-based development split that behavior into reusable
pieces; application architecture brought those pieces together into a whole with routing,
state, and data access. Rendering strategies asked whether the same application would be
produced on the server or the client, when, and at what cost. And this last course showed
how to know that all these decisions stay correct: from document to style, from layout to
behavior, from component to architecture, from rendering to quality.

One question was deliberately left out across the curriculum. How a button would work, what
role it would carry, which criteria it would meet in which states was covered; **why** that
button sits at that size, that color, and in that place was not. The rationale behind a
spacing scale, a typographic scale, color roles, and state design is a field of its own. The
Interface Design and Design Systems curriculum addresses this question: it ties visual and
interaction decisions to justifiable principles and turns those decisions into a system that
scales. Its first course, Fundamentals of Interface Design, is well suited to be read
alongside this curriculum — the decisions about the appearance of the components built here
find their rationale there.

