---
title: 'Layout Systems and Responsive Design'
source: 'https://academia.sh/en/courses/layout-and-responsive-design'
language: en
updated: '2026-08-17T04:41:00+00:00'
license: 'CC BY-SA 4.0'
lessons: 23
outcomes:
    - 'Builds two-dimensional layout with flexbox and grid systems'
    - 'Plans responsive design with content-driven breakpoints'
    - 'Organizes style architecture with a scalable method'
---

# Layout Systems and Responsive Design

Building two-dimensional layout with flexbox and grid, deriving breakpoints from content, writing motion with timing and accessibility criteria, and settling the style layer into a scalable architecture.

## Flexbox and Grid

The models of a flexbox that distributes on a single axis and a grid that defines lines on two axes; the arithmetic of the flexing calculation, automatic placement rules, and the criterion for choosing between the two models.

### The Flexbox Model

Source: https://academia.sh/en/courses/layout-and-responsive-design/flexbox-model

Establishing a flex formatting context, deriving the main and cross axis from the direction declaration, distributing free space on the main axis, and alignment on the cross axis.

The Visual Presentation with CSS course established the rules of normal flow: block boxes
stack on the block axis, inline boxes are placed into line boxes. That course showed
floating and positioning as ways to place boxes side by side; both take a box out of flow
and leave its container unable to compute its height from it.

This lesson covers the **layout model** defined for placing boxes side by side. Flexbox
does not take the path of removing a box from flow; it establishes a new placement rule
inside the container and divides free space among the children.

#### The Flex Formatting Context

When `display: flex` is declared on a box, the box **itself** stays a block box in flow;
what changes is the placement rule for its **children**. This box is now a **flex
container**, and its direct children are **flex items**.

Three consequences follow immediately:

1. The children's `display` value is **blockified**. A `span` child stops being an inline
   box and becomes a flex item; it can take a width and a vertical margin.
2. Margins between boxes **do not collapse**. The collapsing rule from the flow-layout
   lesson is only defined for block layout; in a flex formatting context, two adjacent
   margins are added together.
3. Text not wrapped in a tag also becomes a flex item. Since the item has no name, it is
   called an **anonymous box** and cannot be targeted with a selector.

The third point is one of the first places to look when a layout breaks: a line break and
indentation inside the container do not produce whitespace, but a real piece of text does
count as an item.

#### Two Axes

Flexbox is a **single-axis** model. Items are laid out along one direction, and free space
is divided along that same direction. This direction is called the **main axis**; the one
perpendicular to it is the **cross axis**.

`flex-direction` determines the main axis:

| Declaration | Main axis | Main start | Cross axis |
|---|---|---|---|
| `row` | inline axis | inline start | block axis |
| `row-reverse` | inline axis | inline end | block axis |
| `column` | block axis | block start | inline axis |
| `column-reverse` | block axis | block end | inline axis |

The naming in the table deliberately does not say "horizontal" and "vertical." The inline
axis is the direction text flows in, and it depends on the document's writing direction; in
a right-to-left document, the first item in the `row` direction starts on the right. Because
flexbox declarations name these directions abstractly, the same style file keeps working
correctly when the direction changes.

The main axis's two ends are called the **main start** and **main end**; the cross axis's
two ends are the **cross start** and **cross end**. Every alignment declaration is read
against these four ends.

#### Distributing Free Space

When the items' main-axis sizes are added up and the total is smaller than the container,
**free space** remains. The `justify-content` declaration says where that space goes.

The following program computes six distribution values in a three-item row. The container
is 720 units, the gaps are 16 units.

```js
// axis.mjs — main-axis distribution and cross-axis alignment in a flex row
const CONTAINER = 720;
const GAP = 16;
const boxes = [
  { name: "search", main: 180, cross: 40 },
  { name: "date", main: 140, cross: 32 },
  { name: "status", main: 120, cross: 56 },
];

const totalMain = boxes.reduce((t, b) => t + b.main, 0);
const gapTotal = GAP * (boxes.length - 1);
const freeSpace = CONTAINER - totalMain - gapTotal;
console.log(`container=${CONTAINER}  boxes=${totalMain}  gap=${gapTotal}  free space=${freeSpace}`);

// main-axis distribution: each value places the free space differently
function mainAxis(value) {
  const n = boxes.length;
  let start = 0, extra = 0;
  if (value === "flex-start") { start = 0; extra = 0; }
  if (value === "flex-end") { start = freeSpace; extra = 0; }
  if (value === "center") { start = freeSpace / 2; extra = 0; }
  if (value === "space-between") { start = 0; extra = freeSpace / (n - 1); }
  if (value === "space-around") { extra = freeSpace / n; start = extra / 2; }
  if (value === "space-evenly") { extra = freeSpace / (n + 1); start = extra; }
  const places = [];
  let x = start;
  for (const b of boxes) {
    places.push({ name: b.name, start: x, end: x + b.main });
    x += b.main + GAP + extra;
  }
  return places;
}

console.log("\n--- main axis (justify-content) ---");
for (const v of ["flex-start", "flex-end", "center", "space-between", "space-around", "space-evenly"]) {
  const p = mainAxis(v);
  const text = p.map((b) => `${b.name} ${b.start.toFixed(1)}..${b.end.toFixed(1)}`).join("  ");
  console.log(v.padEnd(14), text);
}

// cross-axis alignment: line height is set by the tallest box
const LINE = Math.max(...boxes.map((b) => b.cross));
console.log(`\n--- cross axis (align-items), line height=${LINE} ---`);
for (const v of ["flex-start", "flex-end", "center", "stretch"]) {
  const p = boxes.map((b) => {
    if (v === "stretch") return `${b.name} 0..${LINE}`;
    const start = v === "flex-start" ? 0 : v === "flex-end" ? LINE - b.cross : (LINE - b.cross) / 2;
    return `${b.name} ${start.toFixed(1)}..${(start + b.cross).toFixed(1)}`;
  });
  console.log(v.padEnd(12), p.join("  "));
}
```

```
container=720  boxes=440  gap=32  free space=248

--- main axis (justify-content) ---
flex-start     search 0.0..180.0  date 196.0..336.0  status 352.0..472.0
flex-end       search 248.0..428.0  date 444.0..584.0  status 600.0..720.0
center         search 124.0..304.0  date 320.0..460.0  status 476.0..596.0
space-between  search 0.0..180.0  date 320.0..460.0  status 600.0..720.0
space-around   search 41.3..221.3  date 320.0..460.0  status 558.7..678.7
space-evenly   search 62.0..242.0  date 320.0..460.0  status 538.0..658.0

--- cross axis (align-items), line height=56 ---
flex-start   search 0.0..40.0  date 0.0..32.0  status 0.0..56.0
flex-end     search 16.0..56.0  date 24.0..56.0  status 0.0..56.0
center       search 8.0..48.0  date 12.0..44.0  status 0.0..56.0
stretch      search 0..56  date 0..56  status 0..56
```

The free space is 248 units, and the six lines place these 248 units in six separate ways.

`flex-start`, `flex-end`, and `center` leave the free space in **one piece**; they only
move its position. The remaining three **split** the free space, and where the shares fall
differs:

- `space-between` gives no share to either end and splits the space evenly between the
  items: $248 / 2 = 124$ units of extra gap.
- `space-around` gives every item its own share, halved between the item's two sides:
  $248 / 3 \approx 82.67$; a half share at the edges $\approx 41.33$, and two half shares
  meeting between items give $\approx 82.67$.
- `space-evenly` equalizes every gap: $248 / 4 = 62$ units, the same at the edge as between.

It is not a coincidence that all three values keep the middle item in the same place: the
distribution is symmetric, and if there is an item in the middle of three, that item stays
centered. The difference is at the edges.

One warning is in order: the notation `start`/`end` is also defined instead of
`flex-start`/`flex-end`, and the two give the same result in most cases. A single style
file settles on one notation; this course uses the `flex-`-prefixed form throughout.

#### Alignment on the Cross Axis

There is no free space to distribute on the cross axis; each item is aligned on its own.
`align-items` is written on the container for all items, `align-self` on a single item and
overrides the container's value.

The output's second block shows four values. Line height is set by the tallest item (56
units), and the other two are positioned within that height. `flex-start` pulls to the top,
`flex-end` to the bottom, `center` to the middle.

The fourth value, `stretch`, differs from the others and is the **default**: if an item's
cross-axis size is not declared, it is **stretched** to fill the entire line. This is why
all three items show `0..56` in the output. If an item has a cross-axis size declared,
stretching does not apply; the declared size is kept and the item is aligned to the cross
start.

The practical value of stretching is that boxes standing side by side end up **equal in
height**. Three cards carrying text of different lengths appear the same size with no
height declared at all — in flow layout, this could only be achieved by writing a fixed
height.

A fifth value, `baseline`, aligns items against the baseline of their own first line of
text. It is used when placing headings of different type sizes side by side and text
alignment is wanted instead of top-edge alignment.

#### Gap and Automatic Margin

The distance between items can be given in two ways. The first is writing margin on the
items, and this produces an unwanted edge margin on the first and last item. The second is
declaring **gap** on the container: `gap` is applied only **between** items and does not
touch the edges.

This is why the gap total in the calculation above was $16 \times 2 = 32$: three items, two
gaps.

A second tool is writing `auto` as an item's `margin` value. An automatic margin **swallows
the entire** free space on the main axis and runs before `justify-content` is applied. This
is the most direct way to push the last item to the opposite end in a navigation bar:

```css
/* layout.css — step 1: navigation bar on a single axis */
.nav > ul {
  display: flex;
  gap: 1.5rem;
  align-items: center;
}

.nav .station-status {
  margin-inline-start: auto;
}
```

The `margin-inline-start: auto` declaration gives all the free space before the status
badge to that item, and the badge sticks to the end of the row. The same result could also
be reached with `justify-content: space-between`, but that declaration distributes a share
to **every** gap; what is wanted here is a break at a single point.

This file sits next to the `station.css` file established in the Visual Presentation with
CSS course. Visual declarations stay there, layout declarations are collected here; the
reason for the separation is covered in the course's final topic.

#### Wrapping

By default, flex items stay on a **single line**; even if their total size exceeds the
container, they do not move to a new line, the items shrink instead. The `flex-wrap: wrap`
declaration changes this behavior, and items that do not fit drop to a new line.

Once wrapping is turned on, a third alignment declaration becomes meaningful:
`align-content` says how the **lines** are distributed on the cross axis, and its set of
values is the same as `justify-content`'s. If there is a single line, this declaration has
no effect.

The distinction here is often confused and can be summarized in one sentence:
`align-items` aligns the **items inside a line**, `align-content` aligns the **lines**
inside the container.

#### Summary

- `display: flex` does not change the box itself but the placement rule for its children;
  the children get blockified, their margins do not collapse, and unwrapped text becomes
  an anonymous item.
- Flexbox is single-axis: `flex-direction` determines the main axis, the cross axis is
  perpendicular to it, and both axes are named abstractly relative to writing direction.
- `justify-content` places the free space on the main axis; `flex-start`, `flex-end`, and
  `center` leave the space in one piece, `space-between`, `space-around`, and
  `space-evenly` split it with different sharing rules.
- On the cross axis, the default `stretch` stretches items to the line's height; alignment
  values only become visible once a cross size is declared or stretching is turned off.
- `gap` only applies between items; `margin: auto` swallows the entire free space on the
  main axis and runs before the distribution declaration.

#### Next Step

This lesson's calculation took the items' main-axis sizes as fixed, and the free space
always came out positive. In a real layout, sizes come from content, and once the
container narrows, free space drops below zero. In that case, which item shrinks by how
much, and which item does surplus space go to? The next lesson covers the three
declarations that govern this sharing and their arithmetic.

### Flexing Properties

Source: https://academia.sh/en/courses/layout-and-responsive-design/flex-properties

Where an item's base size comes from, how positive free space is divided by growth factors, how negative free space is shared with scaled shrink factors, and the freezing of items that hit a minimum size.

The previous lesson took the items' main-axis sizes as fixed and always found the free
space positive. In a real filter bar, sizes come from content; once the container narrows,
the sum of the three fields exceeds the container and the free space drops below zero.

This lesson covers the three declarations that govern that sharing: **flex basis**, **flex
grow factor**, and **flex shrink factor**. Together, the three answer a single question:
what fraction of the difference between the container and the items' total is written to
which item?

#### Three Declarations and Their Shorthand

`flex-basis` is an item's main-axis size **before distribution**. The calculation starts
from this value; growth and shrinkage are added to or subtracted from it.

`flex-grow` is the factor for the share taken from positive free space. Its default is
zero: if an item is not given a factor, it stays at its base size.

`flex-shrink` is the factor for the share taken on from negative free space. Its default is
one: if an item is not given a factor, it still shrinks. The two defaults being asymmetric
is deliberate — overflow is more harmful than growing on its own.

The three are written in a single declaration, and the shorthand's expansion differs from
values written on their own:

```js
// shorthand.mjs — the flex shorthand's expansion into three declarations
const shorthands = [
  ["flex: 1", "1 1 0%"],
  ["flex: 2", "2 1 0%"],
  ["flex: auto", "1 1 auto"],
  ["flex: initial", "0 1 auto"],
  ["flex: none", "0 0 auto"],
  ["flex: 0 0 12rem", "0 0 12rem"],
];
for (const [notation, expansion] of shorthands) {
  console.log(`${notation.padEnd(16)} -> flex-grow flex-shrink flex-basis = ${expansion}`);
}
```

```
flex: 1          -> flex-grow flex-shrink flex-basis = 1 1 0%
flex: 2          -> flex-grow flex-shrink flex-basis = 2 1 0%
flex: auto       -> flex-grow flex-shrink flex-basis = 1 1 auto
flex: initial    -> flex-grow flex-shrink flex-basis = 0 1 auto
flex: none       -> flex-grow flex-shrink flex-basis = 0 0 auto
flex: 0 0 12rem  -> flex-grow flex-shrink flex-basis = 0 0 12rem
```

The first line deserves attention: the notation `flex: 1` pulls the base size to **zero**.
Writing `flex-grow: 1` on its own leaves the base size at `auto`. The two notations look
the same but give a different result; the numeric counterpart of the difference is
computed at the end of the lesson.

#### Where the Base Size Comes From

`flex-basis: auto` is a redirection, not a size: the item looks at its **size
declaration** on the main axis (`width` in the row direction, `height` in the column
direction). If that is also `auto`, the size comes from **content**.

The order is:

1. If `flex-basis` carries a length or a percentage, that is the base size.
2. If `flex-basis: auto`, the size declaration on the main axis is looked at.
3. If that is also `auto`, the item's **max-content size** is used: the width it would
   take up if the text were written on a single line with no wrapping at all.

The base size is subject to the `box-sizing` value. If the `border-box` declaration
established in the Visual Presentation with CSS course is in effect, padding and border
are inside the base size; otherwise they are added on top and the total overflows.

#### The Arithmetic of Distribution

The following program solves the previous lesson's filter bar at five different container
widths. The items' base sizes, growth and shrink factors, and minimum sizes are fixed; the
only thing that changes is the container.

```js
// flexing.mjs — computes the grow and shrink shares of flex items
const GAP = 16;

// each item's: basis, grow factor, shrink factor, minimum size
const items = [
  { name: "search", basis: 180, grow: 2, shrink: 1, min: 120 },
  { name: "date", basis: 140, grow: 1, shrink: 1, min: 100 },
  { name: "status", basis: 120, grow: 1, shrink: 3, min: 64 },
];

// CSS's "resolving flexible lengths" step: iterative distribution with frozen items
function resolve(container) {
  const n = items.length;
  const rawFreeSpace = container - items.reduce((t, o) => t + o.basis, 0) - GAP * (n - 1);
  const growing = rawFreeSpace > 0;

  const state = items.map((o) => ({ ...o, size: o.basis, frozen: false }));
  let round = 0;

  while (true) {
    round += 1;
    const flexible = state.filter((d) => !d.frozen);
    if (flexible.length === 0) break;

    const used = state.reduce((t, d) => t + (d.frozen ? d.size : d.basis), 0);
    const remaining = container - used - GAP * (n - 1);

    if (growing) {
      const totalFactor = flexible.reduce((t, d) => t + d.grow, 0);
      if (totalFactor === 0) break;
      for (const d of flexible) d.size = d.basis + remaining * (d.grow / totalFactor);
    } else {
      // shrinking: the factor is scaled by the basis: a small box shrinks less
      const totalWeight = flexible.reduce((t, d) => t + d.shrink * d.basis, 0);
      if (totalWeight === 0) break;
      for (const d of flexible) d.size = d.basis + remaining * ((d.shrink * d.basis) / totalWeight);
    }

    // an item that drops below its minimum is frozen and distribution restarts
    const violated = flexible.filter((d) => d.size < d.min);
    if (violated.length === 0) break;
    for (const d of violated) { d.size = d.min; d.frozen = true; }
    if (round > 10) break;
  }

  return { rawFreeSpace, round, state };
}

const format = (x) => x.toFixed(1).padStart(6);

console.log("basis sizes:", items.map((o) => `${o.name}=${o.basis}`).join("  "), ` gap=${GAP}x2`);
console.log("factors:     ", items.map((o) => `${o.name} grow=${o.grow} shrink=${o.shrink} min=${o.min}`).join("  |  "));

for (const container of [720, 560, 480, 400, 320]) {
  const { rawFreeSpace, round, state } = resolve(container);
  const total = state.reduce((t, d) => t + d.size, 0) + GAP * (items.length - 1);
  console.log(`\ncontainer=${container}  raw free space=${rawFreeSpace}  round=${round}`);
  for (const d of state) {
    const share = d.size - d.basis;
    const sign = share >= 0 ? "+" : "-";
    console.log(`  ${d.name.padEnd(6)} size=${format(d.size)}  share=${sign}${Math.abs(share).toFixed(1).padStart(5)}  ${d.frozen ? "frozen (minimum size)" : ""}`);
  }
  console.log(`  total (gap included) = ${total.toFixed(1)}`);
}
```

```
basis sizes: search=180  date=140  status=120  gap=16x2
factors:      search grow=2 shrink=1 min=120  |  date grow=1 shrink=1 min=100  |  status grow=1 shrink=3 min=64

container=720  raw free space=248  round=1
  search size= 304.0  share=+124.0  
  date   size= 202.0  share=+ 62.0  
  status size= 182.0  share=+ 62.0  
  total (gap included) = 720.0

container=560  raw free space=88  round=1
  search size= 224.0  share=+ 44.0  
  date   size= 162.0  share=+ 22.0  
  status size= 142.0  share=+ 22.0  
  total (gap included) = 560.0

container=480  raw free space=8  round=1
  search size= 184.0  share=+  4.0  
  date   size= 142.0  share=+  2.0  
  status size= 122.0  share=+  2.0  
  total (gap included) = 480.0

container=400  raw free space=-72  round=1
  search size= 160.9  share=- 19.1  
  date   size= 125.2  share=- 14.8  
  status size=  81.9  share=- 38.1  
  total (gap included) = 400.0

container=320  raw free space=-152  round=3
  search size= 124.0  share=- 56.0  
  date   size= 100.0  share=- 40.0  frozen (minimum size)
  status size=  64.0  share=- 56.0  frozen (minimum size)
  total (gap included) = 320.0
```

Every one of the five blocks ends its last line with the same check: the sizes' total,
gaps included, equals the container. That is flexbox's promise; how it keeps it changes.

#### Positive Free Space Divides by Factors

In the first three blocks, the free space is positive, and the share looks only at the
**ratio of the growth factors**. At a container of 720, the 248-unit space splits in a
$2 : 1 : 1$ ratio: $248 \times 2/4 = 124$ and $248 \times 1/4 = 62$.

A common mistake here is assuming the share is proportional to the item's own size. It is
not. `date` and `status` have different base sizes (140 and 120), but since their factors
are equal, both get 62 units; the 20-unit difference between them is preserved after
distribution too.

The third block shows a boundary case: at a container of 480, the free space is only 8
units and the shares drop to 4 and 2. The factor ratio stays the same, what shrinks is the
dividend.

If the factor total is less than one, not all of the free space is distributed. If every
one of the three items is given `flex-grow: 0.2`, the total is 0.6, and only sixty percent
of the free space is shared; the rest sits as a gap at the end of the container.

#### Negative Free Space Divides by Scaled Factors

In the fourth block, the free space is negative and the calculation changes. The shrink
share is determined by the factor's **product with the base size**:

$$
\text{weight}_i = \text{shrink}_i \times \text{basis}_i
$$

At a container of 400, the weights are $1 \times 180 = 180$, $1 \times 140 = 140$, and
$3 \times 120 = 360$; total 680. The negative 72 units split in this ratio:
$-72 \times 360/680 \approx -38.1$ is written to the `status` item's share.

The scaling is not arbitrary. If factors were used independent of scale, the smaller of
two items with equal factors would shrink by the same absolute amount as the larger one and
hit zero first. A scaled factor balances shrinkage **as a percentage**: every item carries
a load proportional to its own size, and the factor makes that load heavier or lighter.

That the `status` item's shrink factor is three is a design decision: the status badge is
the area that can shrink the most without losing its text. The opposite holds for the
search field; if `flex-shrink: 0` were written on it, it would never shrink and the entire
load would fall on the other two.

The gap does not shrink. In the calculation, 32 units are fixed on every line; the `gap`
value does not enter the flexing distribution, it is subtracted from the container up
front.

#### The Minimum Size Restarts the Distribution

The last block is solved in three rounds, and the reason is written in the output's last
column.

In the first round, proportional distribution brings the `status` item down to 39.5 units;
this is below the item's minimum size of 64. The item is pinned to that value and
**frozen**: it no longer takes part in the distribution, and its size is subtracted from
the container. The second round repeats for the remaining two items, and this time the
`date` item drops to 98, violating its own lower bound; it freezes too. In the third round,
a single flexible item remains, and the entire remaining shortfall is written to it:
`search` stops at 124 units.

This iteration is flexbox's defined algorithm. It has two consequences. First, one item
hitting its lower bound **increases** the others' share; the layout's shrinking behavior is
not linear. Second, if every item hits its lower bound, the total overflows the container
and the item overflows — flexbox cannot keep its promise.

Where the lower bound comes from is a separate matter. If a flex item's `min-width` is
`auto`, the bound is set to the item's **min-content size**: the narrowest form the text
can be broken into, that is, the width of its longest word. This is why an item carrying a
long word ends up wider than expected, and the fix is removing the constraint by writing
`min-width: 0`.

#### Zero Basis versus Content Basis

The difference left open at the start of the lesson can be computed twice in the same
container:

```js
// basis.mjs — two different basis values with the same factors
const CONTAINER = 600, GAP = 16;
const names = ["search", "date", "status"];
const content = [180, 140, 120];
const factor = [2, 1, 1];

for (const mode of ["0%", "auto"]) {
  const bases = mode === "0%" ? [0, 0, 0] : content;
  const free = CONTAINER - bases.reduce((a, b) => a + b, 0) - GAP * 2;
  const total = factor.reduce((a, b) => a + b, 0);
  const sizes = bases.map((b, i) => b + free * (factor[i] / total));
  console.log(`flex-basis: ${mode.padEnd(5)} -> ${sizes.map((s, i) => `${names[i]}=${s.toFixed(1)}`).join("  ")}`);
}
```

```
flex-basis: 0%    -> search=284.0  date=142.0  status=142.0
flex-basis: auto  -> search=244.0  date=172.0  status=152.0
```

When the base size is zero, the container's **entire** width is distributed, and the final
sizes reflect only the factor ratio: two items with equal factors are equal in width. When
the base size is `auto`, content sizes are set aside first and the **remainder** is
distributed; content differences carry through to the result.

The selection rule is: if a content-independent ratio between items is wanted, the base
size is zero; if free space should be shared while content differences are preserved,
`auto` is written. In the filter bar, the second is correct: the fields' natural widths
carry meaning.

```css
/* layout.css — step 2: measurement filter */
.measurement-filter {
  display: flex;
  gap: 1rem;
  flex-wrap: wrap;
  align-items: end;
}

.measurement-filter .search  { flex: 2 1 180px; min-inline-size: 120px; }
.measurement-filter .date    { flex: 1 1 140px; }
.measurement-filter .status  { flex: 1 3 120px; }
```

#### Summary

- `flex-basis` is the size before distribution; `auto` falls back to the size declaration
  on the main axis, and if that is also `auto`, to the max-content size.
- Writing the `flex` shorthand with a single number pulls the base size to zero; writing
  `flex-grow` on its own leaves the base size at `auto`, and the two notations give
  different results.
- Positive free space divides only by the ratio of the growth factors; the items' base
  sizes do not enter the share, and differences between them are preserved after
  distribution.
- Negative free space divides by the factor's product with the base size; scaling keeps
  small items from running out before large ones.
- An item that drops below its minimum size is frozen at that value, and distribution runs
  again with the remaining items; on a flex item, `min-width: auto` is a bound equal to the
  min-content size.

#### Next Step

Flexbox works on a single axis: it distributes in one direction, and only aligns in the
other. That is enough for a filter bar, but the measurement page as a whole needs regions
aligned in two axes — the profile block, the table, and the sidebar need to sit relative to
each other in both row and column. The next lesson establishes the second layout model,
which defines rows and columns ahead of time.

### The Grid Model

Source: https://academia.sh/en/courses/layout-and-responsive-design/grid-model

Establishing a grid formatting context, the distinction between track and line, the fractional unit dividing free space, the hidden lower bound the min-content size places on a share, and naming areas.

Flexbox distributes on a single axis: it shares out in one direction and only aligns in
the other. That is enough for a filter bar. The measurement page as a whole needs regions
aligned on two axes: the profile block, the measurement table, and the sidebar need to sit
relative to each other in both row and column.

This lesson establishes the layout model that defines rows and columns **ahead of time**.
In flexbox, placement arises from the items' sizes; in grid, an empty line layout is
defined first and items are placed into that layout.

#### The Grid Formatting Context

When `display: grid` is declared on a box, the box becomes a **grid container**; its
direct children are placed as **grid items**. As with flexbox, the container itself stays
a block box in flow, the children's `display` gets blockified, and their margins do not
collapse.

The distinction is in what is declared on the container. In flexbox, a single direction is
declared; in grid, the **track definition** for both axes is written:

```css
.station-layout {
  display: grid;
  grid-template-columns: 240px 2fr 1fr;
  grid-template-rows: auto 1fr;
  gap: 24px;
}
```

#### Track, Line, and Cell

Four concepts are kept separate from each other.

A **grid track** is the band between two adjacent lines: a column or a row. The
declaration above defines three column tracks and two row tracks.

A **grid line** is the line separating tracks. Three column tracks produce **four** lines;
lines are numbered from 1 starting at the beginning. The same lines are also referred to
with negative numbers counting from the end: the last line is $-1$, the one before it
$-2$. Extending an item to the end of the grid does not require knowing the track count;
$-1$ is enough.

A **grid cell** is the intersection of a row track and a column track; the smallest unit
in a grid.

A **grid area** is a rectangular region bounded by four lines; it can span one or more
cells. An item's placement is always an area.

Lines can be named, and the name is written inside square brackets, between the entries
of the track definition:

```css
.station-layout {
  display: grid;
  grid-template-columns: [edge-start] 240px [content-start] 2fr 1fr [edge-end];
}
```

A named line can be used instead of a number, and it makes rewriting placement
declarations unnecessary when the track count changes.

#### The Fractional Unit Divides Free Space

`fr` is a unit specific to grid: the **fractional unit**. It is not a length; it says how
many shares the **remaining space** divides into once fixed tracks and gaps are subtracted.

```js
// track.mjs — how the fr unit divides free space, and its hidden lower bound
const GAP = 24;

// track definition: fixed lengths are set aside first, the rest divides into fr shares
const tracks = [
  { notation: "240px", fixed: 240, fr: 0 },
  { notation: "2fr", fixed: 0, fr: 2 },
  { notation: "1fr", fixed: 0, fr: 1 },
];

const gapTotal = GAP * (tracks.length - 1);
const fixedTotal = tracks.reduce((t, s) => t + s.fixed, 0);
const frTotal = tracks.reduce((t, s) => t + s.fr, 0);

function shares(container) {
  const free = container - fixedTotal - gapTotal;
  return tracks.map((s) => s.fixed + (s.fr / frTotal) * free);
}

const CONTAINER = 960;
console.log(`grid-template-columns: ${tracks.map((s) => s.notation).join(" ")}   gap: ${GAP}px   container: ${CONTAINER}px`);
console.log(`fixed=${fixedTotal}  gap=${gapTotal}  free space to divide=${CONTAINER - fixedTotal - gapTotal}  fr total=${frTotal}`);
shares(CONTAINER).forEach((o, i) => console.log(`  ${tracks[i].notation.padEnd(6)} -> ${o.toFixed(1)}px`));

console.log("\n--- fr is a share of the free space, not of the container ---");
for (const extraFixed of [0, 240, 480]) {
  const one = (CONTAINER - extraFixed - gapTotal) / 3;
  console.log(`fixed track ${String(extraFixed).padStart(3)}px -> 1fr = ${one.toFixed(1)}px  (2fr = ${(2 * one).toFixed(1)}px)`);
}

// in a narrow container, the fr share drops below the track's min-content size
const NARROW = 600;
const minContent = [90, 180, 160];   // each track's longest unbreakable content
console.log(`\n--- narrow container (${NARROW}px): 1fr = minmax(auto, 1fr) ---`);
let total = 0;
shares(NARROW).forEach((share, i) => {
  const min = tracks[i].fr > 0 ? minContent[i] : 0;
  const applied = Math.max(share, min);
  total += applied;
  console.log(`  ${tracks[i].notation.padEnd(6)} share=${share.toFixed(1).padStart(6)}  min content=${String(min).padStart(3)}  applied=${applied.toFixed(1).padStart(6)}`);
});
console.log(`  tracks+gap = ${(total + gapTotal).toFixed(1)}px, container = ${NARROW}px, overflow = ${(total + gapTotal - NARROW).toFixed(1)}px`);

console.log(`\n--- same container with minmax(0, 1fr) ---`);
let total2 = 0;
shares(NARROW).forEach((share, i) => {
  total2 += share;
  const name = tracks[i].fr > 0 ? `minmax(0, ${tracks[i].fr}fr)` : tracks[i].notation;
  console.log(`  ${name.padEnd(16)} applied=${share.toFixed(1).padStart(6)}`);
});
console.log(`  tracks+gap = ${(total2 + gapTotal).toFixed(1)}px, overflow = ${(total2 + gapTotal - NARROW).toFixed(1)}px`);
```

```
grid-template-columns: 240px 2fr 1fr   gap: 24px   container: 960px
fixed=240  gap=48  free space to divide=672  fr total=3
  240px  -> 240.0px
  2fr    -> 448.0px
  1fr    -> 224.0px

--- fr is a share of the free space, not of the container ---
fixed track   0px -> 1fr = 304.0px  (2fr = 608.0px)
fixed track 240px -> 1fr = 224.0px  (2fr = 448.0px)
fixed track 480px -> 1fr = 144.0px  (2fr = 288.0px)

--- narrow container (600px): 1fr = minmax(auto, 1fr) ---
  240px  share= 240.0  min content=  0  applied= 240.0
  2fr    share= 208.0  min content=180  applied= 208.0
  1fr    share= 104.0  min content=160  applied= 160.0
  tracks+gap = 656.0px, container = 600px, overflow = 56.0px

--- same container with minmax(0, 1fr) ---
  240px            applied= 240.0
  minmax(0, 2fr)   applied= 208.0
  minmax(0, 1fr)   applied= 104.0
  tracks+gap = 600.0px, overflow = 0.0px
```

The first block shows the calculation's order: from a 960-unit container, the fixed track
(240) and two gaps (48) are subtracted first; the remaining 672 units divide into three
shares. `2fr` takes two shares (448), `1fr` one share (224).

The second block corrects a commonly made reading mistake. `1fr` is not one-third of the
container; it is one-third of the **free space**. As the fixed track grows, the same
`1fr` declaration's value drops from 304 to 144. The track definition itself has not
changed; what changed is the dividend.

If the share total is less than one, not all of the free space is distributed:
`0.5fr 0.5fr` uses half the free space, the rest sits at the end of the grid. This behavior
is consistent with the same declaration's counterpart in flexbox.

#### The Share's Hidden Lower Bound

The third block shows the most common cause of overflow in grid definitions. When the
container drops to 600 units, the last track's share computes to 104 units, but there is
160 units' worth of unbreakable content inside the track — a long measurement code, or a
table. The track stops at 160, and the total exceeds the container by 56 units.

The reason is this: `1fr`, in its full expansion, means `minmax(auto, 1fr)`. When the lower
bound is `auto`, the track cannot drop below its content's **min-content size**. If the
share falls below this bound, the bound wins.

The fourth block gives the fix: if the lower bound is explicitly pulled to zero, the track
follows the share and the overflow disappears. The cost is that content can now overflow
inside the track; overflow management is handled separately in that track, with the
declarations established in the Visual Presentation with CSS course.

This is grid's counterpart to the `min-width: auto` constraint in flexbox. In both models,
the source of the problem is the same: when the content size is larger than the computed
share, the constraint wins.

#### Repetition and Gap

Instead of repeating the same track definition by hand, `repeat()` is written. What is
interesting is that the repeat count can be given not as a number but as one of two
keywords: `auto-fill` and `auto-fit`. Both produce the most tracks that fit the container;
the distinction is in what happens when the produced tracks are **empty**.

```js
// repetition.mjs — track count produced by auto-fill and auto-fit
const MIN = 220;   // minmax(220px, 1fr)
const GAP = 24;
const ITEM_COUNT = 5;

// most tracks that fit the container: n*min + (n-1)*gap <= width
function trackCount(width) {
  const n = Math.floor((width + GAP) / (MIN + GAP));
  return Math.max(1, n);
}

const size = (width, n) => (width - GAP * (n - 1)) / n;

console.log("repeat(auto-fill | auto-fit, minmax(220px, 1fr))   gap: 24px   item count: 5");
console.log("width     tracks  auto-fill size   filled tracks  auto-fit size");
for (const w of [320, 500, 760, 1000, 1240, 1600]) {
  const n = trackCount(w);
  const filled = Math.min(n, ITEM_COUNT);
  const fill = size(w, n);
  const fit = size(w, filled);
  console.log(
    String(w).padStart(8) +
    String(n).padStart(7) +
    (fill.toFixed(1) + "px").padStart(16) +
    String(filled).padStart(12) +
    (fit.toFixed(1) + "px").padStart(15)
  );
}

console.log("\n--- boundary: exact widths where the track count increases ---");
for (let n = 1; n <= 6; n++) {
  const threshold = n * MIN + (n - 1) * GAP;
  console.log(`minimum width for ${n} track${n === 1 ? "" : "s"}: ${threshold}px`);
}

console.log("\n--- same calculation with a fixed track count: repeat(4, minmax(220px, 1fr)) ---");
for (const w of [500, 760, 1000]) {
  const n = 4;
  const share = size(w, n);
  const applied = Math.max(share, MIN);
  const overflow = applied * n + GAP * (n - 1) - w;
  console.log(`width ${String(w).padStart(4)} -> share ${share.toFixed(1)}px, applied ${applied.toFixed(1)}px, overflow ${overflow.toFixed(1)}px`);
}
```

```
repeat(auto-fill | auto-fit, minmax(220px, 1fr))   gap: 24px   item count: 5
width     tracks  auto-fill size   filled tracks  auto-fit size
     320      1         320.0px           1        320.0px
     500      2         238.0px           2        238.0px
     760      3         237.3px           3        237.3px
    1000      4         232.0px           4        232.0px
    1240      5         228.8px           5        228.8px
    1600      6         246.7px           5        300.8px

--- boundary: exact widths where the track count increases ---
minimum width for 1 track: 220px
minimum width for 2 tracks: 464px
minimum width for 3 tracks: 708px
minimum width for 4 tracks: 952px
minimum width for 5 tracks: 1196px
minimum width for 6 tracks: 1440px

--- same calculation with a fixed track count: repeat(4, minmax(220px, 1fr)) ---
width  500 -> share 107.0px, applied 220.0px, overflow 452.0px
width  760 -> share 172.0px, applied 220.0px, overflow 192.0px
width 1000 -> share 232.0px, applied 232.0px, overflow 0.0px
```

`minmax(a, b)` gives a track's lower and upper bound: the track does not drop below `a`,
does not rise above `b`. `minmax(220px, 1fr)` means "at least 220 units, take the rest as
a share," and the repeat count is derived from this lower bound.

The track count follows directly: the minimum width needed for $n$ tracks is
$n \times 220 + (n-1) \times 24$ units, and the count grows as the container exceeds this
value. The second block gives these thresholds; the numbers in the first block line up
exactly with them — a 1000-unit container gets four tracks, because the fourth threshold
is 952 and the fifth is 1196.

The distinction appears in the last line. A 1600-unit container is wide enough for six
tracks, but there are only five items to place. `auto-fill` **keeps the sixth track
empty**; items stay at 246.7 units and an empty column remains on the right. `auto-fit`
**collapses** the empty track; the track and its gap disappear, the five items share the
remaining space and rise to 300.8 units.

The selection criterion is content-related. If card width should stay consistent and items
change across different pages, `auto-fill` is correct: the same card is the same width on
every page. If items should fill the container in every case, `auto-fit` is correct.

The third block shows why a fixed repeat count overflows on narrow screens:
`repeat(4, …)` unconditionally produces four tracks, holds the lower bound at 220 units, and
gives 452 units of overflow in a 500-unit container. The repeat keywords read this
condition from the container; they adapt the track count without needing the media queries
covered in the next topic.

#### Naming Areas

The track definition establishes the grid; where items fall is a separate declaration. The
most readable way is to name areas and draw the placement as text in the container:

```css
/* layout.css — step 3: page grid */
.station-layout {
  display: grid;
  grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
  grid-template-rows: auto auto 1fr;
  grid-template-areas:
    "profile      profile"
    "measurements sidebar"
    "location     sidebar";
  gap: var(--spacing-1);
}

.profile             { grid-area: profile; }
.measurement-section { grid-area: measurements; }
.location-section    { grid-area: location; }
.sidebar              { grid-area: sidebar; }
```

Each row of the string corresponds to a row track, each column to a column track. The same
name appearing in two cells says the area spans those cells; `sidebar` extends across two
rows. Writing a dot instead of a name leaves the cell empty.

This notation's value is that the layout becomes **visible** in the style file: the
alignment of the lines draws the grid itself. An item's order in the document and its
position in the grid are independent of each other — this distinction's accessibility
consequences are covered in the next lesson.

#### Summary

- `display: grid` takes a track definition on two axes; a track is the band between two
  lines, a cell is the intersection of two tracks, and an area is the rectangular region
  bounded by four lines.
- Lines are numbered from 1 at the start and $-1$ from the end; they can be tied to a
  name, and a name keeps placement declarations intact when the track count changes.
- `fr` is not a length but a fractional unit: it divides the space remaining after fixed
  tracks and gaps are subtracted, so it gives a ratio of the free space, not the
  container.
- Because `1fr` expands to `minmax(auto, 1fr)`, a track does not drop below its
  min-content size; to prevent overflow, the lower bound is explicitly zeroed with
  `minmax(0, 1fr)`.
- `repeat(auto-fill, …)` produces every track that fits and keeps empty ones, `auto-fit`
  collapses empty tracks and distributes the space to the remaining items.

#### Next Step

The track definition established the grid, and named areas drew the placement. But where
do items with no area declaration fall? What happens if the container has more items than
are defined? The next lesson covers the declarations that place items explicitly by line
and the automatic algorithm that places the leftover ones.

### Grid Placement

Source: https://academia.sh/en/courses/layout-and-responsive-design/grid-placement

Explicit placement of items by line number, the cursor rule of the auto-placement algorithm, the difference between sparse and dense modes, the formation of implicit tracks, and the separation of visual order from document order.

The previous lesson established the grid and named its areas. Named-area notation makes a
layout readable, but it does not suit every layout: measurement cards whose count is not
known ahead of time cannot be named one by one.

This lesson answers two questions. How is an item placed explicitly by line, and by what
rule are items with no placement declaration distributed?

#### Resolving to Line Numbers

An item's placement is given on two axes with four lines: `grid-column` declares the
column lines, `grid-row` the row lines. Each takes a start and end separated by a slash.

```js
// line.mjs — resolving grid-column notation to line numbers
const COLUMNS = 4;              // 4 tracks -> 5 lines
const LAST_LINE = COLUMNS + 1;  // the line referred to as -1

// a negative line number counts from the end: -1 -> LAST_LINE, -2 -> LAST_LINE-1
const line = (n) => (n > 0 ? n : LAST_LINE + 1 + n);

function resolve(notation) {
  const [start, end] = notation.split("/").map((p) => p.trim());
  let startLine = line(Number(start));
  let endLine;

  if (end === undefined) endLine = startLine + 1;                    // single value: one track
  else if (end.startsWith("span")) endLine = startLine + Number(end.split(" ")[1]);
  else endLine = line(Number(end));

  if (endLine < startLine) [startLine, endLine] = [endLine, startLine];  // reversed notation is fixed
  return { startLine, endLine, tracks: endLine - startLine };
}

console.log(`${COLUMNS} column tracks -> lines 1..${LAST_LINE} (same lines -${LAST_LINE}..-1)`);
for (const y of ["1 / 3", "2 / -1", "1 / span 2", "-3 / -1", "3", "4 / 2"]) {
  const { startLine, endLine, tracks } = resolve(y);
  console.log(`grid-column: ${y.padEnd(12)} -> line ${startLine} to line ${endLine}, ${tracks} track${tracks === 1 ? "" : "s"}`);
}

console.log("\n--- equivalence of negative and positive numbers ---");
for (let n = 1; n <= LAST_LINE; n++) {
  console.log(`line ${n} = line ${n - LAST_LINE - 1}`);
}
```

```
4 column tracks -> lines 1..5 (same lines -5..-1)
grid-column: 1 / 3        -> line 1 to line 3, 2 tracks
grid-column: 2 / -1       -> line 2 to line 5, 3 tracks
grid-column: 1 / span 2   -> line 1 to line 3, 2 tracks
grid-column: -3 / -1      -> line 3 to line 5, 2 tracks
grid-column: 3            -> line 3 to line 4, 1 track
grid-column: 4 / 2        -> line 2 to line 4, 2 tracks

--- equivalence of negative and positive numbers ---
line 1 = line -5
line 2 = line -4
line 3 = line -3
line 4 = line -2
line 5 = line -1
```

Five forms of notation, one rule: every declaration resolves to two line numbers, and the
track count between them is the difference.

The notation `2 / -1` runs from the second line to the last line. The track count is not
written in the declaration; if the grid grows to six columns, the same declaration spans
five tracks. Counting from the end lets the layout be written independently of track
count.

The `span` keyword declares a **length** instead of a line: `1 / span 2` goes two tracks
from the start. Writing only `span 2` on an item whose start line is unknown is also
valid; the item is then auto-placed but occupies two tracks.

The last line shows a detail: if the end line is smaller than the start, the two swap. The
notation `4 / 2` is not an error, it resolves between the second and fourth lines.

The shorthand for all four declarations is the `grid-area` property, and its order is
unconventional: row start, column start, row end, column end. The previous lesson gave an
area a name with the notation `grid-area: profile`; the same property also takes four
lines instead of a name.

#### Automatic Placement

Items with no placement declaration are distributed by the **auto-placement** algorithm.
The algorithm holds a cursor, walks items in document order, and places each item into the
first gap it fits in, starting from the cursor.

The `grid-auto-flow` declaration says which axis to advance on: the value `row` fills a
row and drops to the next one, `column` fills a column and moves to the one beside it. A
third value, `dense`, allows the cursor to move backward.

```js
// placement.mjs — sparse and dense modes of the auto-placement algorithm
const COLUMNS = 3;

// span = how many column tracks it will occupy
const items = [
  { name: "temperature", span: 1 },
  { name: "humidity", span: 2 },
  { name: "wind", span: 2 },
  { name: "pressure", span: 1 },
  { name: "precipitation", span: 1 },
  { name: "snow", span: 3 },
  { name: "sunshine", span: 1 },
  { name: "soil", span: 2 },
];

function place(dense) {
  const filled = [];                     // filled[row][column] = item name
  const addRow = () => { filled.push(new Array(COLUMNS).fill(null)); };
  const fits = (r, c, span) => {
    if (c + span > COLUMNS) return false;
    while (filled.length <= r) addRow();
    for (let i = 0; i < span; i++) if (filled[r][c + i]) return false;
    return true;
  };

  const result = [];
  let cursorRow = 0, cursorColumn = 0;

  for (const o of items) {
    let r = dense ? 0 : cursorRow;
    let c = dense ? 0 : cursorColumn;
    while (!fits(r, c, o.span)) {
      c += 1;
      if (c + o.span > COLUMNS) { c = 0; r += 1; }
    }
    while (filled.length <= r) addRow();
    for (let i = 0; i < o.span; i++) filled[r][c + i] = o.name;
    result.push({ name: o.name, row: r + 1, column: c + 1, span: o.span });
    if (!dense) {                        // in sparse mode the cursor does not go back
      cursorRow = r;
      cursorColumn = c + o.span;
      if (cursorColumn >= COLUMNS) { cursorRow = r + 1; cursorColumn = 0; }
    }
  }
  return { filled, result };
}

function print(title, { filled, result }) {
  console.log(`\n=== ${title} ===`);
  for (const y of result) {
    console.log(`  ${y.name.padEnd(13)} row ${y.row}, column ${y.column}..${y.column + y.span - 1}  (grid-column: ${y.column} / span ${y.span})`);
  }
  console.log(`  row count: ${filled.length}`);
  const emptyCells = filled.flat().filter((h) => h === null).length;
  console.log(`  empty cells: ${emptyCells}`);
  for (const row of filled) {
    console.log("  | " + row.map((h) => (h ?? "-").padEnd(13)).join(" | ") + " |");
  }
}

console.log(`${COLUMNS}-column grid, ${items.length} items, spans: ${items.map((o) => o.span).join(" ")}`);
print("grid-auto-flow: row (sparse, default)", place(false));
print("grid-auto-flow: row dense", place(true));
```

```
3-column grid, 8 items, spans: 1 2 2 1 1 3 1 2

=== grid-auto-flow: row (sparse, default) ===
  temperature   row 1, column 1..1  (grid-column: 1 / span 1)
  humidity      row 1, column 2..3  (grid-column: 2 / span 2)
  wind          row 2, column 1..2  (grid-column: 1 / span 2)
  pressure      row 2, column 3..3  (grid-column: 3 / span 1)
  precipitation row 3, column 1..1  (grid-column: 1 / span 1)
  snow          row 4, column 1..3  (grid-column: 1 / span 3)
  sunshine      row 5, column 1..1  (grid-column: 1 / span 1)
  soil          row 5, column 2..3  (grid-column: 2 / span 2)
  row count: 5
  empty cells: 2
  | temperature   | humidity      | humidity      |
  | wind          | wind          | pressure      |
  | precipitation | -             | -             |
  | snow          | snow          | snow          |
  | sunshine      | soil          | soil          |

=== grid-auto-flow: row dense ===
  temperature   row 1, column 1..1  (grid-column: 1 / span 1)
  humidity      row 1, column 2..3  (grid-column: 2 / span 2)
  wind          row 2, column 1..2  (grid-column: 1 / span 2)
  pressure      row 2, column 3..3  (grid-column: 3 / span 1)
  precipitation row 3, column 1..1  (grid-column: 1 / span 1)
  snow          row 4, column 1..3  (grid-column: 1 / span 3)
  sunshine      row 3, column 2..2  (grid-column: 2 / span 1)
  soil          row 5, column 1..2  (grid-column: 1 / span 2)
  row count: 5
  empty cells: 2
  | temperature   | humidity      | humidity      |
  | wind          | wind          | pressure      |
  | precipitation | sunshine      | -             |
  | snow          | snow          | snow          |
  | soil          | soil          | -             |
```

Both maps place the same eight items into the same three columns, and the result is
identical for the first six items. The distinction starts at the seventh item.

In sparse mode, once the `snow` item has filled the fourth row, the cursor has moved to
the fifth row and **does not go back**. Even though the third row has two empty cells, the
`sunshine` item drops to the fifth row. The gap remaining is not a defect, it is the
consequence of the rule: it preserves the link between document order and visual order.

In dense mode, the search for every item starts from the beginning of the grid.
`sunshine` fills the gap in the third row. The `soil` item wants two tracks and does not
fit there, so it drops to the fifth row — dense mode fills gaps, but it does not force an
item that does not fit.

In the end, both modes leave two cells empty; what changes is which item is where. Dense
mode does not always leave less gap, it only fills earlier.

#### The Cost of Dense Mode

In dense mode, the `sunshine` item appears above the `snow` item, which comes **after** it
in the document. Visual order and document order have diverged.

This has concrete consequences. When a user navigating by keyboard presses tab, focus
follows document order and jumps backward on screen. A screen reader reads document order
and does not match the visual layout. Two users perceive the same page in two different
orders.

The same warning applies to every reordering produced by the `order` declaration,
`flex-direction: row-reverse`, and explicit line placement. The rule is: if visual order
and document order diverge, it should be made certain that the order in the document is
correct as the **reading order**; visual layout is built on top of that order, it does not
replace it.

For measurement cards, the decision is easy: if the cards' order does not carry the
importance of the measurement type, dense mode can be used. If order carries meaning — for
instance, if measurements are in chronological order — sparse mode is kept.

#### Implicit Tracks

A track definition bounds the grid but does not bound the items. Placing eight items
required five rows, yet the track definition did not write a row count. When there are
more items than defined, the grid grows on its own, and the tracks that form this way are
called **implicit tracks**.

The ones defined are called the **explicit grid**, the ones that form on their own the
**implicit grid**. The distinction has two consequences. First, the size of implicit
tracks comes not from the `grid-template-*` declaration but from `grid-auto-rows` and
`grid-auto-columns`; if not written, the size is content. Second, counting from the end
($-1$) is only defined for the explicit grid; implicit tracks are not part of that
numbering.

```css
/* layout.css — step 4: measurement cards */
.measurement-cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  grid-auto-rows: minmax(8rem, auto);
  gap: var(--spacing-0);
}

.measurement-cards .wide { grid-column: span 2; }

.measurement-cards .summary {
  grid-column: 1 / -1;
  grid-row: 1;
}
```

The `grid-auto-rows: minmax(8rem, auto)` declaration gives every implicit row a lower
bound: cards with short content still come out at least eight base units tall, and cards
with long content can grow. The notation `1 / -1` spans the summary card across the entire
row and stays correct even as the track count changes with screen width.

One warning: a card carrying `span 2` will ask for two tracks even when `auto-fill`
produces only one. In this case, the item does not overflow the explicit grid; the grid
adds an implicit column to the right and the layout breaks. If cards are going to be given
a `span`, the widths at which the track count is at least two need to be constrained
separately — the tool for this conditional notation is the queries covered in the next
topic.

#### Alignment Works at Two Levels

In grid, alignment answers two separate questions. First: where does an item sit within
its own area? `justify-self` declares this on the inline axis, `align-self` on the block
axis; `justify-items` and `align-items` written on the container give the default for all
items. Since the default value is `stretch`, an item fills its area.

Second: if the tracks' total is smaller than the container, where does the grid itself
sit? `justify-content` and `align-content` declare this, and the set of values is the same
as flexbox's. If the tracks are defined with `fr`, no free space remains, so these
declarations have no visible effect; on fixed-size tracks, they are visible.

In flexbox, `justify-content` distributed items; in grid, it distributes tracks. The same
name applies to a different object in the two models, and when this distinction is
confused, the declaration appears to have no effect.

#### Summary

- Every placement declaration resolves to two line numbers; `span` declares a length,
  negative numbers count from the end, and reversed lines swap places.
- Auto-placement puts items into the first gap they fit in, starting from the cursor, in
  document order; in sparse mode the cursor does not go back and gaps remain.
- Dense mode fills gaps but separates visual order from document order; focus order and
  screen readers keep following document order.
- Items beyond what is defined produce implicit tracks, whose size comes from
  `grid-auto-rows` and `grid-auto-columns`; counting from the end does not cover implicit
  tracks.
- In grid, `justify-items` and `align-items` place an item inside its area,
  `justify-content` and `align-content` place the tracks inside the container.

#### Next Step

Both layout models are now established: one distributes on a single axis, the other
defines lines on two axes. There are situations the same layout can be built with either,
and the choice is often made out of habit. The next lesson ties this choice to a
criterion: which question is answered with fewer declarations and less fragility by which
model?

### Choosing Between Grid and Flexbox

Source: https://academia.sh/en/courses/layout-and-responsive-design/grid-and-flexbox-selection

Which question each of the two layout models answers, the computed difference in last-row behavior, the cost a spread request creates in flexbox, and the nested use of the two models together.

The previous four lessons established the two layout models separately. Flexbox
distributes on a single axis, grid defines lines on two axes. A row of cards can be laid
out with either; a navigation bar can be built with either. The choice is often made
based on whichever model was learned most recently.

This lesson takes the choice out of habit and ties it to a criterion. The criterion is a
single question: **is the layout decision coming from the content, or from you?** The
answer is hidden not in the two models' names, but in where their calculations start.

#### Where the Size Comes From

In flexbox, the calculation starts from the **item**. Every item has a base size; these
sizes are added up, the difference from the container is taken, and the remaining space is
shared out according to growth and shrink factors. The container asks what the items want,
then negotiates. This is why flexbox is called a model that works **from content to
layout**.

In grid, the calculation starts from the **container**. The track definition sets the
lines before any item is placed; items sit onto those lines afterward. A track's size can
be derived from content (`auto`, `min-content`, `max-content`), but this is an option, not
the rule itself. Grid works **from layout to content**.

The distinction determines which question each model answers well:

- If the number and size of items are not known ahead of time, and the remaining space
  needs to be shared among them — the question is single-axis, and flexbox solves it with
  fewer declarations.
- If items need to align relative to each other in both row and column, and the space one
  item takes up determines where another sits — the question is two-axis, and grid is
  needed.

The phrase "single-axis" does not mean items stay on a single line. A flex container with
`flex-wrap: wrap` also produces more than one line. The distinction is this: in flexbox,
every line is computed **on its own**, the lines do not see each other. In grid, every row
shares the same set of lines.

#### Last-Row Behavior

The most concrete consequence of this distinction shows up when items do not divide
evenly. The following program lays out the same seven cards in the same container with
the two models.

```js
// choice.mjs — placing the same card row with flexbox and grid
const CONTAINER = 980;   // px
const GAP = 16;          // gap
const BASE = 200;        // flex-basis / minmax lower bound
const CARDS = 7;

// in both models, the number of items that fit one row is found by the same rule
const fitting = Math.max(1, Math.floor((CONTAINER + GAP) / (BASE + GAP)));

// grid: repeat(auto-fill, minmax(200px, 1fr)) -> every track the same size
const trackSize = (CONTAINER - GAP * (fitting - 1)) / fitting;

// flexbox: flex: 1 1 200px + flex-wrap: wrap -> every row shares within itself
function flexRows() {
  const rows = [];
  for (let i = 0; i < CARDS; i += fitting) {
    const count = Math.min(fitting, CARDS - i);
    rows.push({ count, size: (CONTAINER - GAP * (count - 1)) / count });
  }
  return rows;
}

const write = (v) => v.toFixed(1).padStart(6);

console.log(`container=${CONTAINER}px  gap=${GAP}px  base=${BASE}px  cards=${CARDS}`);
console.log(`cards that fit one row: ${fitting}\n`);

console.log("--- grid: repeat(auto-fill, minmax(200px, 1fr)) ---");
let no = 1;
for (let s = 0; s < Math.ceil(CARDS / fitting); s++) {
  const count = Math.min(fitting, CARDS - s * fitting);
  let x = 0;
  const places = [];
  for (let i = 0; i < count; i++) {
    places.push(`c${no++} ${write(x)}..${write(x + trackSize)}`);
    x += trackSize + GAP;
  }
  console.log(`row ${s + 1}: ` + places.join("  "));
}

console.log("\n--- flexbox: flex: 1 1 200px; flex-wrap: wrap ---");
no = 1;
flexRows().forEach((row, s) => {
  let x = 0;
  const places = [];
  for (let i = 0; i < row.count; i++) {
    places.push(`c${no++} ${write(x)}..${write(x + row.size)}`);
    x += row.size + GAP;
  }
  console.log(`row ${s + 1}: ` + places.join("  "));
});

console.log("\n--- right edge of the first column, row by row ---");
const gridFirst = trackSize;
flexRows().forEach((row, s) => {
  const diff = row.size - gridFirst;
  console.log(
    `row ${s + 1}: grid ${write(gridFirst)}  flex ${write(row.size)}  diff ${write(diff)}`,
  );
});
```

```
container=980px  gap=16px  base=200px  cards=7
cards that fit one row: 4

--- grid: repeat(auto-fill, minmax(200px, 1fr)) ---
row 1: c1    0.0.. 233.0  c2  249.0.. 482.0  c3  498.0.. 731.0  c4  747.0.. 980.0
row 2: c5    0.0.. 233.0  c6  249.0.. 482.0  c7  498.0.. 731.0

--- flexbox: flex: 1 1 200px; flex-wrap: wrap ---
row 1: c1    0.0.. 233.0  c2  249.0.. 482.0  c3  498.0.. 731.0  c4  747.0.. 980.0
row 2: c5    0.0.. 316.0  c6  332.0.. 648.0  c7  664.0.. 980.0

--- right edge of the first column, row by row ---
row 1: grid  233.0  flex  233.0  diff    0.0
row 2: grid  233.0  flex  316.0  diff   83.0
```

The first rows are identical. Both models find, with the same arithmetic, that four cards
fit the container, and place the four cards in the same spots. The distinction begins in
the second row.

In grid, the second row uses **the same lines**. Three cards are left, but there are four
tracks; the cards sit in the first three tracks, and the fourth stays empty. The cards'
size does not change: 233 units.

In flexbox, the second row runs its own calculation. Three cards share 980 units, and each
grows to 316 units. The last row fills with cards 83 units wider; the first row's card
edges never line up with the second row's at any point.

This is not a defect, it is the consequence of how the two models are defined. Which one
is correct depends on what is wanted. If the cards are the visual counterpart of a table
and column alignment carries meaning for reading, grid is correct. If the cards are
independent, standalone labels and leaving empty space is not wanted, flexbox's behavior
is the wanted one.

A commonly attempted fix is adding "ghost" items to the last row: invisible boxes to fill
the gap that does not divide evenly. This is a sign of making flexbox do grid's job;
changing models is cheaper than adding an item with no meaning to the document.

#### The Cost of a Spread Request

The second criterion shows up when one item is wanted to be **wider** than the others.
Grid writes this with a single declaration: `grid-column: span 2`. In flexbox, its
counterpart is a ratio, and that ratio depends on the track count.

```js
// spread.mjs — the cost of a two-track-wide card in the two models
const GAP = 16;
const BASE = 200;

console.log("minmax(200px, 1fr), gap 16px — size of a card spanning two tracks");
console.log("container  tracks  track size  span 2 size  flex-basis for the same result");

for (const W of [640, 780, 980, 1180, 1420]) {
  const tracks = Math.max(1, Math.floor((W + GAP) / (BASE + GAP)));
  const size = (W - GAP * (tracks - 1)) / tracks;
  const span2 = 2 * size + GAP;           // in grid: grid-column: span 2
  const percent = (span2 / W) * 100;       // basis to write by hand in flexbox
  console.log(
    `${String(W).padStart(9)}  ${String(tracks).padStart(6)}  ` +
      `${size.toFixed(1).padStart(10)}  ${span2.toFixed(1).padStart(11)}  ` +
      `${percent.toFixed(2).padStart(29)}%`,
  );
}

console.log("\n--- the same card written with a single flex-basis value ---");
const CHOSEN = 49.18; // value computed for a 980px width
console.log(`flex: 0 0 ${CHOSEN}%  (the correct value for 980px)`);
console.log("container  applied size  correct at that width  deviation");
for (const W of [640, 780, 980, 1180, 1420]) {
  const tracks = Math.max(1, Math.floor((W + GAP) / (BASE + GAP)));
  const size = (W - GAP * (tracks - 1)) / tracks;
  const correct = 2 * size + GAP;
  const applied = (W * CHOSEN) / 100;
  const deviation = Math.round((applied - correct) * 10) / 10 || 0;
  console.log(
    `${String(W).padStart(9)}  ${applied.toFixed(1).padStart(13)}  ` +
      `${correct.toFixed(1).padStart(22)}  ${deviation.toFixed(1).padStart(9)}`,
  );
}
```

```
minmax(200px, 1fr), gap 16px — size of a card spanning two tracks
container  tracks  track size  span 2 size  flex-basis for the same result
      640       3       202.7        421.3                          65.83%
      780       3       249.3        514.7                          65.98%
      980       4       233.0        482.0                          49.18%
     1180       5       223.2        462.4                          39.19%
     1420       6       223.3        462.7                          32.58%

--- the same card written with a single flex-basis value ---
flex: 0 0 49.18%  (the correct value for 980px)
container  applied size  correct at that width  deviation
      640          314.8                   421.3     -106.6
      780          383.6                   514.7     -131.1
      980          482.0                   482.0        0.0
     1180          580.3                   462.4      117.9
     1420          698.4                   462.7      235.7
```

The first table shows that "two tracks wide" is not a fixed ratio. When the track count is
three, the ratio is close to 66 percent; once it rises to six, it drops to 33 percent. The
same visual request means five different numbers depending on container width.

The second table measures the cost. Once a width is picked and the ratio is written for
it, the deviation at that width is zero; at other widths, it ranges between 100 and 235
units. The card is narrower than it should be in a narrow container, wider than it should
be in a wide one. The only way to hit the correct result at every width is to rewrite the
ratio for width ranges — that is, to hand-compute what grid computes, and imitate it as a
layout decision.

In grid, the `span 2` declaration writes none of this arithmetic; whatever the track size
is, it takes up two tracks and the gap between them, no matter what.

#### Order, Spread, and Overlap

The third criterion is whether items see each other.

In flexbox, items are a one-dimensional array. Order can be changed with `order`, but one
item cannot sit on top of another, cannot span two rows at once. In grid, items sit into
cells; placing two items in the same cell is valid, and the two overlap. Layering text
over an image can be written with grid alone, with no positioning needed.

Another distinction continues on the accessibility side. As seen in the grid-placement
lesson, separating visual order from document order breaks focus order. Grid produces this
separation with much less effort: placement by line number ignores document order entirely. In
flexbox, only `order` and reversed directions create the same risk. The model's power does
just as much damage when used incorrectly.

#### Nesting the Two Models

Though the criteria look like a comparison, the choice on most pages is not "one or the
other." Large sections of a page are two-axis; the rows of buttons and labels inside a
section are single-axis. Nested use is the consequence of the rule.

```css
/* layout.css — step 5: the two models' division of labor */
.station-layout {           /* two-axis: sections align relative to each other */
  display: grid;
  grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
  grid-template-areas:
    "profile      profile"
    "measurements sidebar"
    "location     sidebar";
  gap: var(--spacing-1);
}

.profile             { grid-area: profile; }
.measurement-section { grid-area: measurements; }
.location-section    { grid-area: location; }
.sidebar              { grid-area: sidebar; }

.measurement-cards {        /* two-axis: card edges align column by column */
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: var(--spacing-0);
}

.measurement-cards .summary { grid-column: 1 / -1; }

.card-heading {              /* single-axis: heading and badge on the same line */
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
}

.card-heading .unit { margin-inline-start: auto; }
```

Three containers, three separate questions. The page layout aligns sections in both row
and column: grid. The card row wants card edges to line up across rows: grid. The two
items inside a card heading are laid out in a single direction, and the gap between them
grows from content: flexbox.

A boundary appears here. The `.unit` item inside a card heading does not align with the
`.unit` item in a neighboring card; every card runs its own flex calculation. Establishing
that alignment requires the inner container to see the outer grid's lines. The `subgrid`
value lets a nested grid inherit its container's lines instead of defining its own tracks,
and this solves the problem. Whether the value is recognized is tested with a feature
query; query notation is covered in the next topic.

#### Order of Decision

The choice can be closed out with three questions, and their order matters.

1. **Will items align relative to each other on two axes?** If the answer is yes, grid.
   This question is decisive enough to make the other two unnecessary.
2. **Is content determining the size?** If the item count is unknown, sizes come from text
   length, and the gap between them should be shared from growing space, flexbox asks for
   fewer declarations.
3. **Will one item span a different area than the others?** If there is a request for
   spreading, overlap, or placement into a specific cell, grid.

None of the criteria carries an ordering along the lines of "grid is new, flexbox is old."
The two models are two separate tools of the same layout layer, and neither replaces the
other.

#### Summary

- Flexbox starts its calculation from the item and solves each row on its own; grid
  starts its calculation from the container and applies the same set of lines to every
  row.
- The two models diverge when items do not divide evenly: in grid, the last row's items
  keep the track size; in flexbox, the remaining items grow to share the row.
- One item spanning two tracks is a single declaration in grid; its counterpart in
  flexbox is a ratio that changes with track count and is not correct at every width with
  a single value.
- Overlap, placement into a specific cell, and two-axis alignment are only defined in
  grid; the risk of breaking order grows just as much.
- Grid at the page level, flexbox inside a component is a common division of labor; the
  reason is not habit, it is that the two levels decide on a different number of axes.

#### Next Step

Up to this point, the unit of layout has always been a box: a card, a section, a button.
Boxes were placed into lines or distributed along an axis. But the text itself on a page is
also a flow that can be laid out; a long description paragraph stretched into a single line
on a wide screen becomes hard to read. The next lesson covers the model that divides text
flow into columns without splitting it into boxes: how is the relationship between column
count and column width established, and by what rule does content divide between columns?

### Multi-Column Layout

Source: https://academia.sh/en/courses/layout-and-responsive-design/multi-column-layout

Splitting a text flow into columns, resolving column-count and column-width declarations together, the balancing rule, break control, and the cases multi-column layout is not suited to.

Every layout up to this point has had a box as its unit: a card, a section, a button.
Boxes were placed onto lines or distributed along an axis. A long description paragraph on
a page, though, is a single box, and on a wide container it stretches out as a single run
of lines.

This lesson covers the model that splits the text itself. **Multi-column layout** divides
a box's content across more than one column; it does not split the box, it splits the
flow. The distinction looks small, but it determines the layout's entire behavior.

#### Splitting the Flow, Not the Box

When `columns` is declared on a box, the box itself stays a single box. Its border,
background, and margin apply to the whole. What changes is how the lines inside the box
are placed: lines fill the first column, and once it is full, move to the second.

This differs from flexbox and grid in two ways. First, columns are not items; they cannot
be targeted with a selector, and their count is not visible in the document. Second, a
single paragraph can be **split** across two columns: the text's first three lines can sit
in the first column, its remaining two lines in the second. In grid, an item is not split
across two tracks, it spans them; here, the content genuinely fragments.

For this reason, multi-column layout is not a page-layout tool. Grid exists for placing
page sections side by side; multi-column layout is used to keep a single continuous run of
text at a readable width.

#### Count or Width

There are two declarations, and both feed the same calculation. `column-count` says how
many columns there will be, `column-width` says how wide a column will be **at minimum**.
The `columns` shorthand writes both together.

Whichever is written, the used column count and the used column width are derived by a
single rule.

```js
// column.mjs — used column count and width in a multi-column layout
// CSS Multi-column pseudo-algorithm: whichever of count and width is written,
// the other is derived from the container's width.
const GAP = 32; // column-gap: 2rem (root 16px)

function resolve(W, { width, count }) {
  let usedCount;
  if (count === "auto") {
    usedCount = Math.max(1, Math.floor((W + GAP) / (width + GAP)));
  } else if (width === "auto") {
    usedCount = count;
  } else {
    usedCount = Math.min(count, Math.max(1, Math.floor((W + GAP) / (width + GAP))));
  }
  const usedWidth = (W - GAP * (usedCount - 1)) / usedCount;
  return { count: usedCount, width: usedWidth };
}

const cases = [
  ["column-width: 18rem", { width: 288, count: "auto" }],
  ["column-count: 3", { width: "auto", count: 3 }],
  ["columns: 18rem 3", { width: 288, count: 3 }],
];

console.log(`column-gap: 2rem = ${GAP}px   (18rem = 288px, root 16px)`);
for (const [name, declaration] of cases) {
  console.log(`\n--- ${name} ---`);
  console.log("container  columns  column width");
  for (const W of [320, 480, 640, 800, 1024, 1280]) {
    const { count, width } = resolve(W, declaration);
    console.log(
      `${String(W).padStart(9)}  ${String(count).padStart(7)}  ${width.toFixed(1).padStart(12)}`,
    );
  }
}

// column-width is a lower bound: with n columns produced, the used width
// stays in [U, ((n+1)U + G)/n). The range narrows as n grows.
console.log("\n--- column-width is a lower bound: the range of the used width ---");
console.log("U = 288px (18rem), G = 32px");
console.log("columns  narrowest  widest (approaching the bound)  ratio");
for (let n = 1; n <= 5; n++) {
  const U = 288;
  const narrowest = U;
  const widest = ((n + 1) * U + GAP) / n;
  console.log(
    `${String(n).padStart(7)}  ${narrowest.toFixed(1).padStart(9)}  ` +
      `${widest.toFixed(1).padStart(28)}  ${(widest / narrowest).toFixed(2).padStart(4)}`,
  );
}
```

```
column-gap: 2rem = 32px   (18rem = 288px, root 16px)

--- column-width: 18rem ---
container  columns  column width
      320        1         320.0
      480        1         480.0
      640        2         304.0
      800        2         384.0
     1024        3         320.0
     1280        4         296.0

--- column-count: 3 ---
container  columns  column width
      320        3          85.3
      480        3         138.7
      640        3         192.0
      800        3         245.3
     1024        3         320.0
     1280        3         405.3

--- columns: 18rem 3 ---
container  columns  column width
      320        1         320.0
      480        1         480.0
      640        2         304.0
      800        2         384.0
     1024        3         320.0
     1280        3         405.3

--- column-width is a lower bound: the range of the used width ---
U = 288px (18rem), G = 32px
columns  narrowest  widest (approaching the bound)  ratio
      1      288.0                         608.0  2.11
      2      288.0                         448.0  1.56
      3      288.0                         394.7  1.37
      4      288.0                         368.0  1.28
      5      288.0                         352.0  1.22
```

Three declaration forms, three separate behaviors.

When `column-width` is written on its own, the column count is derived from the container
and has no upper bound: as the container widens, columns keep being added. If five or six
columns forming on a very wide screen is not wanted, this declaration alone is not enough.

When `column-count` is written on its own, the column count is fixed, and as the container
narrows, the columns shrink together. The output's second block shows the result: in a
320-unit container, three columns, each 85 units. A column fits about ten characters; the
text becomes unreadable. This declaration cannot be used alone to adapt to a narrow
container.

`columns: 18rem 3` combines the two and states: "at most three columns, but every column
should be at least 18 base units wide." The column count is the smaller of the two
conditions; the width condition governs in a narrow container, the count condition in a
wide one. In practice, this is usually the behavior that is wanted.

The last block puts a frequently misunderstood point into numbers. `column-width` is not a
target, it is a **lower bound**. As long as the container is wider than 18 base units, the
used width does not drop below this value, but it can rise above it: in the single-column
case, to more than double. This ratio narrows as the column count rises, because a new
column gets added sooner. For this reason, a `column-width` value is chosen against the
**narrowest** acceptable size, not against the ideal size for reading.

When the container is narrower than the `column-width` value, the lower bound cannot be
applied. The column count drops to one and the width equals the container; no overflow
occurs.

#### How Content Divides Into Columns

Once the columns' size is settled, the second question comes up: which line goes into
which column? `column-fill` declares this. The `balance` value tries to equalize column
heights, the `auto` value fills columns in order and requires the box to have been given a
height for that.

Balancing is a simple split only when there are no unbreakable blocks. Once unbreakable
blocks enter, the result changes.

```js
// balance.mjs — column-balancing model: at what height content splits across columns
// The unit of measure is a line. A block is "unbreakable" if break-inside: avoid is written.
const COLUMNS = 3;

const content = [
  { name: "intro-p", lines: 6, breakable: true },
  { name: "method-p", lines: 5, breakable: true },
  { name: "measurement-list", lines: 7, breakable: false },
  { name: "interim-p", lines: 4, breakable: true },
  { name: "figure", lines: 9, breakable: false },
  { name: "result-p", lines: 8, breakable: true },
  { name: "note-p", lines: 3, breakable: true },
];

// Distributes content into columns in order, given a column height.
function pack(H) {
  const columns = [[]];
  let filled = 0;
  const newColumn = () => { columns.push([]); filled = 0; };

  for (const block of content) {
    if (!block.breakable) {
      if (filled > 0 && filled + block.lines > H) newColumn();
      columns[columns.length - 1].push(`${block.name}(${block.lines})`);
      filled += block.lines;
      continue;
    }
    let remaining = block.lines;
    let part = 0;
    while (remaining > 0) {
      if (filled >= H) newColumn();
      const placed = Math.min(remaining, H - filled);
      part += 1;
      const label = remaining === block.lines && placed === block.lines
        ? `${block.name}(${placed})`
        : `${block.name}.${part}(${placed})`;
      columns[columns.length - 1].push(label);
      filled += placed;
      remaining -= placed;
    }
  }
  const heights = columns.map((c) =>
    c.reduce((t, e) => t + Number(e.match(/\((\d+)\)/)[1]), 0));
  return { columns, heights };
}

const total = content.reduce((t, b) => t + b.lines, 0);
console.log(`total ${total} lines, ${COLUMNS} columns, even split ${(total / COLUMNS).toFixed(2)} lines`);
console.log("unbreakable blocks: " + content.filter((b) => !b.breakable).map((b) => `${b.name}(${b.lines})`).join(", "));

// column-fill: balance — searches for the smallest height that does not exceed the column count
let H = Math.ceil(total / COLUMNS);
let result = pack(H);
while (result.columns.length > COLUMNS) {
  H += 1;
  result = pack(H);
}
console.log(`\n--- column-fill: balance  (height found: ${H} lines) ---`);
result.columns.forEach((c, i) => {
  console.log(`column ${i + 1} (${result.heights[i]} lines): ${c.join(" | ")}`);
});
console.log(`highest - lowest difference: ${Math.max(...result.heights) - Math.min(...result.heights)} lines`);

// column-fill: auto — height is given from outside, columns fill in order
const FIXED = 12;
const auto = pack(FIXED);
console.log(`\n--- column-fill: auto, block-size: ${FIXED} lines ---`);
auto.columns.forEach((c, i) => {
  const overflow = i >= COLUMNS ? "  <- exceeded the defined column count" : "";
  console.log(`column ${i + 1} (${auto.heights[i]} lines): ${c.join(" | ")}${overflow}`);
});

// how much does balancing improve if the unbreakable blocks are removed
const oldContent = content.map((b) => ({ ...b }));
for (const b of content) b.breakable = true;
let H2 = Math.ceil(total / COLUMNS);
let r2 = pack(H2);
while (r2.columns.length > COLUMNS) { H2 += 1; r2 = pack(H2); }
console.log(`\n--- same content, no block has break-inside: avoid ---`);
console.log(`height found: ${H2} lines (with unbreakable blocks: ${H} lines)`);
r2.columns.forEach((c, i) => {
  console.log(`column ${i + 1} (${r2.heights[i]} lines): ${c.join(" | ")}`);
});
content.forEach((b, i) => { b.breakable = oldContent[i].breakable; });
```

```
total 42 lines, 3 columns, even split 14.00 lines
unbreakable blocks: measurement-list(7), figure(9)

--- column-fill: balance  (height found: 18 lines) ---
column 1 (18 lines): intro-p(6) | method-p(5) | measurement-list(7)
column 2 (18 lines): interim-p(4) | figure(9) | result-p.1(5)
column 3 (6 lines): result-p.2(3) | note-p(3)
highest - lowest difference: 12 lines

--- column-fill: auto, block-size: 12 lines ---
column 1 (11 lines): intro-p(6) | method-p(5)
column 2 (11 lines): measurement-list(7) | interim-p(4)
column 3 (12 lines): figure(9) | result-p.1(3)
column 4 (8 lines): result-p.2(5) | note-p(3)  <- exceeded the defined column count

--- same content, no block has break-inside: avoid ---
height found: 14 lines (with unbreakable blocks: 18 lines)
column 1 (14 lines): intro-p(6) | method-p(5) | measurement-list.1(3)
column 2 (14 lines): measurement-list.2(4) | interim-p(4) | figure.1(6)
column 3 (14 lines): figure.2(3) | result-p(8) | note-p(3)
```

This is a model, not a browser's actual output: line counts are assumed, and the balancing
search is written to find the smallest height. The constraint the model shows is real.

The third block gives the case where splitting is free: 42 lines divide into three columns
as 14, 14, 14. There is no other result than an even split.

In the first block, two blocks are marked unbreakable. Balancing searches for the smallest
height that can fit the content without opening a third column, and finds 18 instead of 14.
The resulting split is 18, 18, 6; the last column is a third as full as the others. An
unbreakable block that does not fit a gap smaller than itself jumps to the start of the
next column and leaves unusable space behind it. Balancing cannot recover this space.

The second block shows the `auto` value. The height is given from outside, columns fill in
order, and the content does not fit three columns: a fourth column forms. Columns beyond
the defined count are called an **overflow column** and step outside the box's boundary.
`column-fill: auto` is therefore only meaningful in boxes whose height is controlled.

#### Break Control

When text splits across columns, some splits are not wanted: a heading should not be left
alone at the bottom of a column, a table should not be cut in half. The property family
that declares this is break control.

`break-inside: avoid` prevents a box from being split from within. `break-before: column`
and `break-after: column` force a column break before or after a box. These declarations
work under the same names not only in multi-column layout but also in paginated output
(printing); both are contexts where content is divided into parts.

Two more declarations work at the line level. `orphans` gives the minimum number of lines
to be left at the **end** of a fragment, `widows` the minimum number of lines to appear at
the **start** of the next fragment. The default value for both is 2: a paragraph's last
line does not fall alone at the start of a new column.

These declarations state a request, they do not guarantee it. The cost of the request
being honored was seen in the calculation above: as the number of unbreakable blocks
grows, balancing degrades. Break control is used selectively; writing `avoid` on every
block makes the columns unusable.

#### Rule and Spanning

Two more declarations complete the visual arrangement.

`column-rule` draws a line between columns, and its notation is the same as the border
shorthand: thickness, style, color. This line **takes up no space**; it is drawn at the
center of the column gap without entering the measurement. If the gap is narrower than the
rule, the rule overflows onto the columns.

`column-span: all` lets an item cut across every column. Such an item splits the flow in
two: the content before it is distributed into columns, the item sits at full width, and
the content after it is distributed into columns again. The two groups are balanced
separately. This is how a section heading is kept above the columns.

```css
/* layout.css — step 6: station method notes */
.station-notes {
  columns: 18rem 3;
  column-gap: var(--spacing-1);
  column-rule: 1px solid var(--line);
}

.station-notes > h3 {
  column-span: all;
  margin-block-start: 0;
}

.station-notes figure,
.station-notes table,
.station-notes li {
  break-inside: avoid;
}

.station-notes p {
  orphans: 3;
  widows: 3;
}
```

The declaration set is small, but every line has a separate reason. `columns` ties the
column count to the smaller of two conditions. `column-rule` makes the division visible
without interfering with the measurement. `column-span: all` keeps the heading above the
columns. `break-inside: avoid` is written only on the three item types whose splitting
would break meaning — it is not written on paragraphs, because a paragraph splitting is
normal. `orphans` and `widows` are pulled one above the default, ruling out one- and
two-line leftovers.

#### Where It Is Not Suited

Multi-column layout has a limit, and it comes from reading direction. Columns fill
vertically: the reader goes down the first column, then returns to the top of the second.
This is natural on paper, because the whole column is visible at once. On screen, if a
column is longer than the viewport, the reader scrolls down, then scrolls back up, then
moves to the next column.

The rule is: if a column's height exceeds the viewport, multi-column layout makes reading
harder. In short text — a method note, a definition list, a reference list — the gain is
real; in a long article, it is a loss.

A second limit is that columns have no counterpart in the document. Screen readers and
keyboard focus follow document order, and since columns do not change that order, no
additional accessibility problem is created. But when a link inside a column receives
focus, where the page scrolls to can be unpredictable, depending on column height.

#### Summary

- Multi-column layout splits the flow, not the box; a single paragraph can fragment
  across two columns, and columns are structures with no counterpart as an item in the
  document.
- The used column count is the smaller of the written `column-count` value and the count
  that fits the container; the used width is found by dividing the remaining space by
  this count.
- `column-width` is a lower bound, not a target: the used width can rise above this
  value, to more than double in the single-column case.
- `column-fill: balance` tries to equalize column heights; unbreakable blocks disrupt
  balancing and can leave the last column empty.
- `column-span: all` splits the flow in two and the two parts are balanced separately;
  `column-rule` is drawn in the column gap without taking up space.
- If a column's height exceeds the viewport, the reader is forced to scroll down and back
  up; this is the criterion for the layout not being suited.

#### Next Step

This lesson quietly used one criterion: when column width did not fit the container, the
column count was reduced. The decision rested not on a list of screen widths, but on the
narrowest measure at which content stays readable. The same reasoning applies to the page
as a whole. The next topic carries this reasoning to the entirety of a layout: at what
width should a layout change, by what measure is that decision found, and why is it
derived from content rather than from screen lists?

## Responsive Design

Deriving the breakpoint from content, which question media and container queries each ask, the fluid typography computation, image selection with a source set, and style that adapts to user preferences.

### The Principle of Responsive Design

Source: https://academia.sh/en/courses/layout-and-responsive-design/responsive-design-principle

The three components of responsiveness, deriving breakpoints from content by computation, thresholds that resolve on their own through intrinsic sizing, and how a track definition shifts a threshold.

The multi-column layout lesson made a decision silently: when a column's width no longer fit
its container, the column count was lowered. The threshold did not come from a list of screen
widths, it came from the narrowest measure at which the text stayed readable.

This lesson carries the same reasoning to the whole page. At what width a layout needs to
change is a computable question; the answer lies not in device names but in the content's own
measurements.

#### The Three Components of Responsiveness

**Responsive design** is not the name of a single declaration; it is three behaviors present
together.

**A fluid grid.** The layout's dimensions are given not with fixed lengths but with ratios and
remaining space. `fr`, percentages, `minmax()`, and `auto` do this job; fixed pixel widths are
kept only where something genuinely needs to be fixed.

**Flexible media.** Images and embedded content never exceed their container; their size comes
not from their own natural dimensions but from what the container leaves.

**Conditional style.** When the two above are not enough on their own — when the layout's
structure has to change — style is bound to a condition.

The order matters. The more work the first two components do, the less is left for the third.
Every condition written with a query is a branch that has to be maintained in the stylesheet;
every threshold resolved through intrinsic sizing never appears in the file at all.

#### A Breakpoint Is a Measurement

A **breakpoint** is the width at which a layout's structure changes. The question asked
correctly is this: how narrow can this component get before it loses its meaning? The answer
is a number, and it comes from the component's content.

The program below computes this number for three components of the measurement station page.

```js
// breakpoints.mjs — deriving breakpoints from content
// The page's horizontal padding is 24px on each side.
const PADDING = 24 * 2;

// 1) Text column: body text 18px, average character width 0.5em -> 9px/character
const CHAR_WIDTH = 9;
const MEASURE_MIN = 45;   // fewest readable characters
const MEASURE_MAX = 75;   // most readable characters
const textMin = MEASURE_MIN * CHAR_WIDTH;
const textMax = MEASURE_MAX * CHAR_WIDTH;

// 2) Measurement cards: the narrowest size a card stays meaningful at, and the gap
const CARD = 200;
const CARD_GAP = 16;
const cardThreshold = (n) => n * (CARD + CARD_GAP) - CARD_GAP;

// 3) Page layout: the narrowest sizes the main section and aside need to sit side by side
const MAIN_MIN = 480;
const ASIDE_MIN = 260;
const PAGE_GAP = 24;
const twoColumnThreshold = MAIN_MIN + ASIDE_MIN + PAGE_GAP;

console.log("--- 1) text column ---");
console.log(`body text 18px, 1 character ~ ${CHAR_WIDTH}px`);
console.log(`measure ${MEASURE_MIN}-${MEASURE_MAX} characters -> column ${textMin}-${textMax}px`);
console.log(`viewport counterpart: ${textMin + PADDING}-${textMax + PADDING}px`);
console.log(`line grows past upper bound: threshold ${textMax + PADDING}px`);

console.log("\n--- 2) measurement cards ---");
console.log(`card narrowest ${CARD}px, gap ${CARD_GAP}px`);
console.log("columns  content area needed  viewport threshold");
for (let n = 1; n <= 5; n++) {
  console.log(
    `${String(n).padStart(7)}  ${String(cardThreshold(n)).padStart(19)}  ${String(cardThreshold(n) + PADDING).padStart(18)}`,
  );
}

console.log("\n--- 3) page layout ---");
console.log(`main section narrowest ${MAIN_MIN}px + aside narrowest ${ASIDE_MIN}px + gap ${PAGE_GAP}px`);
console.log(`narrowest content area where two columns fit: ${twoColumnThreshold}px`);
console.log(`viewport threshold (lower bound): ${twoColumnThreshold + PADDING}px`);

console.log("\n--- every derived threshold ---");
const thresholds = [
  { name: "card 2 columns", px: cardThreshold(2) + PADDING, query: false },
  { name: "card 3 columns", px: cardThreshold(3) + PADDING, query: false },
  { name: "text upper bound", px: textMax + PADDING, query: false },
  { name: "page two columns*", px: twoColumnThreshold + PADDING, query: true },
  { name: "card 4 columns", px: cardThreshold(4) + PADDING, query: false },
  { name: "card 5 columns", px: cardThreshold(5) + PADDING, query: false },
];
thresholds.sort((a, b) => a.px - b.px);
console.log("threshold(px)  component            query needed");
for (const t of thresholds) {
  console.log(
    `${String(t.px).padStart(13)}  ${t.name.padEnd(20)} ${t.query ? "yes" : "no (intrinsic sizing is enough)"}`,
  );
}
const toWrite = thresholds.filter((t) => t.query);
console.log(`\ntotal thresholds: ${thresholds.length}, queries to write: ${toWrite.length} (${toWrite.map((t) => t.px + "px").join(", ")})`);
console.log("* lower bound from the sum of the narrowest sizes; rises with track definition");
```

```
--- 1) text column ---
body text 18px, 1 character ~ 9px
measure 45-75 characters -> column 405-675px
viewport counterpart: 453-723px
line grows past upper bound: threshold 723px

--- 2) measurement cards ---
card narrowest 200px, gap 16px
columns  content area needed  viewport threshold
      1                  200                 248
      2                  416                 464
      3                  632                 680
      4                  848                 896
      5                 1064                1112

--- 3) page layout ---
main section narrowest 480px + aside narrowest 260px + gap 24px
narrowest content area where two columns fit: 764px
viewport threshold (lower bound): 812px

--- every derived threshold ---
threshold(px)  component            query needed
          464  card 2 columns       no (intrinsic sizing is enough)
          680  card 3 columns       no (intrinsic sizing is enough)
          723  text upper bound     no (intrinsic sizing is enough)
          812  page two columns*    yes
          896  card 4 columns       no (intrinsic sizing is enough)
         1112  card 5 columns       no (intrinsic sizing is enough)

total thresholds: 6, queries to write: 1 (812px)
* lower bound from the sum of the narrowest sizes; rises with track definition
```

Three components, three separate sources of measurement.

The text column's size comes from typography. Below 45 characters per line, the eye jumps
lines too often; above 75, it becomes hard to find the start of the next line after a line
break. In body text at an eighteen-unit base size, character width is roughly nine units; the
two bounds come out to 405 and 675 units.

The cards' size comes from the content itself: in a measurement card, the label, value, and
unit must be readable side by side, and this breaks down below 200 units. How many columns fit
follows from there.

The page layout's size is the sum of the two sections' sizes. The narrowest space they need to
sit side by side is the sum of their two narrowest sizes and the gap between them.

The resulting list holds six thresholds, and none of them is named after a device. The
numbers are odd: 464, 680, 723, 812. This oddness is not a flaw, it is the sign that the
measure really does come from content.

#### Thresholds That Are Never Written

Of the six thresholds, only one requires conditional style. The remaining five are met
automatically by the **intrinsic sizing** tools built in earlier lessons.

The card grid is defined with `repeat(auto-fill, minmax(200px, 1fr))`. Column count is computed
from container width, and the 464, 680, 896, and 1112 thresholds are crossed as a natural
result of that computation. Writing these thresholds with a query would mean doing the same
computation by hand a second time.

The text column's upper bound is given with `max-inline-size`; the measure never exceeds 675
units, and the remaining space goes to the margin. This too needs no condition.

What is left is the page layout itself. A `grid-template-areas` declaration is a structure, not
a measure; moving from a two-column layout to a one-column one requires the declaration to be
**replaced by another declaration**. Intrinsic sizing can compute a measure, it cannot choose a
structure.

The criterion follows from this: **if the measure changes, intrinsic sizing; if the structure
changes, conditional style.**

#### Track Definition Shifts the Threshold

The 812 figure above is the sum of the narrowest sizes, and it is a lower bound. The real
threshold depends on how the tracks are defined.

```js
// tracks.mjs — where the threshold comes from: deriving it from track definitions
const PADDING = 48;      // total horizontal padding on both sides of the page
const GAP = 24;           // gap between the two columns
const MAIN_MIN = 480;     // the narrowest size the main section stays meaningful at
const ASIDE_MIN = 260;    // the narrowest size the aside stays meaningful at

// Sizing minmax(min, Nfr) tracks: if a track's share falls below its minimum,
// the track is frozen at its minimum and the remaining space is redistributed.
function tracks(viewport, definition) {
  let remaining = viewport - PADDING - GAP;
  const result = definition.map((t) => ({ ...t, size: null }));
  let changed = true;
  while (changed) {
    changed = false;
    const open = result.filter((t) => t.size === null);
    const frTotal = open.reduce((s, t) => s + t.fr, 0);
    for (const t of open) {
      const share = (remaining * t.fr) / frTotal;
      if (share < t.min) {
        t.size = t.min;
        remaining -= t.min;
        changed = true;
        break;
      }
    }
    if (!changed) for (const t of open) t.size = (remaining * t.fr) / frTotal;
  }
  return result;
}

const definitions = [
  ["minmax(0, 2fr) minmax(0, 1fr)", [{ name: "main", fr: 2, min: 0 }, { name: "aside", fr: 1, min: 0 }]],
  ["minmax(480px, 2fr) minmax(260px, 1fr)",
    [{ name: "main", fr: 2, min: MAIN_MIN }, { name: "aside", fr: 1, min: ASIDE_MIN }]],
];

for (const [name, definition] of definitions) {
  console.log(`--- ${name} ---`);
  console.log("viewport     main col   aside col  main ok      aside ok     total overflow");
  for (const W of [768, 812, 852, 900, 1024]) {
    const s = tracks(W, definition);
    const main = s[0].size, aside = s[1].size;
    const overflow = main + aside + GAP - (W - PADDING);
    console.log(
      `${String(W).padStart(9)}  ${main.toFixed(1).padStart(9)}  ${aside.toFixed(1).padStart(9)}  ` +
        `${(main >= MAIN_MIN ? "yes" : "NO").padStart(11)}  ` +
        `${(aside >= ASIDE_MIN ? "yes" : "NO").padStart(11)}  ${overflow.toFixed(1).padStart(14)}`,
    );
  }
  // the first width at which both tracks meet their narrowest size without overflow
  let threshold = 300;
  while (threshold < 2000) {
    const s = tracks(threshold, definition);
    const overflow = s[0].size + s[1].size + GAP - (threshold - PADDING);
    if (s[0].size >= MAIN_MIN && s[1].size >= ASIDE_MIN && overflow <= 0.001) break;
    threshold += 1;
  }
  console.log(`derived breakpoint: ${threshold}px\n`);
}

console.log("--- comparison with a threshold taken from device names ---");
const PRESET = 768;
for (const [name, definition] of definitions) {
  const s = tracks(PRESET, definition);
  const overflow = s[0].size + s[1].size + GAP - (PRESET - PADDING);
  const narrow = s[0].size < MAIN_MIN || s[1].size < ASIDE_MIN;
  const diagnosis = narrow ? "tracks are below their narrowest size" : `tracks are adequate but ${overflow.toFixed(0)}px overflow`;
  console.log(`${PRESET}px, ${name}`);
  console.log(`  main ${s[0].size.toFixed(1)}  aside ${s[1].size.toFixed(1)}  -> ${diagnosis}`);
}
```

```
--- minmax(0, 2fr) minmax(0, 1fr) ---
viewport     main col   aside col  main ok      aside ok     total overflow
      768      464.0      232.0           NO           NO             0.0
      812      493.3      246.7          yes           NO             0.0
      852      520.0      260.0          yes          yes             0.0
      900      552.0      276.0          yes          yes             0.0
     1024      634.7      317.3          yes          yes             0.0
derived breakpoint: 852px

--- minmax(480px, 2fr) minmax(260px, 1fr) ---
viewport     main col   aside col  main ok      aside ok     total overflow
      768      480.0      260.0          yes          yes            44.0
      812      480.0      260.0          yes          yes             0.0
      852      520.0      260.0          yes          yes             0.0
      900      552.0      276.0          yes          yes             0.0
     1024      634.7      317.3          yes          yes             0.0
derived breakpoint: 812px

--- comparison with a threshold taken from device names ---
768px, minmax(0, 2fr) minmax(0, 1fr)
  main 464.0  aside 232.0  -> tracks are below their narrowest size
768px, minmax(480px, 2fr) minmax(260px, 1fr)
  main 480.0  aside 260.0  -> tracks are adequate but 44px overflow
```

Two definitions, two different thresholds.

In the first definition, the ratio is locked: the aside always takes a third of what remains,
at every width. For the aside to reach 260 units, a third has to equal 260; the threshold rises
to 852 units. The 812 that is the sum of the lower bounds is not enough here, because the main
section is taking more than it needs.

In the second definition, the lower bounds are written into the tracks. The tracks settle into
their narrowest sizes first, and the growing space is split by ratio afterward. The threshold
comes down to the sum of the lower bounds, 812 units. This definition gains the page 40 units
of width and puts the breakpoint at the same place as the computation's first result.

The last block shows two separate ways an externally chosen threshold breaks. At the
768-unit threshold taken from device names, in the first definition the tracks stay below
their narrowest size: the layout applies but the content gets squeezed. In the second
definition the tracks keep their lower bounds, but this time their sum exceeds the container by
44 units: horizontal overflow results.

Both results come from the same cause. When a threshold is not derived from content, the bond
between the measures and the threshold breaks, and the fault shows up either as squeezing or as
overflow.

#### Starting From the Narrow Side

Once the breakpoint is found, one writing decision remains: which layout should the base style
describe?

If the base style describes the **narrow** layout, conditions are added as width grows. The
single-column arrangement is already written; the condition only builds the second column.

If the base style describes the **wide** layout, conditions are added as width shrinks, and
each condition carries declarations that **undo** what the base built: zeroing area names,
bringing the track count down to one, shrinking margins.

Starting from the narrow side eliminates the undoing declarations. There is a second reason
too: content has to be prioritized in a narrow space. Which section comes first, which
information can be carried at all, is decided at the narrow layout; the wide layout is built
on top of these decisions. Working in the reverse direction leaves the narrow layout as
whatever is left over from the wide one.

```css
/* station.css — step 7: narrow base, one threshold */
.station-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  grid-template-areas:
    "masthead"
    "measurements"
    "aside"
    "location";
  gap: var(--spacing-1);
}

.measurement-cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: var(--spacing-0);
}

.station-notes { max-inline-size: 675px; }
```

There is not a single query in the base style. The card grid gains columns as width grows, the
text column stops at its upper bound, the page is single-column. Only at the 812-unit threshold
does the structure change; that declaration will be written in the next lesson.

#### The Viewport Declaration

One precondition is required for all this computation to hold. On small-screen devices, the
browser can assume a wide virtual viewport, to make pages not written for a narrow screen
readable, and shrink the result to fit. In this case, the width CSS sees is not the screen's
real width, and the derived thresholds never trigger at all.

The viewport meta tag introduced in the Web Fundamentals and HTML course turns this assumption
off:

```html
<meta name="viewport" content="width=device-width, initial-scale=1">
```

The declaration sets the width CSS sees equal to the device's own width. Responsive style's
first line is not in the stylesheet, it is in the document's head.

#### Summary

- Responsiveness is three components present together: fluid measures, media that never
  exceeds its container, and conditional style where needed.
- A breakpoint is not a device width; it is a number derived from the narrowest size a
  component can shrink to before losing its meaning. Derived numbers are not round.
- If the measure changes, intrinsic sizing is enough; if the structure changes, conditional
  style is needed. On the station page, only one of six thresholds turns into a query.
- Track definition shifts the threshold: 852 units with a locked-ratio definition, 812 units
  with a definition whose lower bounds are written in.
- When the threshold is chosen externally, the fault appears in two forms — tracks fall below
  their narrowest size, or their sum exceeds the container and produces overflow.
- When the base style describes the narrow layout, conditions add; starting from the wide
  layout means every condition carries undoing declarations.

#### Next Step

The derived threshold is in hand: 812 units. What remains is how this number gets written in
the stylesheet. A condition is not built with width alone; style can also branch on height, the
screen's orientation, or whether the output is going to paper or to a screen. The next lesson
takes up the writing of conditional style: which features a condition can look at, how two
conditions are combined, and which rule wins at the width sitting at the edge of two ranges.

### Media Queries

Source: https://academia.sh/en/courses/layout-and-responsive-design/media-queries

A media query's components, where its rules sit in the cascade, the overlap and gap at the edge of two ranges, the relation between query units and the user's font size, and feature queries.

The previous lesson gave the derived threshold: 812 units. What remains is how this number
gets written in the stylesheet.

The tool of conditional style is the **media query**. A query asks a question; if the answer is
true, the rules inside it are taken into account. This lesson takes up what a query can look
at, where its rules sit in the cascade, and what happens at the point where two ranges meet.

#### The Query's Components

A media query is built from three parts.

**Media type** says which medium the output is going to: `screen` for a display, `print` for
printing or a print preview, `all` for both. If not written, `all` is assumed.

**Media feature** tests a measurable property of the medium and is written in parentheses. The
most common are `width` and `height`; alongside these are `orientation` for the screen's
orientation, `aspect-ratio` for the ratio of width to height, `resolution` for pixel density. A
separate group tests not screen size but input capability: `pointer` reports the precision of
the pointing device, `hover` reports whether a hover behavior exists.

**Logical operators.** `and` requires two conditions together, a comma means "or" — if one of
the comma-separated branches is true, the query is true. `not` inverts a query.

Feature testing has two notations. The older one uses `min-` and `max-` prefixes and produces a
**closed range**: `(min-width: 812px)` means "width equal to or greater than 812." The newer
one writes comparison operators directly: `(width >= 812px)`. This second notation can also
build an open range and combine two bounds in a single condition.

#### Evaluation and the Cascade

A query's result and a rule's application are two separate steps. If the query is true, the
rules inside it **join** the cascade; which declaration wins is decided by the cascade.

A commonly made assumption is wrong here: a media query **adds no specificity**. The
`.station-layout` selector inside the query and the `.station-layout` selector outside it have
the same specificity; when the two conflict, whichever is written later wins.

```js
// query.mjs — media query evaluator and cascade result
// Supported syntaxes: (min-width: Npx), (max-width: Npx), (width >= Npx),
// (Npx <= width < Mpx), (orientation: portrait|landscape), and, comma, not.

function featureValue(name, env) {
  if (name === "width") return env.width;
  if (name === "height") return env.height;
  if (name === "orientation") return env.width >= env.height ? "landscape" : "portrait";
  throw new Error(`unknown feature: ${name}`);
}

function feature(text, env) {
  const s = text.trim().replace(/^\(/, "").replace(/\)$/, "").trim();

  let m = s.match(/^(\d+(?:\.\d+)?)px\s*(<=|<)\s*([a-z-]+)\s*(<=|<)\s*(\d+(?:\.\d+)?)px$/);
  if (m) {
    const v = featureValue(m[3], env);
    const lowOk = m[2] === "<=" ? Number(m[1]) <= v : Number(m[1]) < v;
    const highOk = m[4] === "<=" ? v <= Number(m[5]) : v < Number(m[5]);
    return lowOk && highOk;
  }

  m = s.match(/^([a-z-]+)\s*(>=|<=|>|<)\s*(\d+(?:\.\d+)?)px$/);
  if (m) {
    const v = featureValue(m[1], env);
    const n = Number(m[3]);
    return m[2] === ">=" ? v >= n : m[2] === "<=" ? v <= n : m[2] === ">" ? v > n : v < n;
  }

  m = s.match(/^(min-|max-)?([a-z-]+)\s*:\s*(.+)$/);
  if (m) {
    const v = featureValue(m[2], env);
    const target = m[3].endsWith("px") ? Number(m[3].slice(0, -2)) : m[3];
    if (m[1] === "min-") return v >= target;
    if (m[1] === "max-") return v <= target;
    return v === target;
  }

  throw new Error(`unresolved condition: ${s}`);
}

function queryMatches(query, env) {
  return query.split(",").some((branch) => {
    const parts = branch.trim().split(/\s+and\s+/);
    const negated = parts[0].startsWith("not ");
    parts[0] = parts[0].replace(/^not\s+/, "");
    const result = parts.every((p) => feature(p, env));
    return negated ? !result : result;
  });
}

const environments = [
  { name: "narrow portrait", width: 380, height: 780 },
  { name: "medium portrait", width: 720, height: 1024 },
  { name: "at threshold", width: 812, height: 1000 },
  { name: "wide landscape", width: 1280, height: 800 },
  { name: "short landscape", width: 900, height: 420 },
];

const rules = [
  { query: null, value: "single column" },
  { query: "(min-width: 812px)", value: "two columns" },
  { query: "(min-width: 1200px)", value: "two columns + wide margin" },
  { query: "(orientation: landscape) and (max-height: 480px)", value: "compact header" },
];

console.log("--- rules in source order, the last match wins ---");
rules.forEach((r, i) => console.log(`${i + 1}. ${r.query ?? "(base, no query)"} -> ${r.value}`));

console.log("\nenvironment      width     height     matched rules     applied");
for (const e of environments) {
  const matched = rules
    .map((r, i) => (r.query === null || queryMatches(r.query, e) ? i + 1 : null))
    .filter((x) => x !== null);
  const winner = rules[matched[matched.length - 1] - 1].value;
  console.log(
    `${e.name.padEnd(16)} ${String(e.width).padStart(8)}  ${String(e.height).padStart(9)}  ` +
      `${matched.join(",").padStart(16)}  ${winner}`,
  );
}

console.log("\n--- the width at the edge of two ranges ---");
const pairs = [
  ["(max-width: 812px)", "(min-width: 812px)"],
  ["(max-width: 811.98px)", "(min-width: 812px)"],
  ["(width < 812px)", "(width >= 812px)"],
];
for (const [a, b] of pairs) {
  console.log(`\n${a}  |  ${b}`);
  console.log("width     first  second  state");
  for (const w of [811, 811.99, 812, 813]) {
    const e = { width: w, height: 900 };
    const x = queryMatches(a, e);
    const y = queryMatches(b, e);
    const state = x && y ? "BOTH matched" : x || y ? "one rule" : "NEITHER matched";
    console.log(
      `${String(w).padStart(8)}  ${String(x).padStart(5)}  ${String(y).padStart(6)}  ${state}`,
    );
  }
}
```

```
--- rules in source order, the last match wins ---
1. (base, no query) -> single column
2. (min-width: 812px) -> two columns
3. (min-width: 1200px) -> two columns + wide margin
4. (orientation: landscape) and (max-height: 480px) -> compact header

environment      width     height     matched rules     applied
narrow portrait       380        780                 1  single column
medium portrait       720       1024                 1  single column
at threshold          812       1000               1,2  two columns
wide landscape       1280        800             1,2,3  two columns + wide margin
short landscape       900        420             1,2,4  compact header

--- the width at the edge of two ranges ---

(max-width: 812px)  |  (min-width: 812px)
width     first  second  state
     811   true   false  one rule
  811.99   true   false  one rule
     812   true    true  BOTH matched
     813  false    true  one rule

(max-width: 811.98px)  |  (min-width: 812px)
width     first  second  state
     811   true   false  one rule
  811.99  false   false  NEITHER matched
     812  false    true  one rule
     813  false    true  one rule

(width < 812px)  |  (width >= 812px)
width     first  second  state
     811   true   false  one rule
  811.99   true   false  one rule
     812  false    true  one rule
     813  false    true  one rule
```

The first table shows matches accumulating. At a 1280-unit environment, three rules are true at
once; the value applied is the **last** of the true ones. This means the order rules are
written in carries meaning: if queries targeting the same declaration are not ordered from the
narrow range to the wide one, the wide range's rule gets crushed by the narrow range's.

The fourth rule works on a different axis. At a 900-unit-wide, short environment, both the
width condition and the orientation-and-height condition are true; the last one wins. Width is
not the only measure: in an environment held horizontally with limited vertical space, dropping
the header to a compact size is a decision that has nothing to do with width.

#### The Edge of Ranges

The second section tests the point where two neighboring ranges meet, with three notations.

The first pair is the classic mistake. Both `max-width: 812px` and `min-width: 812px` are true
at 812 units, because both prefixes produce a **closed** bound. At that exact width, two rules
apply at once, and the result is left to source order. The mistake can be invisible — the
result may already be the wanted rule — but if the declarations differ, a mixture of the two
applies.

The second pair tries the commonly suggested fix: pulling the upper bound down slightly. The
overlap goes away, but a **gap** takes its place. At a width of 811.99 units, no rule matches
at all. Fractional widths are not a theoretical possibility; zooming and scaling produce
fractional CSS pixel values.

The third pair genuinely splits the range with comparison notation. `(width < 812px)` and
`(width >= 812px)` exclude each other and together cover the entire number line. Neither
overlap nor gap remains.

The most solid approach is to never write the second range at all. If the base style describes
the narrow layout, only `(min-width: 812px)` is written; no rule is needed for the lower range,
and the edge-point problem never arises.

#### The Unit in the Query

Wherever a length is written, unit choice is a decision. In a media query, this decision has an
unusual consequence: `em` in a query resolves not against the font size written on the root
element, but against the **user's default font size**.

```js
// unit.mjs — the relation between the media query length unit and the user's font size
// em in a query resolves not against the root element's font-size, but against the
// user's default font size.

// content requirements measured at a 16px default
const MAIN = 480, ASIDE = 260, GAP = 24, PADDING = 48;
const BASE = 16;
const NEEDED_16 = MAIN + ASIDE + GAP + PADDING;

const emThreshold = NEEDED_16 / BASE;   // the same threshold's em counterpart
const pxThreshold = NEEDED_16;

console.log(`width needed for two columns at a 16px default: ${NEEDED_16}px`);
console.log(`if written in px: (min-width: ${pxThreshold}px)`);
console.log(`if written in em: (min-width: ${emThreshold}em)\n`);

console.log("user       needed    px query    em query   px deviation em deviation");
console.log("default    width     triggers at triggers at");
for (const d of [12, 16, 20, 24, 32]) {
  const needed = (NEEDED_16 * d) / BASE;   // scales because every measure is rem-based
  const pxTrigger = pxThreshold;
  const emTrigger = emThreshold * d;
  console.log(
    `${String(d + "px").padStart(10)}  ${needed.toFixed(0).padStart(8)}  ` +
      `${pxTrigger.toFixed(0).padStart(10)}  ${emTrigger.toFixed(0).padStart(10)}  ` +
      `${(pxTrigger - needed).toFixed(0).padStart(12)}  ${(emTrigger - needed).toFixed(0).padStart(12)}`,
  );
}

console.log("\n--- what happens at a 20px default, at 812px width ---");
const d = 20;
const scale = d / BASE;
const area = 812 - PADDING * scale - GAP * scale;
const main = (area * 2) / 3, aside = area / 3;
console.log(`main section: ${main.toFixed(1)}px  (needs ${(MAIN * scale).toFixed(1)}px)`);
console.log(`aside: ${aside.toFixed(1)}px  (needs ${(ASIDE * scale).toFixed(1)}px)`);
console.log(`the px query opens two columns at this width; the em query would have waited until ${(emThreshold * d).toFixed(0)}px`);
```

```
width needed for two columns at a 16px default: 812px
if written in px: (min-width: 812px)
if written in em: (min-width: 50.75em)

user       needed    px query    em query   px deviation em deviation
default    width     triggers at triggers at
      12px       609         812         609           203             0
      16px       812         812         812             0             0
      20px      1015         812        1015          -203             0
      24px      1218         812        1218          -406             0
      32px      1624         812        1624          -812             0

--- what happens at a 20px default, at 812px width ---
main section: 481.3px  (needs 600.0px)
aside: 240.7px  (needs 325.0px)
the px query opens two columns at this width; the em query would have waited until 1015px
```

The right two columns make the comparison on their own. When a user changes their default font
size, every measure that is tied to the base unit grows together, and so does the width the two
columns need. A query written in pixels cannot see this change and leaves the threshold in
place: at a 20-unit default, the layout switches to two columns 203 units short of what is
needed. A query written in the base unit shifts along with the need, and the deviation stays
zero in every row.

The last block gives the deviation's concrete counterpart: when the threshold triggers early,
the main section drops to 481, the aside to 241 units — while the same content at the enlarged
text size needs 600 and 325. Enlarging text is an adjustment made to keep a page readable; if
layout does not scale along with that adjustment, the adjustment itself breaks the result.

The rule is this: length in a media query is written in the base unit. The same reasoning
applies to height and aspect-ratio tests too.

#### Media Types and Feature Queries

Two tests other than width show that media queries are not only about screen size.

`@media print` rules apply to print output. On a printed measurement page, there is no place
for a navigation bar, a filter field, or link decorations; in exchange, link addresses need to
be written into the text and page breaks need to be controlled. The `break-inside` declaration
introduced in the multi-column layout lesson works here too.

A second structure, separate from media queries but written the same way, is the **feature
query**. `@supports` tests whether a declaration is recognized — not the medium, but whether the
style language resolves it. The question left open in the Grid and Flexbox Choice lesson is
answered from here: where the `subgrid` value is recognized, the inner grid inherits the
container's lines; where it is not, it defines its own tracks.

```css
/* station.css — step 8: derived threshold and printing */
@media (min-width: 50.75em) {
  .station-layout {
    grid-template-columns: minmax(480px, 2fr) minmax(260px, 1fr);
    grid-template-areas:
      "masthead     masthead"
      "measurements aside"
      "location     aside";
  }
}

@supports (grid-template-columns: subgrid) {
  .measurement-card {
    display: grid;
    grid-template-rows: subgrid;
    grid-row: span 3;
  }
}

@media print {
  .nav,
  .measurement-filter { display: none; }

  .station-layout {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas: "masthead" "measurements" "location" "aside";
  }

  .measurement-table tr { break-inside: avoid; }
}
```

Three blocks, three separate questions. The first tests the environment's width and changes
only structure; its measures come from the intrinsic sizing in the base style. The second tests
whether a declaration is recognized, and where it is, binds the card rows to the outer grid's
lines. The third tests where the output is going.

What the three share is that none of them undoes what the base style wrote. Each block adds a
new declaration within its own condition.

#### Summary

- A media query is built from a media type, media features written in parentheses, and the
  `and`, comma, and `not` operators; an unwritten type is taken as `all`.
- A query adds no specificity; its rules compete at the same specificity as those outside it,
  and source order resolves conflicts.
- The `min-` and `max-` prefixes produce a closed bound: two queries written at the same number
  both match at that point; nudging the bound with a fraction turns the overlap into a gap.
- Comparison notation splits a range with neither overlap nor gap; writing a single
  one-directional query removes the problem entirely.
- The base unit in a query resolves against the user's default font size and shifts the
  threshold together with content; a threshold written in pixels cannot see this adjustment.
- `@media print` tests the output's medium, `@supports` tests whether the style language
  recognizes a declaration; both add declarations within their condition without undoing the
  base style.

#### Next Step

Every query in this lesson looked at one thing: the viewport itself. But what determines how a
measurement card should look is not the screen's width, it is the width of the place the card
is **placed inside**. The same card can sit in a narrow side column on a wide screen; because
the viewport is wide, the card opens its wide layout and gets squeezed. The next lesson changes
the subject of the question: how does a component query the size of the container it is in?

### Container Queries

Source: https://academia.sh/en/courses/layout-and-responsive-design/container-queries

Moving the query's subject from the viewport to the component's container, declaring a container and the containment it requires, container units, and the same component making the right decision in two different places.

Every query in the previous lesson looked at one thing: the viewport itself. For the page's
layout, that is the right subject; it really is the screen's width that decides how many
columns the page has.

It is not the right subject for a measurement card. What determines how the card should look
is not the screen's width, it is the width of the container it is **placed inside**. This
lesson changes the subject of the query and measures with computation how much that change
matters.

#### The Same Card, Two Containers

On the station page, the measurement card appears in two places: in the card grid in the main
section, and in the summary list in the aside. The two containers have different widths, and
those widths cannot be read directly from the viewport's width.

The card has its own threshold: below 280 units, the label, value, and unit stack; above it
they sit side by side. The program below gives this decision with two separate query types.

```js
// container.mjs — the size the same card gets in two different containers, decided two ways
const PADDING = 48;
const PAGE_GAP = 24;
const MAIN_MIN = 480, ASIDE_MIN = 260;
const CARD = 200, CARD_GAP = 16;

// page layout: minmax(480px, 2fr) minmax(260px, 1fr), single-column threshold 812px
function sections(viewport) {
  if (viewport < 812) return { main: viewport - PADDING, aside: viewport - PADDING };
  let remaining = viewport - PADDING - PAGE_GAP;
  let main = (remaining * 2) / 3, aside = remaining / 3;
  if (aside < ASIDE_MIN) { aside = ASIDE_MIN; main = remaining - aside; }
  if (main < MAIN_MIN) { main = MAIN_MIN; aside = remaining - main; }
  return { main, aside };
}

// card grid: repeat(auto-fill, minmax(200px, 1fr))
function cardSize(containerWidth) {
  const n = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD + CARD_GAP)));
  return { columns: n, size: (containerWidth - CARD_GAP * (n - 1)) / n };
}

// the card's own threshold: below 280px labels stack, above it they sit side by side
const CARD_THRESHOLD = 280;

console.log("size at which the card switches to a side-by-side layout: 280px");
console.log("media query decision: (min-width: 812px) -> every card side-by-side");
console.log("container query decision: (min-width: 280px) -> every card by its own container\n");

console.log("viewport  main    aside   main    aside   media query              container query");
console.log("width             card    card                main / aside             main / aside");
const mismatches = [];
for (const W of [400, 720, 812, 900, 1024, 1280, 1600]) {
  const { main, aside } = sections(W);
  const mainCard = cardSize(main), asideCard = cardSize(aside);
  const media = W >= 812 ? "side-by-side" : "stacked";
  const containerMain = mainCard.size >= CARD_THRESHOLD ? "side-by-side" : "stacked";
  const containerAside = asideCard.size >= CARD_THRESHOLD ? "side-by-side" : "stacked";
  if (media !== containerMain) mismatches.push(`${W}px main section`);
  if (media !== containerAside) mismatches.push(`${W}px aside`);
  console.log(
    `${String(W).padStart(6)}  ${main.toFixed(0).padStart(6)}  ${aside.toFixed(0).padStart(6)}  ` +
      `${mainCard.size.toFixed(0).padStart(6)}  ${asideCard.size.toFixed(0).padStart(6)}  ` +
      `${(media + " / " + media).padEnd(25)}  ${containerMain} / ${containerAside}`,
  );
}

console.log(`\nnumber of cases where the media query decides wrong: ${mismatches.length}`);
for (const m of mismatches) console.log(`  ${m}`);
```

```
size at which the card switches to a side-by-side layout: 280px
media query decision: (min-width: 812px) -> every card side-by-side
container query decision: (min-width: 280px) -> every card by its own container

viewport  main    aside   main    aside   media query              container query
width             card    card                main / aside             main / aside
   400     352     352     352     352  stacked / stacked          side-by-side / side-by-side
   720     672     672     213     213  stacked / stacked          stacked / stacked
   812     480     260     232     260  side-by-side / side-by-side  stacked / stacked
   900     552     276     268     276  side-by-side / side-by-side  stacked / stacked
  1024     635     317     201     317  side-by-side / side-by-side  stacked / side-by-side
  1280     805     403     258     403  side-by-side / side-by-side  stacked / side-by-side
  1600    1019     509     243     247  side-by-side / side-by-side  stacked / stacked

number of cases where the media query decides wrong: 10
  400px main section
  400px aside
  812px main section
  812px aside
  900px main section
  900px aside
  1024px main section
  1280px main section
  1600px main section
  1600px aside
```

Ten of fourteen decisions are wrong. This ratio alone shows that a media query is a poor tool
at the component level, but what is actually interesting is the **direction** of the errors.

The first row gives the opposite of what is expected. The viewport is 400 units — a narrow
screen. Since the page is single-column, the card grid produces a single column, and the card
is 352 units wide. The card is **wide** on a narrow screen. The media query, because the
screen is narrow, stacks the card anyway.

The last row is the other end of the same misjudgment. The viewport is 1600 units; the main
section grows to 1019 units and the card grid produces four columns. Each card is 243 units:
below the card's own threshold. The card is **narrow** on a wide screen.

The rows in between show a third case: at 1024 and 1280 units, the cards in the main section
are narrow while the card in the aside is wide. A single viewport size contains two containers
that need two different answers at the same time. A media query can only give one answer, and
at least one of the two ends up wrong.

The result is a rule: **the page's structure is asked of the viewport, a component's shape is
asked of its container.**

#### Declaring a Container

A **container query** consists of two steps. First an element is declared a **query
container**, then elements descending from that container query the container's size.

The `container-type` declaration declares the container and takes three values. `inline-size`
makes only the container's size on the inline axis queryable. `size` opens both axes. `normal`
opens no size query; it allows only style queries.

The declaration has a cost, and that cost is a logical necessity. If the container's size is
going to be queried, that size must **not be affected** by the query's result; otherwise a loop
forms: the container narrows, the query changes, the content changes, the container widens, the
query changes again. To prevent this, **containment** is applied to the container:
`inline-size` makes the container's size independent of its content on the inline axis, `size`
does so on both axes.

The practical result is this: a container given `container-type: size` must be given height
from outside, or its height is treated as zero. `inline-size` does not create this problem,
because inline size usually already comes from the container's own container. This is why
`inline-size` is the common value.

The second declaration, `container-name`, gives the container a name. A nameless query targets
the **nearest** query container; a named query looks for the nearest container carrying that
name. When containers are nested, the name removes the ambiguity of which one is being queried.
The `container` shorthand writes both together.

A boundary appears here, and it is surprising when forgotten: **an element cannot query
itself.** Declarations written on the query container cannot change based on the container's
own size; only its descendants can. This is why a component's outermost element is declared the
container, and the elements being styled sit inside it.

#### Container Units

When the query's subject changes, length units need a counterpart too. **Container query
units** correspond to one percent of the container's size: `cqi` on the inline axis, `cqb` on
the block axis, `cqw` and `cqh` on the physical axes, `cqmin` and `cqmax` the smaller and larger
of the two.

```js
// cq-unit.mjs — comparing the container unit (cqi) against the viewport unit (vw)
const PADDING = 48, PAGE_GAP = 24;
const MAIN_MIN = 480, ASIDE_MIN = 260;
const CARD = 200, CARD_GAP = 16, ROOT = 16;

function sections(viewport) {
  if (viewport < 812) return { main: viewport - PADDING, aside: viewport - PADDING };
  let remaining = viewport - PADDING - PAGE_GAP;
  let main = (remaining * 2) / 3, aside = remaining / 3;
  if (aside < ASIDE_MIN) { aside = ASIDE_MIN; main = remaining - aside; }
  return { main, aside };
}

function cardSize(container) {
  const n = Math.max(1, Math.floor((container + CARD_GAP) / (CARD + CARD_GAP)));
  return (container - CARD_GAP * (n - 1)) / n;
}

const clamp = (lo, target, hi) => Math.max(lo, Math.min(target, hi));
const MIN = 0.875 * ROOT;   // 14px
const MAX = 1.25 * ROOT;    // 20px
const BASE = 0.75 * ROOT;   // 12px

// cqi: one percent of the container's inline size
const cqiValue = (container) => clamp(MIN, BASE + (1.5 * container) / 100, MAX);
// vw: one percent of the viewport
const vwValue = (viewport) => clamp(MIN, BASE + (1.5 * viewport) / 100, MAX);

console.log("font-size: clamp(0.875rem, 0.75rem + 1.5cqi, 1.25rem)   (root 16px)");
console.log("comparison: if the same expression were written with 1.5vw\n");
console.log("viewport main card aside card cqi: main cqi: aside vw: main vw: aside does vw distinguish");
for (const W of [400, 720, 900, 1024, 1280, 1600]) {
  const { main, aside } = sections(W);
  const mainCard = cardSize(main), asideCard = cardSize(aside);
  const cqiMain = cqiValue(mainCard), cqiAside = cqiValue(asideCard);
  const vwMain = vwValue(W), vwAside = vwValue(W);
  console.log(
    `${String(W).padStart(8)}  ${mainCard.toFixed(0).padStart(9)}  ${asideCard.toFixed(0).padStart(10)}  ` +
      `${cqiMain.toFixed(2).padStart(11)}  ${cqiAside.toFixed(2).padStart(11)}  ` +
      `${vwMain.toFixed(2).padStart(8)}  ${vwAside.toFixed(2).padStart(9)}  ${(vwMain === vwAside ? "no" : "yes").padStart(18)}`,
  );
}

console.log("\n--- the same card alone: the cqi value as the container's size changes ---");
console.log("container  1cqi   applied font size    which zone");
for (const container of [180, 240, 300, 380, 460, 560]) {
  const oneCqi = container / 100;
  const v = cqiValue(container);
  const zone = v === MIN ? "lower bound" : v === MAX ? "upper bound" : "preferred";
  console.log(
    `${String(container).padStart(9)}  ${oneCqi.toFixed(2).padStart(4)}  ${v.toFixed(2).padStart(19)}  ${zone.padStart(13)}`,
  );
}
```

```
font-size: clamp(0.875rem, 0.75rem + 1.5cqi, 1.25rem)   (root 16px)
comparison: if the same expression were written with 1.5vw

viewport main card aside card cqi: main cqi: aside vw: main vw: aside does vw distinguish
     400        352         352        17.28        17.28     18.00      18.00                  no
     720        213         213        15.20        15.20     20.00      20.00                  no
     900        268         276        16.02        16.14     20.00      20.00                  no
    1024        201         317        15.01        16.76     20.00      20.00                  no
    1280        258         403        15.87        18.04     20.00      20.00                  no
    1600        243         247        15.64        15.70     20.00      20.00                  no

--- the same card alone: the cqi value as the container's size changes ---
container  1cqi   applied font size    which zone
      180  1.80                14.70      preferred
      240  2.40                15.60      preferred
      300  3.00                16.50      preferred
      380  3.80                17.70      preferred
      460  4.60                18.90      preferred
      560  5.60                20.00    upper bound
```

The last column shows why the viewport unit falls short for component scaling: `vw` never
distinguishes the two containers on any row. On the same page, at the same time, it gives the
same value to two containers of different widths — and past 720 units it is already pinned to
the upper bound, so the value never changes at all after that.

The container unit, on the other hand, tracks the container's own size. At a 1024-unit
viewport, the card in the main section gets 15.01 units of text, the card in the aside gets
16.76; both are proportional to their own width.

The second table isolates a single container's behavior. As the container widens, `1cqi` grows
and the preferred value rises; at 560 units it hits the upper bound and gets clamped. The
three-zone behavior from the CSS Functions lesson applies here too, only the input has changed.

One detail: if container units are used on an element that is not inside any query container,
they resolve against the viewport. Writing the unit does not declare the container;
`container-type` must be written separately.

#### The Component Carries Its Own Threshold

Container queries' real gain is not in the computation, it is in the stylesheet's structure.

Written with a media query, a component's threshold has to be computed separately for
**every place** the component appears: one threshold for the card in the main section, another
for the card in the aside, both at once when the page layout changes. When the component moves
to a new place, the thresholds have to be recomputed.

Written with a container query, the threshold belongs to the component itself and lives in one
place. Wherever the component is placed, it makes the right decision; when the page layout
changes, the component's style code does not change.

```css
/* station.css — step 9: the card queries its own container */
.measurement-card-container {
  container-type: inline-size;
  container-name: card;
}

.measurement-card {
  display: grid;
  gap: 0.25rem;
  font-size: clamp(0.875rem, 0.75rem + 1.5cqi, 1.25rem);
}

@container card (min-width: 280px) {
  .measurement-card {
    grid-template-columns: 1fr auto;
    align-items: baseline;
  }

  .measurement-card .description { grid-column: 1 / -1; }
}

@container card (min-width: 420px) {
  .measurement-card .trend { display: block; }
}
```

The container being a separate element is a necessity: the card cannot query itself. The
container carries only `container-type` and `container-name`; every visual declaration is on
the `.measurement-card` element inside it.

The two thresholds correspond to two separate questions. At 280 units, the label and value move
side by side; at 420 units, the trend indicator becomes visible. Both are read not from the
viewport but from the container's width, and give two different results in two different
containers.

There is a third form of the query as well. Instead of a size, the value of a custom property
can be queried; the `@container style(--state: warning)` notation branches style based on a
property's value defined on the container. Its difference from a size query is that it looks
not at the container's size but at its style state, and it requires no containment.

#### Summary

- The page's structure is asked of the viewport, a component's shape of its container; the two
  questions cannot be answered with the same measure.
- In the station page's card layout, a media query gives the wrong result in ten of fourteen
  decisions; a card can be wide on a narrow screen and narrow on a wide screen.
- `container-type` makes an element a query container and applies containment in exchange;
  since `size` requires height to be given from outside, `inline-size` is less restrictive.
- An element cannot query itself; the container is declared as a separate element outside the
  component being styled.
- Container units correspond to one percent of the container's size and distinguish two
  containers on the same page; the viewport unit cannot.
- When the threshold is written on the component itself, the style code does not change when
  the component moves; a threshold written with a media query has to be recomputed for every
  placement.

#### Next Step

In this lesson, font size was tied to a container's width once and placed inside a `clamp()`
expression. The expression's three numbers — the lower bound, the preferred value, the upper
bound — looked chosen, but how they were chosen was never said. The next lesson makes these
three numbers computable: given two anchor points, how is the linear transition between them
built, why does the ratio between scale steps narrow on a narrow screen, and what does it mean
for font size to respect the user's own setting?

### Responsive Typography

Source: https://academia.sh/en/courses/layout-and-responsive-design/responsive-typography

Deriving a fluid font-size expression from two anchor points, the scale ratio narrowing on a narrow screen, line-length control, and how text responds to zoom and to the user's setting.

The previous lesson placed card text inside a `clamp()` expression and gave three numbers: a
lower bound, a preferred value, an upper bound. How the numbers were chosen was never said.

This lesson turns that choice into computation. Font size changing with scale is not a visual
preference, it is the result of a readability constraint; once the constraint is turned into a
number, the expression follows on its own.

#### Two Anchor Points, One Line

In **fluid typography**, the input is two decisions: how large the text should be at the
**narrowest** layout, and how large at the **widest**. Two points determine a line; the
`clamp()` expression's middle term is that line's equation.

The line's slope is written with a viewport unit, its intercept with the base unit. The sum of
the two gives the text's linear growth as the viewport grows.

```js
// fluid.mjs — deriving a fluid font-size expression from two anchor points
const ROOT = 16;

// two anchor points: (viewport, desired font size)
function derive(v1, s1, v2, s2) {
  const slope = (s2 - s1) / (v2 - v1);      // px / px
  const vw = slope * 100;                   // 1vw = one percent of the viewport
  const constPx = s1 - slope * v1;          // the expression's constant term
  return { vw, constRem: constPx / ROOT, constPx };
}

const body = derive(360, 16, 1280, 20);
const heading = derive(360, 24, 1280, 40);

const format = (t, min, max) =>
  `clamp(${(min / ROOT).toFixed(3)}rem, ${t.constRem.toFixed(4)}rem + ${t.vw.toFixed(4)}vw, ${(max / ROOT).toFixed(3)}rem)`;

console.log("anchor points: 360px -> body 16px / heading 24px,  1280px -> body 20px / heading 40px");
console.log("body   :", format(body, 16, 20));
console.log("heading:", format(heading, 24, 40));

const clamp = (lo, target, hi) => Math.max(lo, Math.min(target, hi));
const resolve = (t, min, max, viewport) => clamp(min, t.constPx + (t.vw * viewport) / 100, max);

console.log("\nviewport     body   heading  heading/body  body zone");
for (const V of [320, 360, 500, 700, 900, 1280, 1600]) {
  const g = resolve(body, 16, 20, V);
  const b = resolve(heading, 24, 40, V);
  const zone = g === 16 ? "lower bound" : g === 20 ? "upper bound" : "preferred";
  console.log(
    `${String(V).padStart(11)}  ${g.toFixed(2).padStart(5)}  ${b.toFixed(2).padStart(6)}  ` +
      `${(b / g).toFixed(3).padStart(12)}  ${zone.padStart(13)}`,
  );
}

// line-length check: text column bounded at 675px, character width is half the font size
console.log("\n--- line length check (column upper bound 675px, side padding 48px) ---");
console.log("viewport     column body   characters  in 45-75 range");
for (const V of [320, 360, 500, 700, 900, 1280, 1600]) {
  const column = Math.min(675, V - 48);
  const g = resolve(body, 16, 20, V);
  const characters = column / (g * 0.5);
  console.log(
    `${String(V).padStart(11)}  ${column.toFixed(0).padStart(5)}  ${g.toFixed(2).padStart(5)}  ` +
      `${characters.toFixed(1).padStart(8)}  ${(characters >= 45 && characters <= 75 ? "yes" : "NO").padStart(19)}`,
  );
}
```

```
anchor points: 360px -> body 16px / heading 24px,  1280px -> body 20px / heading 40px
body   : clamp(1.000rem, 0.9022rem + 0.4348vw, 1.250rem)
heading: clamp(1.500rem, 1.1087rem + 1.7391vw, 2.500rem)

viewport     body   heading  heading/body  body zone
        320  16.00   24.00         1.500    lower bound
        360  16.00   24.00         1.500    lower bound
        500  16.61   26.43         1.592      preferred
        700  17.48   29.91         1.711      preferred
        900  18.35   33.39         1.820      preferred
       1280  20.00   40.00         2.000    upper bound
       1600  20.00   40.00         2.000    upper bound

--- line length check (column upper bound 675px, side padding 48px) ---
viewport     column body   characters  in 45-75 range
        320    272  16.00      34.0                   NO
        360    312  16.00      39.0                   NO
        500    452  16.61      54.4                  yes
        700    652  17.48      74.6                  yes
        900    675  18.35      73.6                  yes
       1280    675  20.00      67.5                  yes
       1600    675  20.00      67.5                  yes
```

The two lines of output give two expressions that are hard to write by hand. For body text,
the constant term is 0.9022 base units, the slope 0.4348 viewport units. The numbers being
decimal is expected: a line's coefficients through two anchor points do not come out round.

The expression works in three zones. Below 360 units, the lower bound applies; above 1280, the
upper bound; in between, the line does the work. The values at the anchor points are exactly
the requested numbers — this is the derivation's correctness check.

#### The Scale Narrows on a Narrow Screen

The fourth column measures something separate: the ratio between heading and body is not
fixed.

In the narrow layout the ratio is 1.5; in the wide layout, 2.0. This is the unavoidable result
of the two expressions having different slopes, and it is the wanted result. A heading that is
twice the body text splits a line in two on a narrow screen and produces an obstacle instead of
hierarchy information. The same ratio finds its place on a wide screen.

The **modular scale** introduced in the Visual Presentation with CSS course gives the ratio
between steps as a fixed multiplier. In fluid typography, this multiplier itself changes with
scale: it compresses in the narrow layout, opens up in the wide one. In practice this means
writing a separate `clamp()` expression for each scale step; the steps' expressions end up with
different slopes.

One warning: the scale steps' **order** must be preserved. When two expressions are written
with different slopes, it is possible for a lower step to overtake a higher one at a particular
width. The way to check is the derivation itself — if the values at both ends are ordered and
both expressions are linear, the order in between does not break.

#### Line-Length Control

Font size carries no meaning on its own; what is meaningful is **line length** — that is, how
many characters sit on a line. The second table checks this: character count is found by
dividing column width by half the font size.

At five widths the result is in range, at two it is not. At 320- and 360-unit viewports, the
line is 34 and 39 characters; below the lower bound.

This is not a bug, it is a constraint. On a narrow screen, the only way to reach 45 characters
is to shrink the text, and the text is already at its lower bound. The two constraints cannot
both be met at once; the readable font size is preserved, and line length is accepted as is.

The table's real value is that the constraint is accepted **knowingly**. The same computation also catches
the case where the upper bound is exceeded — at 700 units the line reaches 74.6 characters, right against
the bound. The place where the upper bound actually binds is 900 units, where the column is clamped to
675. Had that bound been chosen as 720 units instead of 675, the same line would have reached 78.5
characters and gone outside the range.

#### Zoom and the User's Setting

A font-size expression's final test is how it responds to the user's wish to enlarge text.

```js
// zoom.mjs — how a font-size expression responds to page zoom and to the user's setting
const DEVICE = 1280;   // the device's physical width (device pixels)

// Zoom ratio Z: 1 CSS pixel becomes Z device pixels,
// the viewport in CSS pixels becomes DEVICE/Z.
function measure(Z, root) {
  const viewportCss = DEVICE / Z;
  const vwOnly = (1.25 * viewportCss) / 100;                    // font-size: 1.25vw
  const remOnly = 1.25 * root;                                   // font-size: 1.25rem
  const preferred = 0.9022 * root + (0.4348 * viewportCss) / 100; // clamp's middle term
  const clamped = Math.max(1 * root, Math.min(preferred, 1.25 * root)); // clamp(1rem, ..., 1.25rem)
  return {
    viewportCss,
    vwCss: vwOnly, vwDevice: vwOnly * Z,
    remCss: remOnly, remDevice: remOnly * Z,
    clampCss: clamped, clampDevice: clamped * Z,
  };
}

console.log(`device width ${DEVICE} device pixels, root font size 16px\n`);
console.log("--- page zoom: text's physical size ---");
console.log("zoom           viewport(css)     1.25vw  1.25rem  clamp  <- device pixels");
for (const Z of [1, 1.25, 1.5, 2]) {
  const o = measure(Z, 16);
  console.log(
    `${(Z * 100 + "%").padStart(13)}  ${o.viewportCss.toFixed(0).padStart(16)}  ` +
      `${o.vwDevice.toFixed(2).padStart(6)}  ${o.remDevice.toFixed(2).padStart(7)}  ${o.clampDevice.toFixed(2).padStart(5)}`,
  );
}
const z1 = measure(1, 16), z2 = measure(2, 16);
console.log("\ngrowth ratio at 200% zoom:");
console.log(`  1.25vw  : ${(z2.vwDevice / z1.vwDevice).toFixed(2)}x`);
console.log(`  1.25rem : ${(z2.remDevice / z1.remDevice).toFixed(2)}x`);
console.log(`  clamp   : ${(z2.clampDevice / z1.clampDevice).toFixed(2)}x`);

console.log("\n--- if the user changes their default font size (no zoom) ---");
console.log("default     1.25vw  1.25rem  clamp  <- css pixels");
for (const root of [16, 20, 24, 32]) {
  const o = measure(1, root);
  console.log(
    `${(root + "px").padStart(10)}  ${o.vwCss.toFixed(2).padStart(6)}  ` +
      `${o.remCss.toFixed(2).padStart(7)}  ${o.clampCss.toFixed(2).padStart(5)}`,
  );
}
const d1 = measure(1, 16), d2 = measure(1, 32);
console.log("\ngrowth ratio when the default is doubled:");
console.log(`  1.25vw  : ${(d2.vwCss / d1.vwCss).toFixed(2)}x`);
console.log(`  1.25rem : ${(d2.remCss / d1.remCss).toFixed(2)}x`);
console.log(`  clamp   : ${(d2.clampCss / d1.clampCss).toFixed(2)}x`);
```

```
device width 1280 device pixels, root font size 16px

--- page zoom: text's physical size ---
zoom           viewport(css)     1.25vw  1.25rem  clamp  <- device pixels
         100%              1280   16.00    20.00  20.00
         125%              1024   16.00    25.00  23.61
         150%               853   16.00    30.00  27.22
         200%               640   16.00    40.00  34.44

growth ratio at 200% zoom:
  1.25vw  : 1.00x
  1.25rem : 2.00x
  clamp   : 1.72x

--- if the user changes their default font size (no zoom) ---
default     1.25vw  1.25rem  clamp  <- css pixels
      16px   16.00    20.00  20.00
      20px   16.00    25.00  23.61
      24px   16.00    30.00  27.22
      32px   16.00    40.00  34.44

growth ratio when the default is doubled:
  1.25vw  : 1.00x
  1.25rem : 2.00x
  clamp   : 1.72x
```

The first table's first column carries the entire result: font size written only with a
viewport unit is **not affected by zoom at all**. The reason is arithmetic. When the zoom
ratio doubles, the viewport in CSS pixels is halved; font size is halved too; since every CSS
pixel is now two device pixels, the result stays unchanged. The user zooms, everything on the
page grows, the text stays where it is.

Size written with the base unit doubles. The mixed expression sits between the two: 1.72
times. The scale ratio is exactly the constant term's share of the total; the heavier the
viewport unit, the weaker the response.

The second table gives the same numbers. This is not a coincidence: when the constant term is
written with the base unit and the variable term with the viewport unit, doubling zoom and
doubling the root font size run through the same result. Both settings pass through the same
expression.

This is where the accessibility criterion is read from. WCAG 1.4.4 requires that text be
enlargeable up to 200 percent without an assistive tool, and without loss of content or
function. A font size written only with a viewport unit does not meet this criterion. The rule
is plain: the preferred value **must have a component written with the base unit**; a viewport
unit is not used alone.

#### Text's Other Measures

Font size is not the only variable.

**Line height** is written unitless: `line-height: 1.5` is a ratio and is multiplied by each
element's own font size. When a length is written instead — `line-height: 24px` — what passes
by inheritance is the computed length; a 32-unit heading stays with a 24-unit line height and
its lines overlap. The ratio also changes with scale: a wider line height in small text, a
narrower one in a large heading, both improve readability.

**Letter spacing** narrows at large sizes. As heading size grows, the visual gap between
letters opens up; a small negative value corrects this. Body text needs no such correction.

**Line-break behavior** is set with two declarations. `text-wrap: balance` equalizes line
lengths in headings and prevents a single word from being left on the last line;
`text-wrap: pretty` fixes a single trailing word at the end of a paragraph in long text. Both
are suggestions, not guarantees.

**Hyphenation** is turned on with `hyphens: auto` and works only if the document's language is
declared — the language declaration built in the Web Fundamentals and HTML course gains a
functional counterpart here. It reduces large gaps at line ends in narrow columns.

```css
/* station.css — step 10: fluid typography scale */
:root {
  --font-body:    clamp(1rem, 0.9022rem + 0.4348vw, 1.25rem);
  --font-heading: clamp(1.5rem, 1.1087rem + 1.7391vw, 2.5rem);
}

body {
  font-size: var(--font-body);
  line-height: 1.6;
}

h1 {
  font-size: var(--font-heading);
  line-height: 1.15;
  letter-spacing: -0.01em;
  text-wrap: balance;
}

.station-notes {
  max-inline-size: 675px;
  hyphens: auto;
  text-wrap: pretty;
}
```

Two custom properties carry two derived expressions. Line height is 1.6 in the body, 1.15 in
the heading; being unitless, both are multiplied by their own font size. The column's upper
bound is written in pixels rather than the base unit — a deliberate choice: the column width
comes from the line-length check, and that check already accounts for font size.

#### Summary

- Fluid font size is derived from two anchor points; the slope is written with a viewport
  unit, the constant term with the base unit, forming the `clamp()` expression's middle term.
- When scale steps are written with different slopes, the heading-to-body ratio narrows in the
  narrow layout and opens up in the wide one; this is the wanted behavior.
- Line length is column width divided by font size, and is kept in the 45–75 character range;
  in very narrow layouts, the lower bound cannot be met and this constraint is accepted
  knowingly.
- Font size written only with a viewport unit is unaffected by zoom, and fails to meet the
  criterion requiring text to be enlargeable up to 200 percent.
- Doubling zoom and doubling the root font size run through the same expression and give the
  same result.
- Line height is written unitless; when a length is written instead, the computed value that
  passes by inheritance causes lines to overlap in large text.

#### Next Step

Text now changes with scale, layout adapts to its container. What remains is the page's
heaviest element: the image. An image can be downloaded at an unnecessarily large file size on
a narrow screen, or look blurry on a wide one; both arise from the same declaration. On top of
that, for some images the problem is not size but content — a wide landscape shot needs to be
cropped to stay meaningful on a narrow screen. The next lesson separates these two questions:
which file does the browser choose, and who directs that choice?

### Responsive Images

Source: https://academia.sh/en/courses/layout-and-responsive-design/responsive-images

Separating resolution switching from art direction, resolving a source size declaration, the candidate selection rule, the effect of source order, and reserved space preventing layout shift.

Text now changes with scale, layout adapts to its container. What remains is the page's
heaviest element: the image in the location section.

An image carries two separate problems at the same time, and mixing them up solves neither.
The first is a **size** problem: the same shot gets downloaded at an unnecessarily large file
size on a narrow screen, or looks blurry on a dense screen. The second is a **content**
problem: a wide landscape shot loses its detail when shrunk on a narrow screen; what is needed
is not shrinking but cropping.

This lesson separates the two problems and shows the tool for each.

#### Resolution Switching

The first problem is called **resolution switching**: copies of the same image are prepared at
different pixel sizes, and the browser decides which one to download.

The decision rests on two pieces of information. The `srcset` attribute gives a **candidate
set**; each candidate is written together with the file's real pixel width. The `sizes`
attribute announces the space the image will occupy on the page — this is called the
**source size**.

Why is the second one needed? The browser starts downloading the image before it resolves the
stylesheet; it cannot know at that moment how much space the image will take on the page.
`sizes` writes this information into the document, independent of the stylesheet.

```js
// srcset.mjs — resolving source size and candidate selection
// sizes: tried in order, the first matching condition's length is the source size.
const SIZES = [
  ["(min-width: 812px)", "33vw"],
  ["(min-width: 500px)", "50vw"],
  [null, "calc(100vw - 48px)"],
];

// srcset: each candidate is declared with its real pixel width (the w descriptor)
const CANDIDATES = [320, 480, 640, 960, 1280, 1920];

function conditionTrue(condition, viewport) {
  if (condition === null) return true;
  const m = condition.match(/^\((min|max)-width:\s*(\d+)px\)$/);
  return m[1] === "min" ? viewport >= Number(m[2]) : viewport <= Number(m[2]);
}

function length(expr, viewport) {
  let m = expr.match(/^(\d+(?:\.\d+)?)vw$/);
  if (m) return (Number(m[1]) * viewport) / 100;
  m = expr.match(/^calc\((\d+(?:\.\d+)?)vw\s*-\s*(\d+(?:\.\d+)?)px\)$/);
  if (m) return (Number(m[1]) * viewport) / 100 - Number(m[2]);
  m = expr.match(/^(\d+(?:\.\d+)?)px$/);
  if (m) return Number(m[1]);
  throw new Error(`unresolved length: ${expr}`);
}

function sourceSize(viewport) {
  for (const [condition, expr] of SIZES) {
    if (conditionTrue(condition, viewport)) return { expr, px: length(expr, viewport) };
  }
  throw new Error("sizes list is empty");
}

// Selection rule: the smallest candidate that meets the needed pixel width.
// If none does, the largest candidate is chosen.
function pick(needed) {
  return CANDIDATES.find((c) => c >= needed) ?? CANDIDATES[CANDIDATES.length - 1];
}

console.log("sizes: " + SIZES.map(([k, i]) => (k ? `${k} ${i}` : i)).join(", "));
console.log("srcset candidates: " + CANDIDATES.map((c) => `${c}w`).join(", "));
console.log("\nviewport  source size expr        source   pixel   needed   picked   extra");
console.log("                                   size     ratio            candidate pixels");
for (const V of [360, 500, 700, 812, 1280, 1600]) {
  for (const dpr of [1, 2, 3]) {
    const { expr, px } = sourceSize(V);
    const needed = px * dpr;
    const picked = pick(needed);
    const extra = picked - needed;
    console.log(
      `${String(V).padStart(6)}  ${expr.padEnd(23)}  ${px.toFixed(0).padStart(6)}  ` +
        `${String(dpr + "x").padStart(6)}  ${needed.toFixed(0).padStart(7)}  ` +
        `${String(picked + "w").padStart(9)}  ${extra.toFixed(0).padStart(6)}`,
    );
  }
}

// pixel count of the candidate widths (assuming a 3:2 aspect ratio)
console.log("\n--- pixel count of the candidate files (3:2 aspect ratio) ---");
console.log("candidate  width x height   pixels     how many times 320w");
for (const c of CANDIDATES) {
  const h = Math.round((c * 2) / 3);
  const pixels = c * h;
  const base = 320 * Math.round((320 * 2) / 3);
  console.log(
    `${String(c + "w").padStart(9)}  ${String(c + " x " + h).padStart(15)}  ` +
      `${String(pixels).padStart(9)}  ${(pixels / base).toFixed(1).padStart(19)}x`,
  );
}

// if sizes is declared wrong: image occupies 33vw in reality but 100vw is declared
console.log("\n--- if sizes is declared wrong (really 33vw, declared 100vw) ---");
console.log("viewport  pixel  correct sizes  wrong sizes  ratio of picked pixels");
console.log("          ratio  picks          picks");
for (const V of [812, 1280, 1600]) {
  for (const dpr of [1, 2]) {
    const correct = pick(sourceSize(V).px * dpr);
    const wrong = pick(V * dpr);
    const h1 = Math.round((correct * 2) / 3), h2 = Math.round((wrong * 2) / 3);
    const ratio = (wrong * h2) / (correct * h1);
    console.log(
      `${String(V).padStart(6)}  ${String(dpr + "x").padStart(5)}  ` +
        `${String(correct + "w").padStart(13)}  ${String(wrong + "w").padStart(11)}  ${ratio.toFixed(1).padStart(19)}x`,
    );
  }
}
```

```
sizes: (min-width: 812px) 33vw, (min-width: 500px) 50vw, calc(100vw - 48px)
srcset candidates: 320w, 480w, 640w, 960w, 1280w, 1920w

viewport  source size expr        source   pixel   needed   picked   extra
                                   size     ratio            candidate pixels
   360  calc(100vw - 48px)          312      1x      312       320w       8
   360  calc(100vw - 48px)          312      2x      624       640w      16
   360  calc(100vw - 48px)          312      3x      936       960w      24
   500  50vw                        250      1x      250       320w      70
   500  50vw                        250      2x      500       640w     140
   500  50vw                        250      3x      750       960w     210
   700  50vw                        350      1x      350       480w     130
   700  50vw                        350      2x      700       960w     260
   700  50vw                        350      3x     1050      1280w     230
   812  33vw                        268      1x      268       320w      52
   812  33vw                        268      2x      536       640w     104
   812  33vw                        268      3x      804       960w     156
  1280  33vw                        422      1x      422       480w      58
  1280  33vw                        422      2x      845       960w     115
  1280  33vw                        422      3x     1267      1280w      13
  1600  33vw                        528      1x      528       640w     112
  1600  33vw                        528      2x     1056      1280w     224
  1600  33vw                        528      3x     1584      1920w     336

--- pixel count of the candidate files (3:2 aspect ratio) ---
candidate  width x height   pixels     how many times 320w
     320w        320 x 213      68160                  1.0x
     480w        480 x 320     153600                  2.3x
     640w        640 x 427     273280                  4.0x
     960w        960 x 640     614400                  9.0x
    1280w       1280 x 853    1091840                 16.0x
    1920w      1920 x 1280    2457600                 36.1x

--- if sizes is declared wrong (really 33vw, declared 100vw) ---
viewport  pixel  correct sizes  wrong sizes  ratio of picked pixels
          ratio  picks          picks
   812     1x           320w         960w                  9.0x
   812     2x           640w        1920w                  9.0x
  1280     1x           480w        1280w                  7.1x
  1280     2x           960w        1920w                  4.0x
  1600     1x           640w        1920w                  9.0x
  1600     2x          1280w        1920w                  2.3x
```

The first table shows the two steps separately. First the `sizes` list is tried left to right,
and the **first** length whose condition is true becomes the source size — 33 viewport units,
that is 268 pixels, at an 812-unit viewport. Then this value is multiplied by the device's
**pixel ratio**: at a double-density screen, 536 real pixels are needed. From the candidate
set, the smallest file that meets this number is picked.

The numbers make one point immediately visible: at the same page, the same viewport width, a
320-, 640-, or 960-pixel file gets downloaded depending on pixel ratio. A large file is not
picked just because the viewport is wide; it is the product of source size and pixel ratio
that picks.

The second table gives the magnitude of the difference between candidates. Pixel count grows
with the **square** of width: the 1920-pixel file carries 36 times the pixels of the 320-pixel
file. This is why one wrong step in the candidate set is a cost that grows by a multiple.

#### When Source Size Is Declared Wrong

The third table gives the size of the most commonly made mistake. If the image really occupies
a third of the viewport but the `sizes` declaration says it occupies the whole thing, the
browser picks an unnecessarily large candidate. The difference ranges between 2.3 and 9 times.

The mistake is easy to hide, because the image looks correct — just with an unnecessarily
large file. The way to check is this: the source size declaration is a copy of the width rules
in the stylesheet, and must be updated together whenever the layout changes. The two sitting in
separate places is this notation's structural weakness.

Two more notes. A candidate set can also be written with a pixel-ratio descriptor instead of
width (`image.jpg 1x, image@2x.jpg 2x`); in this notation source size is not taken into
account, and only the device's pixel ratio picks. It is enough for small, fixed-size images —
an icon, a badge.

Second, the selection rule here is a model. Candidate selection is left to the browser's
discretion; a smaller candidate may be picked based on network conditions or a user setting.
The declaration's job is to give correct information, not to force the choice.

#### Art Direction

The second problem is not solved by size. However much a wide panorama shot is shrunk on a
narrow screen, the point where the measurement station sits becomes indistinguishable. What is
needed is a different crop of the same shot.

This distinction is called **art direction**, and its tool is the `picture` element. The
element carries conditional `source` elements and one `img` element.

```js
// art-direction.mjs — source selection in a picture element, and computing reserved space
// Rule: sources are tried in document order, the FIRST source whose condition is true is picked.

const SOURCES = [
  { name: "location-wide", condition: "(min-width: 812px)", w: 1440, h: 600 },   // 12:5 panorama
  { name: "location-medium", condition: "(min-width: 500px)", w: 960, h: 640 },  // 3:2
  { name: "location-narrow", condition: null, w: 480, h: 600 },                  // 4:5 crop
];

const SIZES = [
  ["(min-width: 812px)", 0.33],
  ["(min-width: 500px)", 0.5],
  [null, null],   // calc(100vw - 48px)
];

function conditionTrue(condition, V) {
  if (condition === null) return true;
  const m = condition.match(/^\(min-width:\s*(\d+)px\)$/);
  return V >= Number(m[1]);
}

function displayedWidth(V) {
  for (const [condition, ratio] of SIZES) {
    if (conditionTrue(condition, V)) return ratio === null ? V - 48 : V * ratio;
  }
}

function pick(list, V) {
  return list.find((s) => conditionTrue(s.condition, V));
}

console.log("sources in document order:");
for (const s of SOURCES) {
  console.log(`  ${s.name.padEnd(16)} ${String(s.condition ?? "(unconditional img)").padEnd(20)} ${s.w}x${s.h}  aspect ratio ${(s.w / s.h).toFixed(2)}`);
}

console.log("\nviewport  displayed  picked           aspect  reserved");
console.log("          width      source           ratio   height");
for (const V of [360, 500, 700, 812, 1280, 1600]) {
  const width = displayedWidth(V);
  const s = pick(SOURCES, V);
  const ratio = s.w / s.h;
  console.log(
    `${String(V).padStart(6)}  ${width.toFixed(0).padStart(9)}  ${s.name.padEnd(16)} ` +
      `${ratio.toFixed(2).padStart(6)}  ${(width / ratio).toFixed(0).padStart(8)}`,
  );
}

// if the order breaks: conditional sources ordered narrow to wide
const REVERSED = [SOURCES[1], SOURCES[0], SOURCES[2]];
console.log("\n--- if conditional sources are ordered narrow to wide ---");
console.log("viewport  picked source     correct pick    same");
for (const V of [360, 500, 812, 1280]) {
  const r = pick(REVERSED, V), c = pick(SOURCES, V);
  console.log(
    `${String(V).padStart(6)}  ${r.name.padEnd(17)}  ${c.name.padEnd(15)}  ${(r.name === c.name ? "yes" : "NO").padStart(6)}`,
  );
}

// reserved space varies from source to source: if each source does not declare its own dimensions
console.log("\n--- heights of sources at the same displayed width (500px) ---");
for (const s of SOURCES) {
  console.log(`  ${s.name.padEnd(16)} ${(500 / (s.w / s.h)).toFixed(0).padStart(4)}px`);
}
```

```
sources in document order:
  location-wide    (min-width: 812px)   1440x600  aspect ratio 2.40
  location-medium  (min-width: 500px)   960x640  aspect ratio 1.50
  location-narrow  (unconditional img)  480x600  aspect ratio 0.80

viewport  displayed  picked           aspect  reserved
          width      source           ratio   height
   360        312  location-narrow    0.80       390
   500        250  location-medium    1.50       167
   700        350  location-medium    1.50       233
   812        268  location-wide      2.40       112
  1280        422  location-wide      2.40       176
  1600        528  location-wide      2.40       220

--- if conditional sources are ordered narrow to wide ---
viewport  picked source     correct pick    same
   360  location-narrow    location-narrow     yes
   500  location-medium    location-medium     yes
   812  location-medium    location-wide        NO
  1280  location-medium    location-wide        NO

--- heights of sources at the same displayed width (500px) ---
  location-wide     208px
  location-medium   333px
  location-narrow   625px
```

Three sources, three different aspect ratios. The narrow layout uses a vertical crop: it takes
up more vertical space at the same width, and the point where the station sits stays at scale.
The wide layout's panorama crop also shows the shape of the surrounding terrain.

The second table measures a rule that departs from media queries. In the stylesheet, the
**last** matching rule won; inside `picture`, the **first** source whose condition is true is
picked. When sources are ordered narrow to wide, the wide conditions never get their turn: at
812 and 1280 units, the medium crop is picked and the panorama is never used. Sources are
ordered from the wide condition to the narrow one.

The third block shows how differently the crops occupy space. At the same 500-unit width, the
panorama needs 208 units of height, the vertical crop 625. This difference directly produces
the reserved-space problem.

#### Reserved Space

Before an image downloads, the browser cannot know how much space to give it. If it does not
know, it reserves no space; when the image arrives, the content beneath it gets pushed down.
This is the **layout shift** introduced in the Web Fundamentals and HTML course.

The fix is to write the aspect ratio into the document. When an `img` element is given `width`
and `height` attributes, the browser computes the ratio from their quotient and reserves the
box the image will occupy in advance. The attributes are pixel values but do not mean a fixed
size; the `inline-size` declaration in the stylesheet determines the image's real width, the
attributes only carry the ratio.

Art direction needs one more thing. Since each crop has a different ratio, the space to be
reserved differs too; this is why **every** `source` element declares its own `width` and
`height`. If a single ratio is written, the space gets recomputed the moment the source
changes and a shift results.

The same result can also be built on the style side with an `aspect-ratio` declaration; when
the two are used together, the style wins.

```html
<figure class="location-image">
  <picture>
    <source media="(min-width: 812px)"
            srcset="location-wide-960.jpg 960w, location-wide-1440.jpg 1440w"
            sizes="33vw" width="1440" height="600">
    <source media="(min-width: 500px)"
            srcset="location-medium-640.jpg 640w, location-medium-960.jpg 960w"
            sizes="50vw" width="960" height="640">
    <img src="location-narrow-480.jpg"
         srcset="location-narrow-320.jpg 320w, location-narrow-480.jpg 480w"
         sizes="calc(100vw - 48px)"
         width="480" height="600"
         loading="lazy" decoding="async"
         alt="Southwest view of the ridge where the North Slope measurement station sits">
  </picture>
  <figcaption>The measurement station sits on the ridge's southwest slope, at 1840 meters.</figcaption>
</figure>
```

```css
/* station.css — step 11: the image's box */
.location-image img {
  inline-size: 100%;
  block-size: auto;
  object-fit: cover;
  object-position: 50% 35%;
  background-color: var(--surface);
}

.location-image figcaption {
  margin-block-start: 0.5rem;
  color: var(--text-muted);
  font-size: 0.875em;
}
```

There are four declarations on the document side, and each answers a separate question:
`media` says which crop to use, `srcset` says what sizes that crop is available in, `sizes`
says how much space it will take on the page, `width` and `height` say what ratio of space to
reserve. The alternative text is independent of the crop and is written once on the `img`
element — all three sources show the same scene.

On the style side, `inline-size: 100%` fits the image to its container, `block-size: auto`
preserves the ratio. The `object-fit: cover` declaration crops the image instead of stretching
it when the box's ratio differs from the image's own; `object-position` says which point the
crop centers on. The 35 percent value keeps the horizon line in the upper third.

The counterpart for background images is the `image-set()` function; it builds the same
candidate logic in the stylesheet. Background images, however, are decorative images with no
counterpart in the document; a shot carrying content is not placed as a background.

#### Summary

- An image carries two separate problems: choosing the size of the same shot (resolution
  switching) and showing a different crop in the narrow layout (art direction).
- Candidate selection is determined by two factors: the source size resolved from the `sizes`
  declaration and the device's pixel ratio; viewport width alone does not pick.
- Candidate files' pixel count grows with the square of width; when source size is declared
  wrong, the picked file carries 2.3 to 9 times too many pixels.
- In a `picture` element, the **first** source whose condition is true is picked; this is the
  reverse of the stylesheet's "last one wins" rule, and sources are ordered from the wide
  condition to the narrow one.
- Since crops have different aspect ratios, each source declares its own `width` and `height`;
  writing a single ratio produces layout shift when the source changes.
- `object-fit` and `object-position` provide cropping instead of stretching, and choice of
  crop center, when the box's ratio differs from the image's own.

#### Next Step

Up to this point, style has always adapted to the medium's measurable properties: width, a
container's size, pixel ratio. The user's own preferences can be read the same way — whether
they want a dark or light color scheme, whether they want increased contrast, whether they
want motion reduced. The next lesson takes up these preferences, and the last of them will
leave a question: what exactly does the reduced-motion preference reduce?

### User Preferences

Source: https://academia.sh/en/courses/layout-and-responsive-design/user-preferences

Querying color scheme, contrast, forced colors, and reduced-motion preferences, contrast-checking the dark palette, resolving preferences together, and what reduced motion covers.

Up to this point, style has always adapted to the medium's measurable properties: viewport
width, a container's size, the device's pixel ratio. All of these were properties of the
device.

The user themself also announces a preference. They may have chosen a dark color scheme in
their operating system or browser settings, asked for increased contrast, or asked for reduced
motion. These preferences are read with the same query notation, and the page adapts to them.

#### A Preference Is a Media Feature

Four media features announce a user preference.

`prefers-color-scheme` gives a preference for a light or dark surface; its values are `light`
and `dark`. `prefers-contrast` announces a wish for increased or decreased
contrast. `prefers-reduced-motion` carries a wish for reduced motion, and its one meaningful
value is `reduce`. `forced-colors` announces a separate condition: the user has asked for the
page's colors to be forced into a limited palette they themselves have set, instead of the
page's own.

All four are written the same way as media queries and follow the same cascade rules. The
distinction is not in the question but in the subject: width belongs to the device, preference
belongs to the user.

One design decision follows immediately. Preference queries force style to be written
**additively**: the base style describes the `no-preference` state, and the query block writes
only what changes. Written the other way — with the dark scheme as the base and the light
scheme undone by a query — a user who has announced no preference becomes the exception to the
design.

#### Contrast-Checking the Dark Scheme

The dark scheme is not the light scheme's colors inverted. Because relative luminance is not
linear, changing the surface requires recomputing the ratio of every foreground color.

The **contrast ratio** introduced in the Visual Presentation with CSS course is computed from
two colors' relative luminances. The program below checks the station palette in both schemes.

```js
// contrast.mjs — checking contrast ratios for the light and dark color schemes
// Relative luminance and contrast ratio are computed per the WCAG definition.

function hslRgb(h, s, l) {
  s /= 100; l /= 100;
  const k = (n) => (n + h / 30) % 12;
  const a = s * Math.min(l, 1 - l);
  const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  return [f(0), f(8), f(4)];
}

function relativeLuminance([r, g, b]) {
  const d = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
  return 0.2126 * d(r) + 0.7152 * d(g) + 0.0722 * d(b);
}

function ratio(color1, color2) {
  const l1 = relativeLuminance(color1), l2 = relativeLuminance(color2);
  const [higher, lower] = l1 > l2 ? [l1, l2] : [l2, l1];
  return (higher + 0.05) / (lower + 0.05);
}

// light scheme: the palette built in the Visual Presentation with CSS course
const LIGHT = {
  surface: [203, 18, 97],
  text: [211, 29, 16],
  textMuted: [211, 12, 45],
  warning: [0, 66, 33],
  line: [207, 15, 86],
};

// dark scheme, only surface and main text changed; the rest left as is
const DARK_INCOMPLETE = {
  surface: [211, 22, 12],
  text: [211, 20, 92],
  textMuted: LIGHT.textMuted,
  warning: LIGHT.warning,
  line: LIGHT.line,
};

const TARGET = { text: 4.5, textMuted: 4.5, warning: 4.5, line: 3 };

function table(title, scheme) {
  console.log(`\n--- ${title} ---`);
  console.log(`surface: hsl(${scheme.surface.join(" ")})`);
  console.log("foreground   hsl           target  ratio  result");
  for (const key of ["text", "textMuted", "warning", "line"]) {
    const r = ratio(hslRgb(...scheme[key]), hslRgb(...scheme.surface));
    console.log(
      `${key.padEnd(11)}  ${("hsl(" + scheme[key].join(" ") + ")").padEnd(14)}` +
        `${(TARGET[key] + ":1").padStart(6)}  ${r.toFixed(2).padStart(5)}  ${(r >= TARGET[key] ? "passes" : "FAILS").padStart(6)}`,
    );
  }
}

table("light scheme", LIGHT);
table("dark scheme: only surface and text changed", DARK_INCOMPLETE);

// correction: for each foreground, the lowest lightness that meets the target is sought
console.log("\n--- lightness values needed in the dark scheme ---");
console.log("foreground   current L  needed L  current ratio  corrected ratio");
for (const key of ["text", "textMuted", "warning", "line"]) {
  const [h, s, l] = DARK_INCOMPLETE[key];
  let needed = l;
  while (needed <= 100 && ratio(hslRgb(h, s, needed), hslRgb(...DARK_INCOMPLETE.surface)) < TARGET[key]) {
    needed += 0.5;
  }
  console.log(
    `${key.padEnd(11)}  ${l.toFixed(1).padStart(9)}  ${needed.toFixed(1).padStart(8)}  ` +
      `${ratio(hslRgb(h, s, l), hslRgb(...DARK_INCOMPLETE.surface)).toFixed(2).padStart(14)}  ` +
      `${ratio(hslRgb(h, s, needed), hslRgb(...DARK_INCOMPLETE.surface)).toFixed(2).padStart(15)}`,
  );
}
```

```

--- light scheme ---
surface: hsl(203 18 97)
foreground   hsl           target  ratio  result
text         hsl(211 29 16) 4.5:1  13.93  passes
textMuted    hsl(211 12 45) 4.5:1   4.59  passes
warning      hsl(0 66 33)   4.5:1   8.56  passes
line         hsl(207 15 86)   3:1   1.30   FAILS

--- dark scheme: only surface and text changed ---
surface: hsl(211 22 12)
foreground   hsl           target  ratio  result
text         hsl(211 20 92) 4.5:1  13.90  passes
textMuted    hsl(211 12 45) 4.5:1   3.42   FAILS
warning      hsl(0 66 33)   4.5:1   1.83   FAILS
line         hsl(207 15 86)   3:1  12.08  passes

--- lightness values needed in the dark scheme ---
foreground   current L  needed L  current ratio  corrected ratio
text              92.0      92.0           13.90            13.90
textMuted         45.0      53.0            3.42             4.54
warning           33.0      61.0            1.83             4.53
line              86.0      86.0           12.08            12.08
```

In the light scheme, all three text colors meet the 4.5:1 criterion. The divider line, at a
1.30 ratio, is below the 3:1 criterion — this is not a flaw, it is a question of scope. The
WCAG 1.4.11 criterion requires non-text contrast for the boundaries of interface components
and status indicators; a row divider in a table is decorative. If the same color were used as
an input's border or a focus indicator, it would fail the criterion and would need to be
darkened.

In the dark scheme, the table flips. The surface and main text were changed; the ratio between
the two is 13.90 and looks fine. But the two colors that were not changed fall: muted text to
3.42, the warning color to 1.83. The warning color is the color meant to alert the user, and it
is nearly unreadable on a dark surface.

The third block computes the fix. Once muted text's lightness is raised from 45 to 53, and the
warning color's from 33 to 61, the criterion is met. The 28-point increase needed for the
warning color shows directly why a plain inversion is not enough: red's relative luminance is
low, and it needs to move to a much lighter tone to be distinguishable on a dark surface.

The rule is this: a dark scheme is a recheck of **every** color in the light scheme. A color left
unchecked is one that can silently fall outside the criterion — of the three colors left unchanged here,
two did fall, while the divider line already met its target.

#### Resolving Preferences Together

A user can announce more than one preference at once. Since the queries are independent of
each other, the result is the cascade combination of every matching block.

```js
// preferences.mjs — resolving user preference queries and determining the outcome

// Rules are in source order; the last one whose condition is true wins.
const RULES = [
  { condition: null, value: { surface: "light", line: "thin", transition: "200ms", scroll: "smooth" } },
  { condition: "prefers-color-scheme: dark", value: { surface: "dark" } },
  { condition: "prefers-contrast: more", value: { line: "bold" } },
  { condition: "prefers-reduced-motion: reduce", value: { transition: "1ms", scroll: "instant" } },
  { condition: "forced-colors: active", value: { surface: "system", line: "system" } },
];

const PROFILES = [
  { name: "default", "prefers-color-scheme": "light", "prefers-contrast": "no-preference", "prefers-reduced-motion": "no-preference", "forced-colors": "none" },
  { name: "dark scheme", "prefers-color-scheme": "dark", "prefers-contrast": "no-preference", "prefers-reduced-motion": "no-preference", "forced-colors": "none" },
  { name: "dark + reduced motion", "prefers-color-scheme": "dark", "prefers-contrast": "no-preference", "prefers-reduced-motion": "reduce", "forced-colors": "none" },
  { name: "high contrast", "prefers-color-scheme": "light", "prefers-contrast": "more", "prefers-reduced-motion": "no-preference", "forced-colors": "none" },
  { name: "forced colors", "prefers-color-scheme": "dark", "prefers-contrast": "more", "prefers-reduced-motion": "reduce", "forced-colors": "active" },
];

function matches(condition, profile) {
  if (condition === null) return true;
  const [feature, value] = condition.split(":").map((s) => s.trim());
  return profile[feature] === value;
}

function resolve(profile) {
  const result = {};
  const matched = [];
  for (const r of RULES) {
    if (!matches(r.condition, profile)) continue;
    matched.push(r.condition ?? "base");
    Object.assign(result, r.value);
  }
  return { result, matched };
}

console.log("rules:");
for (const r of RULES) {
  console.log(`  ${(r.condition ?? "(base)").padEnd(32)} ${JSON.stringify(r.value)}`);
}

console.log("\nprofile                 surface  line     trans  scroll    matched rule count");
for (const p of PROFILES) {
  const { result, matched } = resolve(p);
  console.log(
    `${p.name.padEnd(23)}  ${result.surface.padEnd(7)}  ${result.line.padEnd(7)}  ` +
      `${result.transition.padStart(5)}  ${result.scroll.padEnd(8)}  ${String(matched.length).padStart(19)}`,
  );
}

// color-scheme declaration: which scheme browser controls and the scrollbar use
console.log("\n--- the color-scheme declaration's effect on browser controls ---");
console.log("user preference    color-scheme decl.     controls    page     compatible");
for (const preference of ["light", "dark"]) {
  for (const declaration of ["normal", "light", "light dark"]) {
    const supported = declaration.split(" ").includes(preference);
    const controls = supported ? preference : declaration === "normal" ? "light" : declaration.split(" ")[0];
    const page = preference;   // the page's own colors are already adapted by query
    console.log(
      `${preference.padEnd(19)}  ${declaration.padEnd(22)}  ${controls.padEnd(10)}  ${page.padEnd(7)}  ` +
        `${(controls === page ? "yes" : "NO").padStart(9)}`,
    );
  }
}

// what reduced motion reduces: policy by property kind
console.log("\n--- duration by property kind under the reduced-motion preference ---");
const MOTIONS = [
  { name: "translate/scale", kind: "displacement", base: 300 },
  { name: "rotate", kind: "displacement", base: 400 },
  { name: "opacity", kind: "visibility", base: 200 },
  { name: "background-color", kind: "color", base: 150 },
  { name: "continuously spinning ribbon", kind: "continuous motion", base: 8000 },
];
console.log("property                       kind                base    reduced     decision");
for (const m of MOTIONS) {
  const removed = m.kind === "displacement" || m.kind === "continuous motion";
  const reduced = removed ? 0 : m.base;
  const decision = removed ? "removed" : "kept";
  console.log(
    `${m.name.padEnd(30)}  ${m.kind.padEnd(18)}  ${String(m.base + "ms").padStart(6)}  ` +
      `${String(reduced + "ms").padStart(10)}  ${decision.padStart(11)}`,
  );
}
```

```
rules:
  (base)                           {"surface":"light","line":"thin","transition":"200ms","scroll":"smooth"}
  prefers-color-scheme: dark       {"surface":"dark"}
  prefers-contrast: more           {"line":"bold"}
  prefers-reduced-motion: reduce   {"transition":"1ms","scroll":"instant"}
  forced-colors: active            {"surface":"system","line":"system"}

profile                 surface  line     trans  scroll    matched rule count
default                  light    thin     200ms  smooth                      1
dark scheme              dark     thin     200ms  smooth                      2
dark + reduced motion    dark     thin       1ms  instant                     3
high contrast            light    bold     200ms  smooth                      2
forced colors            system   system     1ms  instant                     5

--- the color-scheme declaration's effect on browser controls ---
user preference    color-scheme decl.     controls    page     compatible
light                normal                  light       light          yes
light                light                   light       light          yes
light                light dark              light       light          yes
dark                 normal                  light       dark            NO
dark                 light                   light       dark            NO
dark                 light dark              dark        dark           yes

--- duration by property kind under the reduced-motion preference ---
property                       kind                base    reduced     decision
translate/scale                 displacement         300ms         0ms      removed
rotate                          displacement         400ms         0ms      removed
opacity                         visibility           200ms       200ms         kept
background-color                color                150ms       150ms         kept
continuously spinning ribbon    continuous motion   8000ms         0ms      removed
```

The first table shows that preferences do not block each other. When dark scheme and reduced
motion are both announced, both apply; each query writes its own property and does not
overwrite what the others wrote.

The last row flags one exception. While forced colors is active, most of the page's color
declarations are ignored and a limited palette set by the system takes their place. This is a
strict requirement for the user; what the page must do is leave **another** distinguishing tool
in place of color. A state distinguished by color alone — showing a missing measurement in red,
for instance — is entirely lost under forced colors; the same information must also be given
with an icon or text.

#### The Browser's Own Parts

The second table shows an area where changing the page's colors is not enough.

The scrollbar, the default look of form controls, the text selection color — these are parts
the browser draws, and they are not affected by the page's color declarations. The
`color-scheme` declaration tells them which scheme to use.

When the declaration is not written, or only `light` is written, controls stay light even if
the user prefers a dark scheme: a bright white scrollbar and light-surfaced inputs appear on a
dark page. A `color-scheme: light dark` declaration announces support for both schemes, and the
controls then follow the user's preference.

The declaration has a second consequence: the `light-dark()` function only works once a scheme
is selected. The function takes two values and picks one based on the active scheme; it allows
separating dark-scheme rules at the value level instead of writing them into a separate query
block.

#### What Reduced Motion Reduces

The third table arrives at this lesson's real question.

The name of the `prefers-reduced-motion` preference can be misleading: the preference asks not
for **animation to be turned off** but for motion to be reduced. The distinction is made by
looking at which property changes.

An element's **displacement** — sliding, rotating, scaling — produces movement for the eye.
For users with vestibular sensitivity, large or unexpected movement can cause dizziness and
nausea. A user who announces the preference wants to avoid this movement.

A change in an element's **visibility** or **color** produces no movement. A button's
background changing over 150 milliseconds on hover can be kept even under the reduced-motion
preference — it should even be kept, because this transition makes a state change easier to
read.

The table's decision column applies this distinction: motion that produces displacement and
repeats continuously is removed, visibility and color transitions are kept. Zeroing every
transition with a single rule — writing a zero duration on the universal selector — is an easy
fix, but it makes the interface abrupt and hard to read.

The WCAG 2.3.3 criterion describes this policy directly: motion animation triggered by
interaction must be able to be turned off, unless it is essential to the function.

```css
/* station.css — step 12: user preferences */
:root {
  color-scheme: light dark;

  --surface:    hsl(203 18% 97%);
  --text:       hsl(211 29% 16%);
  --text-muted: hsl(211 12% 45%);
  --warning:    hsl(0 66% 33%);
  --line:       hsl(207 15% 86%);
  --transition-duration: 200ms;
}

@media (prefers-color-scheme: dark) {
  :root {
    --surface:    hsl(211 22% 12%);
    --text:       hsl(211 20% 92%);
    --text-muted: hsl(211 12% 53%);
    --warning:    hsl(0 66% 61%);
  }
}

@media (prefers-contrast: more) {
  :root {
    --text-muted: var(--text);
    --line: var(--text);
  }
}

@media (prefers-reduced-motion: reduce) {
  :root { --transition-duration: 1ms; }
  html { scroll-behavior: auto; }
}

@media (forced-colors: active) {
  .measurement-table .missing { forced-color-adjust: none; }
  .measurement-table .missing::before { content: "! "; }
}
```

Five blocks, five separate questions. The base block defines the light scheme and the default duration;
the remaining four write only the custom properties that change. The lightness values in the dark scheme
are the result of the computation above: muted text at 53, warning at 61. The forced-colors block does two
things: `forced-color-adjust: none` keeps the meaningful color of a missing measurement out of the forced
palette, and `::before` adds a text mark that does not depend on color at all.

Because every declaration passes through custom properties, component rules never change. When
a card, a table row, or a button writes `var(--text)`, the combination of all four preferences
is already resolved.

#### Summary

- Four media features announce a user preference: color scheme, contrast, reduced motion, and
  forced colors; all are written the same way as media queries.
- The base style describes a user who has announced no preference; query blocks write only
  what changes and undo none of it.
- A dark scheme is not the light scheme translated: when the surface changes, every foreground
  color's ratio has to be recomputed; in the station palette, the warning color's lightness has
  to rise from 33 to 61.
- Preferences are independent of each other and all apply when announced together; while
  forced colors is active, distinctions given by color alone are lost and need a text or icon
  fallback.
- The `color-scheme` declaration tells the browser's own drawn parts which scheme to follow;
  when not written, light controls remain on a dark page.
- The reduced-motion preference removes motion that produces displacement and repeats
  continuously; visibility and color transitions are kept, because they produce no movement.

#### Next Step

The last table built a distinction but used it without defining it: which property "produces
displacement"? Declarations that slide, rotate, and scale an element share a common name and a
common mathematics; deciding which motion to reduce without seeing both is left to intuition.
The next topic starts from here: how is an element translated, scaled, and rotated without
breaking its own box, in what order are these operations applied, and why does the order
change the result?

## Motion and Transforms

The matrix equivalent and order dependence of transforms, the timing of transitions and keyframe animations, motion's cost in the rendering pipeline, and respecting the reduced-motion preference.

### Transforms

Source: https://academia.sh/en/courses/layout-and-responsive-design/transforms

The matrix equivalent of the translate, scale, rotate, and skew functions; how function order changes the result, the effect of the transform origin, and the transform being applied after layout.

The previous lesson read user preferences: color scheme and reduced motion. What the color
scheme preference changes is clear — color declarations. The reduced-motion preference
removes declarations that "produce a change of position," and what those declarations are
has not been defined yet.

This topic establishes that definition. Motion on a page comes from three layers:
geometric operations that translate, scale, and rotate a box in its own place; the spread
of a change between two states over time; and keyframe sequences driven by a timeline.
This lesson covers the first. The three operations share a common name and a common
mathematics; once both are seen, which motion moves what becomes a measurable question.

#### A Transform Is Applied After Layout

**Transform** is used to translate, scale, rotate, or skew an element's box. Its defining
property is that it does **not touch** the layout calculation.

The order is this: the browser computes layout, finds the box's place and size, then
applies the transform to that box. The element's place in layout does not change; its
neighbors stay exactly as if nothing happened. This is why pushing an element to the
right with `transform: translateX(40px)` differs from pushing it with
`margin-inline-start: 40px`: the second recomputes the entire flow inside the containing
block, the first only changes that one box's rendering.

This has three consequences:

1. A transformed element can overflow onto its neighbors; the overflow calculation comes
   from layout, not from the transform.
2. Percentage values are read against **the element's own border box**. `translateX(50%)`
   is half the element's own width, whereas `left: 50%` is half the containing block's
   width.
3. A transform carrying a value other than `none` establishes a new **stacking context**
   and becomes the **containing block** for every `fixed`- and `absolute`-positioned
   descendant inside it. This rule was defined in the Visual Presentation with CSS
   course's stacking-context lesson;
   it comes back often when writing motion.

The third point is a common source of bugs. Writing a small transform on a container that
wraps the entire page turns a fixed-positioned header inside that container into one
pinned to the container, not the screen.

#### Functions and Their Matrix Equivalents

The transform property takes a **list of functions**. Every function in the list
corresponds to a $3 \times 3$ matrix, and the list is the product of these matrices. In two
dimensions, the matrix's last row is always $(0, 0, 1)$, so six numbers are enough; these
are the numbers the `matrix()` function takes.

$$
\begin{pmatrix} a & c & e \\ b & d & f \\ 0 & 0 & 1 \end{pmatrix}
\begin{pmatrix} x \\ y \\ 1 \end{pmatrix}
=
\begin{pmatrix} ax + cy + e \\ bx + dy + f \end{pmatrix}
$$

A point is carried to its new place by this product. The following program converts
functions to matrices, multiplies lists, and tracks a card's four corners.

```js
// transform.mjs — matrix equivalents of transform functions and order dependence
// matrix [a, b, c, d, e, f] -> | a c e |
//                              | b d f |
//                              | 0 0 1 |
const multiply = (m, n) => [
  m[0] * n[0] + m[2] * n[1],
  m[1] * n[0] + m[3] * n[1],
  m[0] * n[2] + m[2] * n[3],
  m[1] * n[2] + m[3] * n[3],
  m[0] * n[4] + m[2] * n[5] + m[4],
  m[1] * n[4] + m[3] * n[5] + m[5],
];

const toRad = (d) => (d * Math.PI) / 180;
const translation = (tx, ty) => [1, 0, 0, 1, tx, ty];
const scaling = (sx, sy) => [sx, 0, 0, sy, 0, 0];
const rotation = (d) => [Math.cos(toRad(d)), Math.sin(toRad(d)), -Math.sin(toRad(d)), Math.cos(toRad(d)), 0, 0];
const skewX = (d) => [1, 0, Math.tan(toRad(d)), 1, 0, 0];
const skewY = (d) => [1, Math.tan(toRad(d)), 0, 1, 0, 0];

const IDENTITY = [1, 0, 0, 1, 0, 0];
const chain = (list) => list.reduce((t, m) => multiply(t, m), IDENTITY);
const format = (m) => "matrix(" + m.map((v) => (Math.abs(v) < 1e-9 ? 0 : +v.toFixed(4))).join(", ") + ")";
const point = (m, [x, y]) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
const p = ([x, y]) => `(${x.toFixed(1)}, ${y.toFixed(1)})`;

console.log("--- matrix equivalents of single functions ---");
console.log("translate(40px, 12px) ->", format(translation(40, 12)));
console.log("scale(1.25, 1.25)     ->", format(scaling(1.25, 1.25)));
console.log("rotate(30deg)         ->", format(rotation(30)));
console.log("skewX(12deg)          ->", format(skewX(12)));
console.log("skewY(12deg)          ->", format(skewY(12)));

console.log("\n--- order dependence: same two functions, two notations ---");
const A = chain([translation(120, 0), rotation(30)]);
const B = chain([rotation(30), translation(120, 0)]);
console.log("translate(120px, 0) rotate(30deg) ->", format(A));
console.log("rotate(30deg) translate(120px, 0) ->", format(B));

const CORNERS = [[0, 0], [220, 0], [220, 120], [0, 120]];   // a 220x120-unit card
console.log("\ncorner points (top-left, top-right, bottom-right, bottom-left):");
console.log("  notation 1:", CORNERS.map((k) => p(point(A, k))).join(" "));
console.log("  notation 2:", CORNERS.map((k) => p(point(B, k))).join(" "));

console.log("\n--- transform-origin: T(o) * M * T(-o) ---");
const ORIGINS = [[0, 0], [110, 60], [220, 120]];          // top-left, center, bottom-right
for (const [ox, oy] of ORIGINS) {
  const M = chain([translation(ox, oy), rotation(30), translation(-ox, -oy)]);
  console.log(`origin (${ox}, ${oy})`.padEnd(18), format(M));
  console.log("  corners:", CORNERS.map((k) => p(point(M, k))).join(" "));
}

console.log("\n--- order of scaling and translation ---");
const C = chain([scaling(2, 2), translation(50, 0)]);
const D = chain([translation(50, 0), scaling(2, 2)]);
console.log("scale(2) translate(50px, 0) ->", format(C), " top-left corner:", p(point(C, [0, 0])));
console.log("translate(50px, 0) scale(2) ->", format(D), " top-left corner:", p(point(D, [0, 0])));

console.log("\n--- reducing to a single matrix: three functions, one product ---");
const E = chain([translation(0, -8), rotation(-3), scaling(1.04, 1.04)]);
console.log("translate(0, -8px) rotate(-3deg) scale(1.04) ->", format(E));
console.log("card corners:", CORNERS.map((k) => p(point(E, k))).join(" "));
```

```
--- matrix equivalents of single functions ---
translate(40px, 12px) -> matrix(1, 0, 0, 1, 40, 12)
scale(1.25, 1.25)     -> matrix(1.25, 0, 0, 1.25, 0, 0)
rotate(30deg)         -> matrix(0.866, 0.5, -0.5, 0.866, 0, 0)
skewX(12deg)          -> matrix(1, 0, 0.2126, 1, 0, 0)
skewY(12deg)          -> matrix(1, 0.2126, 0, 1, 0, 0)

--- order dependence: same two functions, two notations ---
translate(120px, 0) rotate(30deg) -> matrix(0.866, 0.5, -0.5, 0.866, 120, 0)
rotate(30deg) translate(120px, 0) -> matrix(0.866, 0.5, -0.5, 0.866, 103.923, 60)

corner points (top-left, top-right, bottom-right, bottom-left):
  notation 1: (120.0, 0.0) (310.5, 110.0) (250.5, 213.9) (60.0, 103.9)
  notation 2: (103.9, 60.0) (294.4, 170.0) (234.4, 273.9) (43.9, 163.9)

--- transform-origin: T(o) * M * T(-o) ---
origin (0, 0)      matrix(0.866, 0.5, -0.5, 0.866, 0, 0)
  corners: (0.0, 0.0) (190.5, 110.0) (130.5, 213.9) (-60.0, 103.9)
origin (110, 60)   matrix(0.866, 0.5, -0.5, 0.866, 44.7372, -46.9615)
  corners: (44.7, -47.0) (235.3, 63.0) (175.3, 167.0) (-15.3, 57.0)
origin (220, 120)  matrix(0.866, 0.5, -0.5, 0.866, 89.4744, -93.923)
  corners: (89.5, -93.9) (280.0, 16.1) (220.0, 120.0) (29.5, 10.0)

--- order of scaling and translation ---
scale(2) translate(50px, 0) -> matrix(2, 0, 0, 2, 100, 0)  top-left corner: (100.0, 0.0)
translate(50px, 0) scale(2) -> matrix(2, 0, 0, 2, 50, 0)  top-left corner: (50.0, 0.0)

--- reducing to a single matrix: three functions, one product ---
translate(0, -8px) rotate(-3deg) scale(1.04) -> matrix(1.0386, -0.0544, 0.0544, 1.0386, 0, -8)
card corners: (0.0, -8.0) (228.5, -20.0) (235.0, 104.7) (6.5, 116.6)
```

The first block shows where each of the four families writes into the matrix.
**Translation** touches only the last two numbers, $e$ and $f$; the top-left $2 \times 2$
section stays the identity matrix. **Scaling** writes to the diagonal. **Rotation** uses
all four cells, and the values are the angle's cosine and sine. **Skew** fills a single
off-diagonal cell: `skewX` fills the top $c$ cell, `skewY` the bottom $b$ cell.

Skewing has a side effect: once an off-diagonal cell is filled, right angles are not
preserved. Scaling and rotation leave a rectangle a rectangle; skewing turns it into a
parallelogram.

#### Order Dependence

Matrix multiplication is **not commutative**. The output's second block shows this with
two notations. The same two functions, written in different order, give two different
matrices: one with a translation of $(120, 0)$, the other of $(103.9, 60)$.

The rule is: the list is multiplied left to right, but applied to a point **right to
left**. The function written last is the first to touch the point.

- `translate(120px, 0) rotate(30deg)` — the card first rotates in its own place, then, in
  its rotated form, moves 120 units to the right along the page's axes. The amount of
  translation is not affected by the rotation.
- `rotate(30deg) translate(120px, 0)` — the card is first translated 120 units, then
  everything, including that translation, rotates. The translation appears to have
  happened along the rotated axis: the 120-unit move lands at the point $(103.9, 60)$,
  because $120 \cos 30° \approx 103.9$ and $120 \sin 30° = 60$.

The same event is more exposed in the scale-and-translation pair. In the notation
`scale(2) translate(50px, 0)`, the translation also doubles and the card moves 100 units;
in the reversed notation, it moves 50 units. Lengths written **after** a scale are in
scaled units.

This is the rule broken most often when writing motion. If a card is meant to both grow
and lift at once, the order of the two functions determines both the direction and the
amount of the motion.

#### Transform Origin

Rotation and scaling leave a fixed point in place. This point is called the **transform
origin**, and its default value is the box's **center**.

The matrix equivalent is not a separate mechanism: the origin turns the transform into a
three-step chain. The origin point is moved to the start, the transform is applied, the
point is carried back to its place. The output's third block computes this chain for three
origins.

The results read as follows: with the origin at the top-left corner, the point $(0, 0)$
stays in place; with the origin at the bottom-right corner, the point $(220, 120)$ stays in
place. The difference is reflected in the $e$ and $f$ values — the rotation matrix's first
four numbers are the same across all three cases.

`transform-origin` takes two or three values; percentages are read against the element's
own border box. If a card should open from its left edge, the origin is written as
`left center`.

#### Moving to Three Dimensions

Once a function such as `translateZ`, `rotateX`, or `rotateY` enters the two-dimensional
list, the matrix becomes $4 \times 4$ and `matrix3d()` takes sixteen numbers. The rule
stays the same: the list is multiplied, and applied to the point right to left.

In three dimensions, two additional declarations come into play. `perspective` defines a
viewing distance; a small value gives a strong depth effect, a large value a weak one. This
value can be written on the element itself, inside the `transform` list, or on its
**parent** with the `perspective` property; the second lets sibling elements share a
common vanishing point.

`transform-style: preserve-3d`, in turn, tells child elements to keep their own depth
instead of being flattened. The default value, `flat`, presses the subtree onto the
parent's plane. The `backface-visibility: hidden` declaration says an element's back face
should not be drawn when it turns toward the viewer — it is used to hide the reverse side
of a card that has two faces.

Three-dimensional transforms have a cost, and it is covered in this topic's fourth lesson:
an element carrying depth is split into its own layer during rendering.

#### Transform on the Station Page

Let a touch indicator be added to the measurement cards. When the pointer is over a card,
it should lift slightly and grow a little.

```css
/* motion.css — step 1: card highlight */
.measurement-cards > .card {
  transform-origin: center bottom;
}

.measurement-cards > .card:hover,
.measurement-cards > .card:focus-within {
  transform: translateY(-8px) scale(1.04);
}
```

The order is deliberate. Because `translateY(-8px)` is written first, the 8-unit lift is
not scaled; written in the reverse order, the lift would be $8 \times 1.04 = 8.32$ units.
The difference is small, but the same rule produces an eight-unit deviation for a
200-unit translation.

`transform-origin: center bottom` fixes the card's bottom edge: the card grows from its
own base and does not spill symmetrically onto the neighboring card.

The `:focus-within` pseudo-class gives the same declaration to a keyboard user that
`:hover` gives to a pointer user. When a link inside the card receives focus, the card
lifts the same way.

This file sits next to the `layout.css` file. Whether a third file is needed — how visual,
layout, and motion declarations should be divided — is the question of the course's final
topic.

#### Summary

- A transform is applied after the layout calculation; the element's place in layout and
  its neighbors' positions do not change, only the rendered box is moved.
- Every function in the transform list is a matrix; the list is the product of these
  matrices, and the `matrix()` notation gives a two-dimensional transform's six numbers.
- Because the product is not commutative, function order changes the result: the list is
  multiplied left to right, applied to a point right to left, and lengths written after a
  scale are in scaled units.
- `transform-origin` is not a separate mechanism but a pair of translations added before
  and after the transform; its default is the box's center.
- A transform value other than `none` establishes a stacking context and becomes the
  containing block for fixed- and absolute-positioned descendants.

#### Next Step

This lesson's declarations make motion **instantaneous**: the moment the pointer is over
the card, the card is eight units up and four percent larger, with nothing in between. But
what is called motion is the **path** between two states. The next lesson covers the
declarations that define that path: which property, over how long, along which speed
curve, goes from its old value to its new one?

### Transitions

Source: https://academia.sh/en/courses/layout-and-responsive-design/transitions

Spreading a change between two states over time; values that can transition and values that cannot, computing the cubic Bezier timing curve, an interrupted transition's behavior, and stepped timing.

The previous lesson defined the card's highlighted state: eight units up, four percent
larger. The declaration gives both states, but there is no **path** between them. The
moment the pointer is over the card, the card is already in its new place.

Motion is the spreading of the path between two states over time. This lesson covers the
declarations that define that path: which property, over how long, along which speed
curve, goes from its old value to its new one, and what happens if the path is cut short?

#### A Transition Fills the Gap Between Two Values

A **transition** makes a property's computed value progress from its old value to its new
one over time, once that value changes. The transition does not trigger the change; it
only spreads a change that was going to happen anyway.

One warning is needed: the Visual Presentation with CSS course covered **gradients** —
the computed visual between colors, written with functions like `linear-gradient()`. That
is an unrelated concept; "transition" in this lesson always means change over time, never
a color gradient.

A transition has four separate declarations:

| Declaration | Question it answers |
|---|---|
| `transition-property` | Which properties will be spread |
| `transition-duration` | How long the path will take |
| `transition-timing-function` | How speed is distributed along the path |
| `transition-delay` | How long after the change it will start |

The `transition` shorthand takes all four in a single line, and comma-separated lists let
a separate value be written for each property. If duration is not written, its default is
zero; a transition declaration with no duration does nothing.

`transition-delay` can also take a negative value. A negative delay starts the transition
not from its beginning but from its **middle**: writing a delay of $-60$ milliseconds on a
240-millisecond transition opens it already 25 percent along.

#### Values That Can and Cannot Transition

A transition requires being able to produce an in-between value between two values. This
is defined for length, number, color, and transform lists. It is not defined for keywords.

This has consequences. A transition from `height: auto` to a fixed height does not work, because `auto` is
not a number. There is no in-between value between `display: none` and a visible state; display type is a
**discrete** property, and discrete properties take no part in a transition by default; they take part
once `transition-behavior: allow-discrete` is written, but they change at the end or the start rather than
in the middle — and in the opening direction the starting value has to be supplied separately with
`@starting-style`.

If a collapsible section's height is meant to be spread, there are three routes: transition
over a fixed upper bound, transition a grid track's fractional value, or work with
transform and opacity without touching height at all. The third is the cheapest, for
reasons the following lessons will show.

The `visibility` property is an exception: although discrete, it does interpolate — as long as one
endpoint is `visible`, the value stays `visible` for the whole transition and drops to the other value
only at the end. That is why it is written together with `opacity`: once the fade finishes the element
stops taking interaction, which an element left at zero opacity never would.

#### The Timing Curve

The **timing function** converts the fraction of elapsed time into a fraction of progress.
The input is time between $0$ and $1$, the output progress between $0$ and $1$.

Every named function is a **cubic Bezier** curve joining the ends $(0,0)$ and $(1,1)$ with
two control points. The notation `cubic-bezier(x1, y1, x2, y2)` gives these two points.

Evaluating the curve cannot be done in a single step: the curve is defined against the
parameter $t$, while the time available is on the $x$ axis. First the $t$ that gives the
$x$ value is found, then $y$ is read for that $t$.

```js
// transition.mjs — evaluating a cubic Bezier timing curve and computing in-between values
// The timing curve is between (0,0) and (1,1), with control points (x1,y1) and (x2,y2).
const bez = (a, b, t) => {
  const u = 1 - t;
  return 3 * u * u * t * a + 3 * u * t * t * b + t * t * t;   // p0=0, p3=1
};

// The curve's parameter t for a given x is found by binary search, then y is read.
function curve(x1, y1, x2, y2) {
  return (x) => {
    if (x <= 0) return 0;
    if (x >= 1) return 1;
    let lo = 0, hi = 1, t = x;
    for (let i = 0; i < 60; i++) {
      t = (lo + hi) / 2;
      if (bez(x1, x2, t) < x) lo = t; else hi = t;
    }
    return bez(y1, y2, t);
  };
}

const NAMED = {
  linear: [0, 0, 1, 1],
  ease: [0.25, 0.1, 0.25, 1],
  "ease-in": [0.42, 0, 1, 1],
  "ease-out": [0, 0, 0.58, 1],
  "ease-in-out": [0.42, 0, 0.58, 1],
};

console.log("--- progress values of the named timing functions ---");
console.log("elapsed".padEnd(12) + Object.keys(NAMED).map((k) => k.padStart(12)).join(""));
for (let i = 0; i <= 10; i++) {
  const x = i / 10;
  const row = Object.values(NAMED).map((c) => curve(...c)(x).toFixed(4).padStart(12)).join("");
  console.log(`%${(x * 100).toFixed(0)}`.padEnd(12) + row);
}

console.log("\n--- the same progress applied to two properties (240 ms, ease-out) ---");
const f = curve(...NAMED["ease-out"]);
const DURATION = 240;
const between = (start, end, p) => start + (end - start) * p;
console.log("ms".padStart(5), "progress".padStart(10), "translateY".padStart(12), "scale".padStart(10));
for (const ms of [0, 30, 60, 90, 120, 180, 240]) {
  const p = f(ms / DURATION);
  console.log(
    String(ms).padStart(5),
    p.toFixed(4).padStart(10),
    (between(0, -8, p).toFixed(2) + "px").padStart(12),
    between(1, 1.04, p).toFixed(4).padStart(10),
  );
}

console.log("\n--- interrupting the transition midway ---");
const CUT = 72;                     // ms; pointer leaves the card at the 72nd ms
const state = between(0, -8, f(CUT / DURATION));
console.log(`translateY at the moment of interruption = ${state.toFixed(2)}px`);
console.log("the return uses the same duration and curve, going from this value to 0:");
for (const ms of [0, 30, 60, 90, 120, 180, 240]) {
  const p = f(ms / DURATION);
  console.log(`  ${String(ms).padStart(3)} ms -> ${between(state, 0, p).toFixed(2)}px`);
}

console.log("\n--- discrete progress with steps(), 4 steps ---");
const step = (n, mode) => (x) => {
  if (x <= 0) return mode === "start" ? 1 / n : 0;
  if (x >= 1) return 1;
  const k = Math.floor(x * n);
  return (mode === "start" ? k + 1 : k) / n;
};
const sStart = step(4, "start");
const sEnd = step(4, "end");
console.log("elapsed".padEnd(12) + "steps(4, start)".padStart(16) + "steps(4, end)".padStart(16));
for (let i = 0; i <= 10; i++) {
  const x = i / 10;
  console.log(
    `%${(x * 100).toFixed(0)}`.padEnd(12) +
    sStart(x).toFixed(2).padStart(16) +
    sEnd(x).toFixed(2).padStart(16),
  );
}
```

```
--- progress values of the named timing functions ---
elapsed           linear        ease     ease-in    ease-out ease-in-out
%0                0.0000      0.0000      0.0000      0.0000      0.0000
%10               0.1000      0.0948      0.0170      0.1606      0.0197
%20               0.2000      0.2952      0.0623      0.3084      0.0817
%30               0.3000      0.5133      0.1296      0.4452      0.1874
%40               0.4000      0.6825      0.2149      0.5709      0.3319
%50               0.5000      0.8024      0.3154      0.6846      0.5000
%60               0.6000      0.8852      0.4291      0.7851      0.6681
%70               0.7000      0.9408      0.5548      0.8704      0.8126
%80               0.8000      0.9756      0.6916      0.9377      0.9183
%90               0.9000      0.9943      0.8394      0.9830      0.9803
%100              1.0000      1.0000      1.0000      1.0000      1.0000

--- the same progress applied to two properties (240 ms, ease-out) ---
   ms   progress   translateY      scale
    0     0.0000       0.00px     1.0000
   30     0.1986      -1.59px     1.0079
   60     0.3781      -3.03px     1.0151
   90     0.5405      -4.32px     1.0216
  120     0.6846      -5.48px     1.0274
  180     0.9065      -7.25px     1.0363
  240     1.0000      -8.00px     1.0400

--- interrupting the transition midway ---
translateY at the moment of interruption = -3.56px
the return uses the same duration and curve, going from this value to 0:
    0 ms -> -3.56px
   30 ms -> -2.85px
   60 ms -> -2.21px
   90 ms -> -1.64px
  120 ms -> -1.12px
  180 ms -> -0.33px
  240 ms -> 0.00px

--- discrete progress with steps(), 4 steps ---
elapsed      steps(4, start)   steps(4, end)
%0                      0.25            0.00
%10                     0.25            0.00
%20                     0.25            0.00
%30                     0.50            0.25
%40                     0.50            0.25
%50                     0.75            0.50
%60                     0.75            0.50
%70                     0.75            0.50
%80                     1.00            0.75
%90                     1.00            0.75
%100                    1.00            1.00
```

The first table compares five curves at the same points in time. Every one of them except `linear` and
`ease-in-out` is not halfway along the path at half the time.

`ease` completes half the path at about 30 percent of the duration; it starts fast and
leaves a long, slow tail. `ease-in` starts slow and speeds up toward the end — at half the
duration, only a third of the path has been covered. `ease-out` is the reverse of this.
`ease-in-out` is symmetric and is exactly halfway along the path at exactly half the time.

The choice is not arbitrary. If an element is **entering** the screen, it is expected to
slow down as it settles (`ease-out`); if it is **leaving** the screen, it is expected to
speed up as it goes (`ease-in`). A symmetric curve is used for an element that stays in
place and changes form.

#### Computing the In-Between Value

The output's second block shows how a single progress value is applied to two separate
properties. At the 60th millisecond of a 240-millisecond transition, progress is $0.3781$;
at this fraction, the translation is $-3.03$ units, the scale factor $1.0151$.

The formula is the same for every property:

$$
\text{value}(t) = \text{start} + (\text{end} - \text{start}) \times \text{progress}(t)
$$

For transform lists, this computation is done function by function — if the same functions
in the same order are present in both lists, each function's own numbers are given an
in-between value. If the lists do not match, the browser reduces both to a matrix and
gives the matrices an in-between value; the result is usually not the expected path. This
is why it is a notational rule for the two transform lists entering a transition to carry
**the same functions in the same order**.

#### Interruption and Return

The output's third block computes the case where the pointer leaves the card at the 72nd
millisecond. At that moment, the translation is $-3.56$ units, and the return starts from
this value, not from $-8$ units.

The rule is: when a transition is interrupted, the new transition starts from the
property's **computed value at that moment**. This is what keeps motion from jumping.

But it leaves a side effect: the return also uses the full duration. The card lifts for 72
milliseconds and descends over 240 milliseconds. A short touch is answered with a long
return, and the motion feels delayed. To prevent this, entry and exit durations are written
separately: the exit duration is kept shorter than the entry.

A transition declaration, when written on the element's **base state**, applies to both
the outward and the return path; when written only on the `:hover` rule, only the outward
path spreads, and the return is instantaneous.

#### Stepped Timing

The `steps(n, mode)` function produces progress not continuously but in **steps**. The
output's last block compares two modes with four steps.

`end` mode starts at zero and drops the last step at the end; `start` mode drops the first
step immediately and never shows the zero value. With four steps, progress only takes the
values $0$, $0.25$, $0.5$, $0.75$, $1$; everything in between is skipped.

This is used for a frame-by-frame indicator or a counter. In places where continuity is not
wanted, stepped timing makes motion less distracting.

#### Transition on the Station Page

```css
/* motion.css — step 2: spreading the card highlight over time */
.measurement-cards > .card {
  transform-origin: center bottom;
  transition:
    transform 240ms ease-out,
    box-shadow 240ms ease-out;
}

.measurement-cards > .card:hover,
.measurement-cards > .card:focus-within {
  transform: translateY(-8px) scale(1.04);
  transition-duration: 160ms;
}
```

The transition declaration is written on the base state; the return spreads too. In the
highlighted state, the duration is pulled to 160 milliseconds: entry short, return
somewhat longer. This ordering makes the sequence both responsive and smooth.

`transform` and `box-shadow` were listed individually. Writing `transition-property: all`
is less to type, but it draws every computed-value change into the transition; a color or
size declaration added later starts spreading unintentionally. Listing properties
prevents this surprise.

#### Summary

- A transition does not trigger a change; it spreads a property whose computed value is
  already changing over time, from its old value to its new one.
- Values with no in-between value cannot transition: discrete properties like `auto` and
  `display`, and keywords, produce a jump rather than a path.
- The timing function converts a fraction of elapsed time into a fraction of progress;
  named functions are cubic Bezier curves, and other than `linear` and `ease-in-out`,
  half the time is not half the path.
- An interrupted transition starts again from the property's computed value at that
  moment and uses its full duration; entry and exit durations are balanced by writing
  them separately.
- `steps(n, mode)` produces progress in steps; `start` drops the first step immediately,
  `end` drops the last step at the end.

#### Next Step

A transition depends on a condition: the value has to change. If a value does not change
on its own — a loading indicator that keeps spinning, a warning that alternates between two
colors — a transition has no event to trigger it. The next lesson covers the mechanism
that writes the timeline into the declaration itself: intermediate stops, repeat count, and
direction.

### Keyframe Animations

Source: https://academia.sh/en/courses/layout-and-responsive-design/keyframe-animations

Animations that carry their own timeline; the definition of a keyframe set, resolving local progress from iteration and direction values, the fill mode's values outside the animation, and the timing function applied to frame intervals.

A transition depends on a condition: a value has to change. If there is no event to change
the value, a transition has no trigger. The measurement station's connection indicator has
to announce "waiting for data" even when nobody touches the page.

The tool for this is the mechanism that writes the timeline into the declaration itself.
This lesson covers how the timeline is defined, which value is in effect at a given
moment, and what happens at times outside the timeline.

#### The Keyframe Set

The `@keyframes` rule defines a name and a list of **keyframes**. Every frame carries a
position and the declarations valid at that position. The position is a percentage; the
`from` and `to` keywords correspond to `%0` and `%100`.

```css
@keyframes pulse {
  0%   { opacity: 1;    transform: scale(1);    animation-timing-function: ease-in-out; }
  60%  { opacity: 0.35; transform: scale(1.06); }
  100% { opacity: 1;    transform: scale(1); }
}
```

Four rules govern reading this definition:

1. If `%0` or `%100` is not written, that end is filled with the value **coming from the
   element's own declarations**. This value is called the base value.
2. If two frames are written at the same position, the later one wins; if two
   `@keyframes` rules are written with the same name, the later one replaces the earlier
   one entirely — frames are not merged.
3. An `animation-timing-function` written on a frame applies to the interval **after**
   that frame. If written on the last frame, it has no effect.
4. If a value that cannot transition is written into a frame, that declaration is
   ignored; a declaration carrying `!important` is ignored too.

The third point is the source of a common misunderstanding: the
`animation-timing-function` written on the element does not apply to the animation **as a
whole**, it applies separately to each interval between frames. Writing `ease-in-out` in a
three-frame animation produces two separate slow-fast-slow curves.

#### Animation Declarations

| Declaration | Question it answers |
|---|---|
| `animation-name` | Which keyframe set |
| `animation-duration` | How long one round will take |
| `animation-timing-function` | How speed is distributed across frame intervals |
| `animation-delay` | How long the start is postponed |
| `animation-iteration-count` | How many rounds it will run |
| `animation-direction` | Which rounds play in the reverse direction |
| `animation-fill-mode` | Which value is in effect outside the timeline |
| `animation-play-state` | Running, or paused |

The `animation` shorthand takes all of these. A comma-separated list lets more than one
animation be attached to the same element; between two animations writing to the same
property, the one that comes **later** in the list wins.

#### Resolving the Timeline

Finding the value in effect at a given moment is four steps: the round number is
subtracted from the elapsed time, the raw progress within the round is found, it is
reversed if needed according to the direction value, then the frame pair corresponding to
that progress is looked up.

```js
// keyframe.mjs — keyframe resolution, iteration, direction, and fill mode
const bez = (a, b, t) => { const u = 1 - t; return 3*u*u*t*a + 3*u*t*t*b + t*t*t; };
const curve = (x1, y1, x2, y2) => (x) => {
  if (x <= 0) return 0;
  if (x >= 1) return 1;
  let lo = 0, hi = 1, t = x;
  for (let i = 0; i < 60; i++) { t = (lo + hi) / 2; if (bez(x1, x2, t) < x) lo = t; else hi = t; }
  return bez(y1, y2, t);
};
const LINEAR = curve(0, 0, 1, 1);
const EASE_IN_OUT = curve(0.42, 0, 0.58, 1);

// @keyframes pulse { 0% {...} 60% {...} 100% {...} }
const FRAMES = [
  { position: 0,   opacity: 1.00, scale: 1.00, curve: EASE_IN_OUT },
  { position: 60,  opacity: 0.35, scale: 1.06, curve: LINEAR },
  { position: 100, opacity: 1.00, scale: 1.00, curve: LINEAR },
];

// value generation from local progress (0..1): find the frame pair, apply that pair's curve
function value(local) {
  const y = local * 100;
  let i = 0;
  while (i < FRAMES.length - 2 && y >= FRAMES[i + 1].position) i++;
  const a = FRAMES[i], b = FRAMES[i + 1];
  const ratio = (y - a.position) / (b.position - a.position);
  const e = a.curve(Math.min(Math.max(ratio, 0), 1));
  return {
    pair: `%${a.position}-%${b.position}`,
    opacity: a.opacity + (b.opacity - a.opacity) * e,
    scale: a.scale + (b.scale - a.scale) * e,
  };
}

const DURATION = 1400, DELAY = 200, ITERATIONS = 3;

// does the round's progress reverse, according to the direction value?
const reversed = (direction, round) =>
  direction === "reverse" ? true :
  direction === "alternate" ? round % 2 === 1 :
  direction === "alternate-reverse" ? round % 2 === 0 : false;

console.log(`duration=${DURATION}ms delay=${DELAY}ms iterations=${ITERATIONS} direction=alternate`);
console.log("\n  ms | round | raw progress | local progress | frame pair | opacity | scale");
for (const ms of [0, 200, 620, 1040, 1600, 2200, 2600, 3000, 3400, 4600]) {
  const elapsed = ms - DELAY;
  if (elapsed < 0) { console.log(`${String(ms).padStart(4)} | delay period, animation has not started yet`); continue; }
  if (elapsed >= DURATION * ITERATIONS) { console.log(`${String(ms).padStart(4)} | animation finished`); continue; }
  const round = Math.floor(elapsed / DURATION);
  const raw = (elapsed % DURATION) / DURATION;
  const local = reversed("alternate", round) ? 1 - raw : raw;
  const d = value(local);
  console.log(
    `${String(ms).padStart(4)} | ${String(round).padStart(5)} | ${raw.toFixed(4).padStart(12)} | ${local.toFixed(4).padStart(15)} | ${d.pair.padStart(10)} | ${d.opacity.toFixed(4).padStart(7)} | ${d.scale.toFixed(4)}`,
  );
}

console.log("\n--- effect of direction values on rounds (r = reversed) ---");
console.log("direction".padEnd(20) + [0, 1, 2, 3].map((n) => `round ${n}`.padStart(9)).join(""));
for (const direction of ["normal", "reverse", "alternate", "alternate-reverse"]) {
  console.log(direction.padEnd(20) + [0, 1, 2, 3].map((n) => (reversed(direction, n) ? "r" : "-").padStart(9)).join(""));
}

// Fill mode becomes visible in a one-round entrance animation:
// @keyframes appear { from { opacity: 0; translateY 8px } to { opacity: 1; translateY 0 } }
console.log("\n--- fill mode: values outside the 'appear' animation ---");
const APPEAR = { start: { opacity: 0, translation: 8 }, end: { opacity: 1, translation: 0 } };
const g = (d) => `opacity=${d.opacity.toFixed(2)} translateY=${d.translation}px`;
// Base value = the value coming from the element's own declarations; two separate bases are tried.
for (const BASE of [{ opacity: 1, translation: 0 }, { opacity: 0, translation: 8 }]) {
  console.log(`\nbase value: ${g(BASE)}`);
  for (const mode of ["none", "forwards", "backwards", "both"]) {
    const before = (mode === "backwards" || mode === "both") ? APPEAR.start : BASE;
    const after = (mode === "forwards" || mode === "both") ? APPEAR.end : BASE;
    console.log(`  ${mode.padEnd(10)} during delay: ${g(before).padEnd(28)} after finishing: ${g(after)}`);
  }
}
```

```
duration=1400ms delay=200ms iterations=3 direction=alternate

  ms | round | raw progress | local progress | frame pair | opacity | scale
   0 | delay period, animation has not started yet
 200 |     0 |       0.0000 |          0.0000 |     %0-%60 |  1.0000 | 1.0000
 620 |     0 |       0.3000 |          0.3000 |     %0-%60 |  0.6750 | 1.0300
1040 |     0 |       0.6000 |          0.6000 |   %60-%100 |  0.3500 | 1.0600
1600 |     1 |       0.0000 |          1.0000 |   %60-%100 |  1.0000 | 1.0000
2200 |     1 |       0.4286 |          0.5714 |     %0-%60 |  0.3528 | 1.0597
2600 |     1 |       0.7143 |          0.2857 |     %0-%60 |  0.7016 | 1.0275
3000 |     2 |       0.0000 |          0.0000 |     %0-%60 |  1.0000 | 1.0000
3400 |     2 |       0.2857 |          0.2857 |     %0-%60 |  0.7016 | 1.0275
4600 | animation finished

--- effect of direction values on rounds (r = reversed) ---
direction             round 0  round 1  round 2  round 3
normal                      -        -        -        -
reverse                     r        r        r        r
alternate                   -        r        -        r
alternate-reverse           r        -        r        -

--- fill mode: values outside the 'appear' animation ---

base value: opacity=1.00 translateY=0px
  none       during delay: opacity=1.00 translateY=0px  after finishing: opacity=1.00 translateY=0px
  forwards   during delay: opacity=1.00 translateY=0px  after finishing: opacity=1.00 translateY=0px
  backwards  during delay: opacity=0.00 translateY=8px  after finishing: opacity=1.00 translateY=0px
  both       during delay: opacity=0.00 translateY=8px  after finishing: opacity=1.00 translateY=0px

base value: opacity=0.00 translateY=8px
  none       during delay: opacity=0.00 translateY=8px  after finishing: opacity=0.00 translateY=8px
  forwards   during delay: opacity=0.00 translateY=8px  after finishing: opacity=1.00 translateY=0px
  backwards  during delay: opacity=0.00 translateY=8px  after finishing: opacity=0.00 translateY=8px
  both       during delay: opacity=0.00 translateY=8px  after finishing: opacity=1.00 translateY=0px
```

Every row of the first table resolves one moment. At the 1040th millisecond, elapsed time
is 840 milliseconds, round zero, raw progress $0.6$; because this value falls exactly on
the third frame, the pair reads as `%60-%100` and the values are that frame's
declarations.

At the 2200th millisecond, two steps are visible at once. Round one, raw progress
$0.4286$; because direction is `alternate`, the timeline reverses on odd-numbered rounds
and local progress becomes $1 - 0.4286 = 0.5714$. The frame pair is looked up against this
local value.

Frame intervals are not equal in length: the first interval is 60 percent of a round, the
second 40 percent. The `ease-in-out` written on the `%0` frame only runs on the first
interval, and its effect becomes visible at the 2200th millisecond. Local progress
$0.5714$'s ratio within the first interval is $0.9523$; the curve pulls this ratio to
$0.9956$, and opacity becomes $0.3528$ — linear interpolation would have given $0.3810$. At
the exact middle of the interval, the curve is symmetric, so there is no difference: the
$0.675$ value at the 620th millisecond comes out the same both ways.

#### Direction and Iteration

`animation-iteration-count` takes a number or `infinite`. A fractional value is also
valid: writing $2.5$ cuts the animation in the middle of its third round.

The second table shows which rounds each of the four direction values reverses. `normal`
reverses none, `reverse` reverses all. `alternate` plays even-numbered rounds forward and
odd-numbered rounds reversed; `alternate-reverse` is the opposite of this.

The distinction is behavior, not numbers: with `alternate`, the animation returns to where
it started and there is no jump. With `normal`, an animation running three rounds
**jumps** from the last frame to the first frame at the end of every round. If the frames
do not carry the same value at both ends, this jump is visible.

#### Fill Mode

An animation falls outside two time intervals: the delay period and after it finishes.
`animation-fill-mode` declares which value is in effect during these intervals.

The third block computes four modes for two different base values and ties the choice to
a rule. While the base value is `opacity: 1` — that is, while the element is already
visible in the style file — `forwards` and `none` give the same result, because the
animation's last frame is also the same as the base. What makes the distinction is
`backwards`: it applies the first frame during the delay and prevents the element from
appearing for a moment and then disappearing.

While the base value is `opacity: 0`, the situation reverses. The element is hidden in the
style file; with `none`, once the animation finishes, it returns to its hidden state and
the entrance it made goes to waste. What is needed here is the `forwards` value.

The rule is: fill mode is needed when the values at the animation's ends do not match the
element's base value. If the two match, which mode is written makes no visible
difference.

#### Pulse on the Station Page

```css
/* motion.css — step 3: measurement indicator waiting for data */
@keyframes pulse {
  0%   { opacity: 1;    transform: scale(1);    animation-timing-function: ease-in-out; }
  60%  { opacity: 0.35; transform: scale(1.06); }
  100% { opacity: 1;    transform: scale(1); }
}

.station-status[data-status="waiting"] .indicator {
  animation: pulse 1400ms 200ms infinite alternate;
  transform-origin: center;
}

.station-status[data-status="offline"] .indicator {
  animation: none;
}
```

The indicator only moves in the waiting state. Status information is read from the
`data-status` attribute; attribute selectors were defined in the Visual Presentation with
CSS course. In the offline state, the animation is explicitly turned off — silence is a
declaration too.

Because the motion runs without end, a responsibility arises here: an animation that keeps
running forever is an attention-grabber the user cannot stop. This topic's last lesson
covers that responsibility.

#### Summary

- `@keyframes` defines a timeline; unwritten ends are filled with the element's base
  value, and a second rule with the same name replaces the earlier one entirely.
- The value at a given moment resolves in four steps: round number, raw progress within
  the round, reversal according to the direction value, and finding the frame pair.
- The timing function applies not to the animation as a whole but **to each frame
  interval** separately; the curve written on a frame applies to the interval after that
  frame.
- `alternate` removes the jump at round ends by playing odd-numbered rounds in reverse;
  with `normal`, there is a jump from the last frame to the first.
- Fill mode only produces a visible effect when the animation's end values differ from
  the element's base value.

#### Next Step

These three lessons defined motion, but never discussed its cost. An animation that
changes scale looks the same as one that changes width; for the browser, the two are not
the same work. The next lesson covers motion's counterpart in the rendering pipeline:
which property reruns which stage, and why does that determine how smooth the motion is?

### Animation Performance

Source: https://academia.sh/en/courses/layout-and-responsive-design/animation-performance

Motion's counterpart in the rendering pipeline; which property change reruns the layout, paint, and compositing stages, what a compositing layer is, forced synchronous layout, and the frame budget.

The previous three lessons defined motion: transform gave the geometry, transitions and
keyframes gave the time. The cost was never mentioned.

There are two ways to move a card to the right: increasing the `left` value, or writing a
translation into the `transform` list. On screen, the two look the same. For the browser,
they are not the same work. This lesson establishes where the difference comes from, as a
rule.

#### The Rendering Pipeline

A page reaching the screen goes through ordered stages. Four of them matter for motion:

1. **Style calculation.** Which declaration applies to which element is resolved; this is
   where the cascade and inheritance work. Every change runs this stage.
2. **Layout.** Every box's size and place is computed. If a box's width changes, its
   siblings, its parent, and the flow after it can be affected; the computation may not
   stay local.
3. **Paint.** A box's content — color, border, shadow, text — is turned into pixels.
4. **Composite.** Painted surfaces are ordered, transformed, and placed on the screen.

The stages are sequential: if layout reruns, paint and compositing rerun too. But the
reverse does not hold; compositing can run on its own.

#### Which Property Triggers Which Stage

```js
// performance.mjs — which pipeline stages a property change triggers, and forced layout count
const PIPELINE = ["style", "layout", "paint", "composite"];

// Rule: from which stage does a property rerun?
const START = {
  width: "layout", height: "layout", "margin-block-start": "layout", padding: "layout",
  "border-width": "layout", "font-size": "layout", "line-height": "layout",
  top: "layout", left: "layout", display: "layout", "flex-basis": "layout",
  color: "paint", "background-color": "paint", "background-image": "paint",
  "border-radius": "paint", "box-shadow": "paint", outline: "paint",
  visibility: "paint", "text-decoration": "paint",
  transform: "composite", opacity: "composite",
};

console.log("--- from which stage a property change reruns ---");
console.log("property".padEnd(20) + PIPELINE.map((a) => a.padStart(10)).join("") + "   stage count");
for (const [property, start] of Object.entries(START)) {
  const i = PIPELINE.indexOf(start);
  const mark = PIPELINE.map((_, k) => (k === 0 || k >= i ? "x" : "-").padStart(10)).join("");
  console.log(property.padEnd(20) + mark + String(PIPELINE.length - i + (i > 0 ? 1 : 0)).padStart(15));
}

console.log("\n--- two notations giving the same visual result ---");
for (const [a, b] of [["left", "transform"], ["width", "transform"], ["visibility", "opacity"]]) {
  const ia = PIPELINE.indexOf(START[a]), ib = PIPELINE.indexOf(START[b]);
  console.log(
    `${a.padEnd(12)} -> ${PIPELINE.slice(ia).join(" > ").padEnd(30)}` +
    `${b.padEnd(12)} -> ${PIPELINE.slice(ib).join(" > ")}`,
  );
}

// --- forced synchronous layout count ---
// Model: a write operation invalidates layout; a subsequent size read forces layout
// to be recomputed to read the invalidated value. One computation happens at frame end anyway.
function layoutCount(operations) {
  let dirty = false, forced = 0;
  const trace = [];
  for (const [kind, name] of operations) {
    if (kind === "write") { dirty = true; trace.push(`write ${name}`); }
    else {
      if (dirty) { forced++; dirty = false; trace.push(`read ${name} -> LAYOUT COMPUTED`); }
      else trace.push(`read ${name}`);
    }
  }
  return { forced, frameEnd: dirty ? 1 : 0, trace };
}

const INTERLEAVED = [
  ["read", "width(card 1)"], ["write", "width(card 1)"],
  ["read", "width(card 2)"], ["write", "width(card 2)"],
  ["read", "width(card 3)"], ["write", "width(card 3)"],
];
const BATCHED = [
  ["read", "width(card 1)"], ["read", "width(card 2)"], ["read", "width(card 3)"],
  ["write", "width(card 1)"], ["write", "width(card 2)"], ["write", "width(card 3)"],
];

for (const [name, operations] of [["reads and writes interleaved", INTERLEAVED], ["reads first, then writes", BATCHED]]) {
  const s = layoutCount(operations);
  console.log(`\n--- ${name} ---`);
  for (const line of s.trace) console.log("  " + line);
  console.log(`  forced layout computations: ${s.forced}, computation at frame end: ${s.frameEnd}`);
}

console.log("\n--- frame budget (arithmetic from refresh rate) ---");
for (const hz of [60, 90, 120, 144]) {
  console.log(`${String(hz).padStart(3)} fps -> ${(1000 / hz).toFixed(2)} ms per frame`);
}
```

```
--- from which stage a property change reruns ---
property                 style    layout     paint composite   stage count
width                        x         x         x         x              4
height                       x         x         x         x              4
margin-block-start           x         x         x         x              4
padding                      x         x         x         x              4
border-width                 x         x         x         x              4
font-size                    x         x         x         x              4
line-height                  x         x         x         x              4
top                          x         x         x         x              4
left                         x         x         x         x              4
display                      x         x         x         x              4
flex-basis                   x         x         x         x              4
color                        x         -         x         x              3
background-color             x         -         x         x              3
background-image             x         -         x         x              3
border-radius                x         -         x         x              3
box-shadow                   x         -         x         x              3
outline                      x         -         x         x              3
visibility                   x         -         x         x              3
text-decoration              x         -         x         x              3
transform                    x         -         -         x              2
opacity                      x         -         -         x              2

--- two notations giving the same visual result ---
left         -> layout > paint > composite    transform    -> composite
width        -> layout > paint > composite    transform    -> composite
visibility   -> paint > composite             opacity      -> composite

--- reads and writes interleaved ---
  read width(card 1)
  write width(card 1)
  read width(card 2) -> LAYOUT COMPUTED
  write width(card 2)
  read width(card 3) -> LAYOUT COMPUTED
  write width(card 3)
  forced layout computations: 2, computation at frame end: 1

--- reads first, then writes ---
  read width(card 1)
  read width(card 2)
  read width(card 3)
  write width(card 1)
  write width(card 2)
  write width(card 3)
  forced layout computations: 0, computation at frame end: 1

--- frame budget (arithmetic from refresh rate) ---
 60 fps -> 16.67 ms per frame
 90 fps -> 11.11 ms per frame
120 fps -> 8.33 ms per frame
144 fps -> 6.94 ms per frame
```

The table is not a measurement, it is a **rule set**: properties split into three groups by
their definition. Ones that touch geometry run from layout onward, ones that touch only
appearance run from paint onward, and ones that change neither the box's size nor its
content run from compositing onward alone.

A handful of properties sit at the edge of the classification — filters and blend modes,
for instance — and which stage they start from depends on the rendering implementation.
The exact information is read from the browser's developer tools rendering log; it should
not be memorized as a rule.

#### Same Appearance, Different Cost

The output's second block compares three pairs. Moving with `left` runs three stages,
moving with `transform` runs one. Hiding with `visibility` reruns paint, fading with
`opacity` does not.

From this comes the most practical rule of writing motion: **animations are written
through transform and opacity.** If a box needs to grow, `scale` is used instead of
`width`; if it needs to change place, `translate` is used instead of `left`.

The rule has a cost. `scale` also scales the box's content; text grows as the box grows.
Changing `width` re-wraps the text into new lines. The two do not give the same visual
result, and the choice depends on which result is wanted.

#### The Compositing Layer

The reason a transform is cheap is where it is applied. A painted surface is produced
once; transform and opacity are applied to that surface at the compositing stage. The
surface is not repainted, it is only placed differently.

For this, the element needs to be split off onto its own surface — a **compositing
layer**. The browser decides on this split on its own; the `will-change` declaration is a
way to announce that decision ahead of time:

```css
.measurement-cards > .card:hover { will-change: transform; }
```

`will-change` is not a guarantee, it is a **hint**. It has three rules. Splitting off a
layer costs memory; writing it on every element reverses the benefit. The declaration
should be turned on shortly before motion starts and removed once it finishes — a hint
written permanently is a permanent cost. And `will-change` also establishes a stacking
context; the warning from the first lesson applies here too.

#### Forced Synchronous Layout

The output's third and fourth blocks count, on a model, a trap that script-driven motion
often falls into. The model is this: a write operation invalidates layout; a subsequent
size-reading operation, since it has to give the current value, forces layout to be
recomputed immediately. This is called **forced synchronous layout**.

When reads and writes are interleaved, the model counts two extra computations. When the
same operations are ordered as all reads first, then all writes, there is no extra
computation; the single computation that would happen at frame's end anyway is enough.

The rule is independent of notation: operations that read a measurement are grouped into
one batch, operations that change a measurement into a separate batch. As the number of
batches grows, so does the number of computations.

#### Frame Budget

The last block is an arithmetic conversion: on a screen producing 60 frames per second,
the time per frame is $1000 / 60 \approx 16.67$ milliseconds. This is the budget shared by
every stage of a frame — script, style, layout, paint, composite.

Once the budget is exceeded, a frame is dropped and the motion stutters. This is why the
question to ask when writing motion is not "how long does it take" but "which stages run
per frame." Reducing the stage count is a more reliable path than trying to estimate
duration.

Refresh rate varies by screen; the same animation runs under different budgets. Motion
written to depend on frame count speeds up on a fast screen. Declarations written in terms
of duration do not carry this problem.

#### Limiting the Paint Area

An element being repainted can extend to its neighbors too; the paint area can be larger
than the element. The `contain` declaration limits this spread: `contain: layout` declares
that an element's layout computation will not spill outward, `contain: paint` that
painting will not extend outside the box.

This declaration makes a promise; content that does not keep the promise breaks. A shadow
or a dropdown menu overflowing a box carrying the `paint` value is clipped. The declaration
is written where a box is genuinely self-contained.

#### Measurement on the Station Page

```css
/* motion.css — step 4: limiting motion's rendering cost */
.measurement-cards > .card {
  transform-origin: center bottom;
  transition: transform 240ms ease-out, box-shadow 240ms ease-out;
  contain: layout paint;
}

.measurement-cards > .card:hover,
.measurement-cards > .card:focus-within {
  transform: translateY(-8px) scale(1.04);
  transition-duration: 160ms;
}

.station-status[data-status="waiting"] .indicator {
  animation: pulse 1400ms 200ms infinite alternate;
  will-change: transform, opacity;
}
```

The card's motion is written only through transform; the `contain` declaration limits the
card's layout and paint effect to its own box. Because the indicator moves continuously,
the `will-change` declaration is written there permanently — permanent motion is one of
the rare cases that justifies a permanent layer.

The shadow transition is a trade-off: `box-shadow` reruns paint. The same appearance can
also be reached with the opacity of a separate pseudo-element placed behind the card, and
that notation runs only compositing.

#### Summary

- The rendering pipeline is sequential: if layout runs, paint and compositing run too, but
  compositing can run on its own.
- Properties that touch geometry run from layout, ones that touch only appearance from
  paint, transform and opacity from compositing alone.
- When motion is written through transform and opacity, the stage count drops;
  measurement-changing notations produce the same appearance with more work.
- A compositing layer is the reason a transform is cheap; `will-change` is a hint that
  announces this split and is used temporarily because it costs memory.
- When measurement reads and measurement writes are interleaved, forced synchronous layout
  computations form; batching reads and writes removes this.

#### Next Step

Up to this point, how motion is written and what it costs has been established. One
question remains: is motion the same thing for every user? For some users, motion on
screen produces discomfort, dizziness, or nausea, and the operating system announces this
preference. The next lesson covers what the reduced-motion preference means and which
motion should be removed, which one kept.

### Accessible Motion

Source: https://academia.sh/en/courses/layout-and-responsive-design/accessible-motion

What motion sensitivity is, separating large-area motion from local motion, the cost of turning motion off wholesale in the reduced-motion preference, and the rules for replacing motion instead of removing it.

The previous lesson covered motion's cost to the browser. There is one more cost, and it
falls not on the browser but on the user: motion on screen produces dizziness, nausea, or
difficulty focusing in some users.

The Responsive Design topic introduced the `prefers-reduced-motion` query and showed that
the user announces this preference from the operating system. This lesson covers what goes
inside that query: which motion is removed, which is kept, and what does wholesale
shutdown cost?

#### What Motion Sensitivity Responds To

Motion sensitivity arises from a mismatch between the inner ear's balance mechanism and
visual perception. When a large area of the screen moves, the visual system produces the
impression that we ourselves are moving; the balance mechanism does not confirm this, and
the mismatch causes discomfort.

The kinds of motion that trigger it are specific: large areas sliding, layers scrolling at
different speeds, zooming in and out, rotation, oscillation. The kinds that do not trigger
it are specific too: opacity change, color change, small-area displacement.

The distinction is not "is there an animation or not." The distinction is **how much area,
over how much distance**, the motion carries.

#### Cascade and Area Calculation

The following program computes two things. First, it resolves the winning declarations
for the two states of the reduced-motion preference using cascade rules. Second, it
classifies four motions by the area they cover and the distance they travel.

```js
// reduce.mjs — cascade resolution under the preference, and the area a motion covers on screen
// Cascade: importance first, then specificity triples, then write order.
const RULES = [
  { order: 1, selector: ".indicator", specificity: [0, 1, 0], condition: null,     declaration: { "animation-name": "pulse", "animation-duration": "1400ms" } },
  { order: 2, selector: ".card",      specificity: [0, 1, 0], condition: null,     declaration: { "transition-duration": "240ms" } },
  { order: 3, selector: "*",          specificity: [0, 0, 0], condition: "reduce", declaration: { "animation-duration": "0.01ms", "transition-duration": "0.01ms" }, important: true },
  { order: 4, selector: ".indicator", specificity: [0, 1, 0], condition: "reduce", declaration: { "animation-name": "none" } },
];

const applies = (rule, preference) => rule.condition === null || rule.condition === preference;
const higher = (a, b) => {                       // comparison of specificity triples
  for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
  return false;
};

function resolve(preference, properties) {
  const result = {};
  for (const property of properties) {
    let winner = null;
    for (const r of RULES) {
      if (!applies(r, preference) || !(property in r.declaration)) continue;
      if (winner === null) { winner = r; continue; }
      const rImportant = !!r.important, wImportant = !!winner.important;
      if (rImportant !== wImportant) { if (rImportant) winner = r; continue; }
      if (higher(r.specificity, winner.specificity)) { winner = r; continue; }
      if (!higher(winner.specificity, r.specificity) && r.order > winner.order) winner = r;
    }
    result[property] = winner ? `${winner.declaration[property]}   (rule ${winner.order}: ${winner.selector}${winner.important ? " !important" : ""})` : "no declaration";
  }
  return result;
}

const PROPERTIES = ["animation-name", "animation-duration", "transition-duration"];
for (const preference of ["no-preference", "reduce"]) {
  console.log(`\n=== prefers-reduced-motion: ${preference} ===`);
  const s = resolve(preference, PROPERTIES);
  for (const p of PROPERTIES) console.log(`  ${p.padEnd(20)} -> ${s[p]}`);
}

// --- proportion of the viewport a motion covers ---
// Large-area, long-path motion is treated in a different class than small-area motion
// for users with motion sensitivity.
console.log("\n--- ratio of motion to viewport (1280 x 800 units) ---");
const W = 1280, H = 800;
const MOTIONS = [
  { name: "card highlight",     w: 220, h: 120, path: 8 },
  { name: "indicator pulse",    w: 12,  h: 12,  path: 1 },
  { name: "side panel opening", w: 320, h: 800, path: 320 },
  { name: "background shift",   w: 1280, h: 800, path: 240 },
];
console.log("motion".padEnd(20) + "area ratio".padStart(12) + "path / screen".padStart(15) + "  classification");
for (const m of MOTIONS) {
  const area = (m.w * m.h) / (W * H);
  const path = m.path / Math.max(W, H);
  const largeArea = area >= 0.25 && path >= 0.05;
  console.log(
    m.name.padEnd(20) +
    `%${(area * 100).toFixed(1)}`.padStart(12) +
    `%${(path * 100).toFixed(1)}`.padStart(15) +
    "  " + (largeArea ? "large-area motion" : "local motion"),
  );
}
```

```

=== prefers-reduced-motion: no-preference ===
  animation-name       -> pulse   (rule 1: .indicator)
  animation-duration   -> 1400ms   (rule 1: .indicator)
  transition-duration  -> 240ms   (rule 2: .card)

=== prefers-reduced-motion: reduce ===
  animation-name       -> none   (rule 4: .indicator)
  animation-duration   -> 0.01ms   (rule 3: * !important)
  transition-duration  -> 0.01ms   (rule 3: * !important)

--- ratio of motion to viewport (1280 x 800 units) ---
motion                area ratio  path / screen  classification
card highlight              %2.6           %0.6  local motion
indicator pulse             %0.0           %0.1  local motion
side panel opening         %25.0          %25.0  large-area motion
background shift          %100.0          %18.8  large-area motion
```

The second block gives a triage criterion. The card highlight covers less than three
percent of the viewport and travels eight units; the indicator pulse is even smaller. The
side panel covers a quarter of the viewport and travels a distance equal to its own width;
the background shift covers the entire screen.

The thresholds are not exact numbers, they are triage tools: motions where both the area
covered and the distance traveled are large are placed in a separate class. Motions in
this class are removed once the preference is announced; local motions can be kept,
shortened.

#### The Cost of Wholesale Shutdown

The first block resolves a commonly written pattern: a rule that writes a near-zero
duration to the universal selector with `!important`. Reading the result shows the rule's
power — the third rule suppresses even the duration of the first rule, which is more
specific than it is.

Why is the duration $0.01$ milliseconds and not $0$? A zero-duration transition does not
run at all, and its end event does not fire either; scripts waiting for motion to finish
never receive that event. A duration that is very short but greater than zero makes the
motion invisible while keeping the event chain intact.

The pattern's cost is that it makes no distinction. The same rule also suppresses:

- A form's error message appearing softly — this is not motion, it is attention
  direction.
- A dropdown menu closing — an instant close hides where the element went.
- A loading indicator spinning — it carries status information; if removed, text needs to
  take its place.

Wholesale shutdown can be written as a starting point, but if it is not reviewed component
by component, it is a half solution. This is what the fourth rule does: it turns off the
indicator's animation by name and takes on the responsibility of putting another
declaration in its place.

#### Not Removing, Replacing

The reduced-motion preference does not mean "I want no animation at all"; it asks for
large-area, distance-covering motion to be reduced. This does not mean erasing the
information carried by the motion either.

Three rules are enough:

1. **Replace displacement with opacity.** A panel that slides in can instead appear in
   place. The information is preserved, the path disappears.
2. **Shorten the duration, remove the loop.** A motion that repeats endlessly is reduced to
   a single round under the preference, or stopped.
3. **Replace status-carrying motion with text.** Text saying "waiting for data" carries the
   same information as a spinning indicator and does not move at all.

Scrolling text, auto-advancing promotional carousels, and background videos are a separate topic.
Accessibility criterion 2.2.2 requires motion that lasts more than five seconds, starts on its own, and is
presented alongside other content to be pausable; 2.3.1 forbids flashing more than three times per second
unless the flash stays below the flash thresholds; 2.3.3, a AAA criterion, requires interaction-triggered
motion to be turned off. In 2.2.2 and 2.3.3 the criterion does not apply when the motion is essential to
the activity or to the information being conveyed; the first two apply even when no preference has been
announced.

#### Choosing a Notational Direction

There are two notational directions. The first writes motion as the default and turns it
off in the `reduce` query. The second writes without motion and turns it on in the
`no-preference` query.

The second has an advantage: when the query cannot be evaluated, its condition is not met,
and motion does not turn on. That is, uncertainty resolves in favor of no motion. In the
first direction, uncertainty resolves in favor of motion.

Its cost is that motion is written in a place separate from the default state; a
component's style is split into two parts. The choice is made according to how much
weight motion carries on the page: if there are only a few small transitions throughout
the page, the first direction is enough; if motion sits at the center of the design, the
second direction is safer.

#### On the Station Page

```css
/* motion.css — step 5: reduced-motion preference */
@media (prefers-reduced-motion: reduce) {
  .measurement-cards > .card {
    transition-duration: 80ms;
  }

  .measurement-cards > .card:hover,
  .measurement-cards > .card:focus-within {
    transform: none;
    box-shadow: 0 0 0 2px var(--brand-dark);
  }

  .station-status[data-status="waiting"] .indicator {
    animation: none;
    will-change: auto;
  }

  .sidebar {
    scroll-behavior: auto;
  }
}
```

The card highlight was not removed, it was **replaced**: instead of lifting and growing, a
frame appears. The user still sees which card is highlighted, and the card does not change
place. The duration was shortened, not zeroed; a sudden change is uncomfortable too.

The indicator's animation is turned off and the layer hint is withdrawn. Status
information also needs to be given in text; this is the document's responsibility, not the
style file's, and the text next to the `.indicator` element takes on that role.

The `scroll-behavior: auto` declaration turns smooth scrolling into instant scrolling.
Because scrolling moves the entire viewport, it belongs in the large-area motion class.

#### Summary

- Motion sensitivity arises from a mismatch between visual perception and the balance
  mechanism; what triggers it is not whether an animation exists, but the size of the
  area covered and the distance traveled.
- Motions are triaged by computing the area covered and the path ratio: large-area ones
  are removed, local ones can be kept, shortened.
- An `!important` duration rule written on the universal selector suppresses every
  declaration; duration is written near zero, not zero, so that end events keep firing.
- Motion's information is not removed along with the motion: displacement is turned into
  opacity, loops are stopped, status-carrying motion is replaced with text.
- The choice between writing motion as the default and turning it off, versus writing
  without motion and turning it on, determines which direction uncertainty resolves in.

#### Next Step

Throughout this topic, a third style file named `motion.css` has grown. Now three separate
files feed declarations to the same element: visual declarations in `station.css`, layout
in `layout.css`, motion here. Class names have multiplied too, and which name belongs to
which component is known only by habit. The next topic turns this disorder into a
question: by what rules is the style layer named and divided so that it stays readable as
it grows?

## Style Architecture

Keeping specificity flat with naming conventions, managing theme and variant with custom properties, what compile-time tools do, controlling leakage with layers and scope, and style's performance cost.

### Naming Methodologies

Source: https://academia.sh/en/courses/layout-and-responsive-design/naming-methodologies

The block-element-modifier approach; keeping specificity flat, a name announcing ownership, the separation between modifier and state, and the pattern being checkable.

Three stylesheets have accumulated in this course. Visual declarations in `station.css`,
layout in `layout.css`, motion in `motion.css`. Class names have multiplied too: `.card`,
`.heading`, `.wide`, `.summary`, `.indicator`.

None of these names say who they belong to. Is `.heading` the measurement card's heading, or
the aside's? Is it safe to use the same name when a new section is added to the page? What
else breaks when a rule is deleted?

This topic ties the answer to these questions to an architecture. This lesson takes the first
step: the names themselves.

#### What Naming Solves

As a stylesheet grows, three problems appear.

**Ownership.** If which component a class name belongs to cannot be read from the name itself,
no one can change that name with confidence. The place to make the change cannot be found.

**Specificity creep.** One more class gets added to a selector to override a declaration; then
one more to override that. The triple defined in the Cascade and Specificity lesson starts
growing, and `!important` eventually gets written.

**Deletability.** If which part of the document a rule matches is unknown, that rule cannot be
deleted. The stylesheet only grows.

A naming methodology takes on all three with a single decision: **a name is derived not from
the element's place in the document, but from the component it belongs to.**

#### Block, Element, Modifier

The common methodology splits a name into three parts.

A **block** is a component that is meaningful and portable on its own. Wherever it is placed
on the page, it is the same thing: the measurement card, the navigation bar, the measurement
filter.

An **element** is part of the block and has no meaning without it: the card's heading, the
card's value, the card's unit. The name starts with the block's name and is separated by two
underscores: `measurement-card__heading`.

A **modifier** is a variant of the block or the element: a wide card, a warning card, a short
heading. It is separated by two hyphens: `measurement-card--wide`.

One term distinction is needed: here, **element** means a part of a block. Nodes in the HTML
document are called elements in this and earlier courses too. The two words name different
things; `measurement-card__heading` is an element name and can be written on an `h3` element.

#### Keeping Specificity Flat

The methodology's critical consequence is that every rule has **a single class selector**.
The program below computes selectors' specificity triples and compares the distribution of two
notations.

```js
// naming.mjs — checking selector specificity and name patterns
// Specificity triple (a, b, c): a=id, b=class/attribute/pseudo-class, c=type/pseudo-element

// :where(...) content adds no specificity; the most specific selector inside :is(...) counts.
function specificity(selector) {
  let s = selector;
  let extra = [0, 0, 0];
  s = s.replace(/:where\([^()]*\)/g, " ");                       // no contribution
  s = s.replace(/:is\(([^()]*)\)/g, (_, inner) => {              // most specific branch counts
    const branches = inner.split(",").map((d) => specificity(d.trim()));
    branches.sort((x, y) => y[0] - x[0] || y[1] - x[1] || y[2] - x[2]);
    extra = extra.map((v, i) => v + branches[0][i]);
    return " ";
  });
  const a = (s.match(/#[\w-]+/g) || []).length;
  const b = (s.match(/\.[\w-]+/g) || []).length
          + (s.match(/\[[^\]]+\]/g) || []).length
          + (s.match(/:(?!:)[\w-]+(\([^()]*\))?/g) || []).length;
  const c = (s.match(/::[\w-]+/g) || []).length
          + (s.replace(/::[\w-]+/g, " ").replace(/:(?!:)[\w-]+(\([^()]*\))?/g, " ")
             .match(/(^|[\s>+~(])([a-zA-Z][\w-]*)/g) || []).length;
  return [a + extra[0], b + extra[1], c + extra[2]];
}

const SELECTORS = [
  "#content .measurements-section ul li a.active",
  ".station-layout .measurements-section .measurement-cards .card .heading",
  "section.measurements-section > ul > li",
  ".measurement-card__heading",
  ".measurement-card--warning",
  ".measurement-card:hover",
  ".measurement-card[data-status='missing']",
  ":where(.measurement-cards) .measurement-card__heading",
  ":is(h2, .heading).measurement-card__heading",
];

console.log("--- selector specificities ---");
console.log("selector".padEnd(58) + "specificity".padStart(12) + "  depth");
for (const s of SELECTORS) {
  const [a, b, c] = specificity(s);
  const depth = s.split(/[\s>+~]+/).filter(Boolean).length;
  console.log(s.padEnd(58) + `(${a}, ${b}, ${c})`.padStart(12) + String(depth).padStart(10));
}

// --- checking the name pattern: block__element--modifier ---
const PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*(__[a-z][a-z0-9]*(-[a-z0-9]+)*)?(--[a-z][a-z0-9]*(-[a-z0-9]+)*)?$/;
const NAMES = [
  "measurement-card", "measurement-card__heading", "measurement-card--wide", "measurement-card__value--missing",
  "measurement-card__body__row", "MeasurementCard", "measurement_card", "card--", "measurement-card__heading--short",
];

console.log("\n--- name pattern check ---");
for (const name of NAMES) {
  const parts = name.split("__");
  const multiElement = parts.length > 2;
  const valid = PATTERN.test(name) && !multiElement;
  const reason = multiElement ? "nested element name" : !PATTERN.test(name) ? "off-pattern spelling" : "-";
  console.log(`  ${name.padEnd(34)} ${(valid ? "valid" : "invalid").padEnd(9)} ${reason}`);
}

// --- the specificity distribution the same component's rules produce in two notations ---
console.log("\n--- specificity of all of one component's rules ---");
const NESTED = [
  ".measurement-cards .card",
  ".measurement-cards .card .heading",
  ".measurement-cards .card .heading span",
  ".measurement-cards .card.wide .heading",
];
const FLAT = [".measurement-card", ".measurement-card__heading", ".measurement-card__unit", ".measurement-card--wide .measurement-card__heading"];
const distribution = (list) => list.map((s) => specificity(s).join("."));
console.log("  nested notation:", distribution(NESTED).join("  "));
console.log("  flat notation  :", distribution(FLAT).join("  "));
```

```
--- selector specificities ---
selector                                                   specificity  depth
#content .measurements-section ul li a.active                (1, 2, 3)         5
.station-layout .measurements-section .measurement-cards .card .heading   (0, 5, 0)         5
section.measurements-section > ul > li                       (0, 1, 3)         3
.measurement-card__heading                                   (0, 1, 0)         1
.measurement-card--warning                                   (0, 1, 0)         1
.measurement-card:hover                                      (0, 2, 0)         1
.measurement-card[data-status='missing']                     (0, 2, 0)         1
:where(.measurement-cards) .measurement-card__heading        (0, 1, 0)         2
:is(h2, .heading).measurement-card__heading                  (0, 2, 0)         2

--- name pattern check ---
  measurement-card                   valid     -
  measurement-card__heading          valid     -
  measurement-card--wide             valid     -
  measurement-card__value--missing   valid     -
  measurement-card__body__row        invalid   nested element name
  MeasurementCard                    invalid   off-pattern spelling
  measurement_card                   invalid   off-pattern spelling
  card--                             invalid   off-pattern spelling
  measurement-card__heading--short   valid     -

--- specificity of all of one component's rules ---
  nested notation: 0.2.0  0.3.0  0.3.1  0.4.0
  flat notation  : 0.1.0  0.1.0  0.1.0  0.2.0
```

The last block fits the methodology's entire justification into two lines. In the nested
notation, the same component's four rules sit at four different specificities; overriding one
with another requires knowing which selector is how specific. In the flat notation, three
rules sit at the same specificity, and only **write order** decides the ordering between them.

Deciding between rules at the same specificity is easier than deciding between rules at
different specificities: whichever is written later wins, no other computation is needed.

The table above shows two more tools. A selector wrapped in `:where()` adds nothing to
specificity — this is the way to write a container without growing the triple. `:is()`, on the
other hand, counts the most specific branch inside it, and so does grow the triple.

#### The Pattern Being Checkable

A naming methodology stays practical only if it can be checked by machine. The second block
tests nine names against a single regular expression and rejects four of them.

Three of the rejected ones are spelling-rule mistakes: an uppercase letter, a misused
underscore separator, an empty modifier. The fourth is more interesting:
`measurement-card__body__row` follows the spelling rules but contains **two** element names.

This prohibition is deliberate. A nested element name embeds the document's tree structure
into the name; when the heading is pulled out of the card's body, the name becomes wrong. A
block is flat: every element belongs directly to the block, and the tree relation between them
is not written into the name.

The same check also finds names that exist in the stylesheet but are never used in the
document. Since the name pattern is fixed, comparing the two sets is a matter of string
matching.

#### Modifier or State

A modifier is a variant of the component known **at design time**: a wide card, a warning
card. It is written when the document is produced and never changes over its lifetime.

State, on the other hand, changes **at runtime**: open, selected, loading, errored. If the two
are written with the same mechanism, which class a script is allowed to change becomes
unclear.

The way to keep the distinction is to carry state through a separate mechanism. There are two
options, and both work with either `data-` attributes or `aria-` attributes:

```css
.measurement-card[data-status="missing"] { border-color: var(--warning); }
.measurement-card[aria-expanded="true"] .measurement-card__body { display: block; }
```

The second is preferred. If the state is already announced to the accessibility tree, style
reads that same announcement; a single source does two jobs. Writing a separate state class
means keeping the same information in two places, and the two can drift apart.

#### Layer Prefixes

Naming methodologies organize the inside of a component; to organize the space between
components, adding a **layer prefix** to the name is common. Prefixes vary by approach, but
the function is the same: the name tells which layer a rule comes from.

The layers usually separated out are: layout rules that build the page skeleton, portable
components, single-declaration utility classes, and runtime states. This separation can also
be built with `@layer`, in which case there is no need to write it into the name — cascade
layers already announce a rule's order too. Layers are taken up in this topic's sixth lesson.

#### Renaming the Station Page

```css
/* components/measurement-card.css — step 1: all of the component's rules in one file */
.measurement-card {
  display: grid;
  gap: var(--spacing-0);
  padding: var(--spacing-1);
  border: 1px solid var(--line);
  transition: transform 240ms ease-out;
}

.measurement-card__heading { font-size: 1rem; color: var(--text-muted); }
.measurement-card__value   { font-size: 2rem; font-variant-numeric: tabular-nums; }
.measurement-card__unit    { color: var(--text-muted); }

.measurement-card--wide    { grid-column: span 2; }
.measurement-card--summary { grid-column: 1 / -1; }

.measurement-card[data-status="missing"] .measurement-card__value { color: var(--warning); }

.measurement-card:hover,
.measurement-card:focus-within { transform: translateY(-8px) scale(1.04); }
```

The component's visual, layout, and motion declarations are gathered into one file.
Understanding how a card looked when split across three files required reading three files;
now there is one file per component, and the file name can be read from the class name's root.

The container's grid definition — how the cards line up side by side — is not in this file.
The component knows its own inside, not its outside; outer layout stays in the
`.measurement-cards` rule.

#### Summary

- A naming methodology derives a name from the component it belongs to, not from the
  element's place in the document; it takes on ownership, specificity creep, and
  deletability together.
- A block is a portable component, an element is a part belonging to the block, a modifier is
  a variant; element names do not nest, because tree structure is not written into the name.
- In flat notation, all of a component's rules sit at the same specificity and only write
  order decides between them; in nested notation, every rule lands at a different
  specificity.
- `:where()` adds nothing to specificity, `:is()` counts the most specific branch inside it;
  both are tools for keeping the triple under control.
- A design-time variant is written with a modifier, a runtime state with an attribute; if
  accessibility attributes exist, style reads them.

#### Next Step

Every rule in the component file refers to names like `var(--line)`, `var(--warning)`,
`var(--spacing-1)`. These names were defined in the Visual Presentation with CSS course and
kept on the root element. This single-centered structure is not enough once a component needs
to set its own scale, a section needs to carry its own theme, or a modifier needs to change
only a few values. The next lesson takes up custom properties as an architectural tool.

### Custom Properties

Source: https://academia.sh/en/courses/layout-and-responsive-design/custom-properties

Custom properties being computed at the element they are declared on, inheritance carrying the computed value, fallback value chains, invalidity at computed-value time, and building a component's style interface.

Every rule in the previous lesson's component file refers to a name: `var(--line)`,
`var(--warning)`, `var(--spacing-1)`. These names were defined on the root element in the
Visual Presentation with CSS course, and that was enough for that course.

Component architecture wants more. A card variant should change only the surface color; the
aside section should carry its own color scale; a component should announce which values can
be adjusted from outside. This lesson looks at custom properties as an architectural tool, and
first clarifies the moment they are computed.

#### The Moment of Computation

A **custom property** is a name starting with two hyphens and read with `var()`. Its deciding
rule is that its value is computed **at the element it is declared on**.

This means inheritance carries not the raw text but the **computed value**. When
`--card-surface: var(--surface)` is written on the root element, the root's `--surface` value
is substituted in, and what travels down the subtree is now that color. Even if `--surface` is
redefined on a descendant element, the `--card-surface` already computed at the root does not
change.

The program below resolves this over a tree.

```js
// custom-properties.mjs — the moment custom properties are computed, inheritance, fallback values, and invalidation behavior
// Rule: a custom property's value is computed at the element WHERE IT IS DECLARED; the
// computed value is inherited by the subtree. Redefining a source property further down the
// tree does not retroactively affect a derived value already computed further up.
const TREE = {
  name: ":root",
  declarations: { "--surface": "hsl(203 18% 97%)", "--text": "hsl(211 29% 16%)",
              "--card-surface": "var(--surface)", "--spacing-1": "1.5rem" },
  children: [{
    name: ".sidebar",
    declarations: { "--surface": "hsl(203 18% 92%)" },            // only --surface redefined
    children: [{
      name: ".measurement-card",
      declarations: {},
      children: [
        { name: ".measurement-card--warning", declarations: { "--card-surface": "hsl(0 66% 96%)" }, children: [] },
        { name: ".measurement-card--plain",  declarations: { "--card-surface": "invalid-value" },   children: [] },
        { name: ".measurement-card--local", declarations: { "--card-surface": "var(--surface)" },   children: [] },
      ],
    }],
  }],
};

const VAR_PATTERN = /var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/;

// resolve var() references inside text; lookup(name) returns a custom property's value
function resolve(text, lookup) {
  let s = text, step = 0;
  while (VAR_PATTERN.test(s)) {
    if (++step > 50) return { value: null, reason: "cycle" };
    const [whole, name, fallback] = s.match(VAR_PATTERN);
    const found = lookup(name);
    if (found && found.reason) return found;                       // cycle propagates upward
    if (!found || found.value === null) {
      if (fallback === undefined) return { value: null, reason: `${name} undefined, no fallback` };
      s = s.replace(whole, fallback);
    } else {
      s = s.replace(whole, found.value);
    }
  }
  return { value: s.trim(), reason: null };
}

// an element's custom property map: the inherited map + its own declarations computed
function propertyMap(node, inherited) {
  const result = { ...inherited };
  const resolving = new Set();
  const lookup = (name) => {
    if (name in node.declarations) {
      if (resolving.has(name)) return { value: null, reason: "cycle" };
      resolving.add(name);
      const r = resolve(node.declarations[name], lookup);
      resolving.delete(name);
      return r;
    }
    return name in inherited ? { value: inherited[name], reason: null } : null;
  };
  for (const name of Object.keys(node.declarations)) {
    const r = lookup(name);
    result[name] = r.value;                                   // stays null if invalid
  }
  return result;
}

const VALID_COLOR = /^(hsl\(|rgb\(|#)/;
function walk(node, inherited = {}, path = []) {
  const props = propertyMap(node, inherited);
  const name = [...path, node.name];
  const r = resolve("var(--card-surface, var(--surface))", (n) => (n in props ? { value: props[n], reason: null } : null));
  let outcome;
  if (r.value === null) outcome = `INVALID (${r.reason})`;
  else if (!VALID_COLOR.test(r.value)) outcome = `"${r.value}" -> invalid at computed-value time`;
  else outcome = r.value;
  console.log("  " + name.join(" > ").padEnd(66) + outcome);
  for (const child of node.children) walk(child, props, name);
}

console.log("--- in the tree, background-color: var(--card-surface, var(--surface)) ---");
walk(TREE);

console.log("\n--- fallback value chains at the root element ---");
const ROOT = propertyMap(TREE, {});
const rootLookup = (n) => (n in ROOT ? { value: ROOT[n], reason: null } : null);
for (const expr of [
  "var(--surface)",
  "var(--missing)",
  "var(--missing, var(--surface))",
  "var(--missing, var(--also-missing, hsl(0 0% 100%)))",
  "calc(var(--spacing-1) * 2)",
]) {
  const r = resolve(expr, rootLookup);
  console.log(`  ${expr.padEnd(53)} -> ${r.value === null ? "INVALID: " + r.reason : r.value}`);
}

console.log("\n--- cycle ---");
const D = { name: ":root", declarations: { "--a": "var(--b)", "--b": "var(--a)" }, children: [] };
console.log("  --a: var(--b); --b: var(--a);  -> --a value:", propertyMap(D, {})["--a"]);
```

```
--- in the tree, background-color: var(--card-surface, var(--surface)) ---
  :root                                                             hsl(203 18% 97%)
  :root > .sidebar                                                  hsl(203 18% 97%)
  :root > .sidebar > .measurement-card                              hsl(203 18% 97%)
  :root > .sidebar > .measurement-card > .measurement-card--warning hsl(0 66% 96%)
  :root > .sidebar > .measurement-card > .measurement-card--plain   "invalid-value" -> invalid at computed-value time
  :root > .sidebar > .measurement-card > .measurement-card--local   hsl(203 18% 92%)

--- fallback value chains at the root element ---
  var(--surface)                                        -> hsl(203 18% 97%)
  var(--missing)                                        -> INVALID: --missing undefined, no fallback
  var(--missing, var(--surface))                        -> hsl(203 18% 97%)
  var(--missing, var(--also-missing, hsl(0 0% 100%)))   -> hsl(0 0% 100%)
  calc(var(--spacing-1) * 2)                            -> calc(1.5rem * 2)

--- cycle ---
  --a: var(--b); --b: var(--a);  -> --a value: null
```

The second row is the lesson's most commonly missed point. The `.sidebar` element changes the
`--surface` value, but its surface stays `hsl(203 18% 97%)` — because the `--card-surface` it
reads was already computed on the root element.

The last row shows the fix: once `--card-surface` is redeclared in the subtree, `var(--surface)`
is resolved at that element and comes out `hsl(203 18% 92%)`. The rule is this: if a derived
value needs to be affected by context, the derived value must be redeclared in that context.

The `calc(var(--spacing-1) * 2)` line shows one more detail: substitution happens only at the
text level. Whether the value makes sense is tested only after substitution finishes.

#### Fallback Value and Invalidity

`var()` takes a second argument: the **fallback value** to use when the name is undefined.
Fallback values can be nested, and the output's second block resolves a two-level chain.

There is one case where the fallback does not kick in, and that is where the trap lies. If the
name **is defined** but its value makes no sense where it is used, the fallback does not run.
The `--card-surface: invalid-value` declaration is defined; `background-color` takes this
value and becomes invalid at that point.

This condition is called **invalid at computed-value time**, and its result differs from
invalidity during parsing:

- A declaration invalid during parsing is **discarded**; a previous declaration writing to the
  same property stays in place.
- A declaration invalid at computed-value time is not discarded; it puts the property into the
  `unset` state. That is, the inherited value applies for inherited properties, and the
  initial value for non-inherited ones.

Because the previous declaration does not stay in place, the result is usually unexpected.
When a color declaration falls because of a wrong custom property, the element's color comes
from its parent instead.

A cycle is a separate case: two custom properties referring to each other produce no value at
all, and both are treated as undefined.

#### Registered Properties

The `@property` rule **registers** a custom property: it declares its value's syntax, whether
it is inherited, and its initial value.

```css
@property --card-surface {
  syntax: "<color>";
  inherits: true;
  initial-value: hsl(203 18% 97%);
}
```

Registration has three consequences. The first is type checking: a value that does not match the declared
syntax is invalid at computed-value time, but it does not fall to `unset` the way an unregistered property
would — it returns to the registration's initial value. The second is that inheritance becomes
controllable; a property written with `inherits: false` is valid only on the element it is declared on.
The third concerns motion: an unregistered custom property is only text and its intermediate value cannot
be computed, while a registered color or length can take part in a transition or keyframes.

The third consequence connects with the motion declarations in the previous topic: a color
scale itself becomes something that can be animated.

#### A Component's Style Interface

The rules so far arrive at an architectural pattern. A component defines the adjustment points
it exposes to the outside as custom properties; internally it reads only these names.

```css
.measurement-card {
  --card-surface: var(--surface);
  --card-border: var(--line);
  --card-lift-distance: -8px;

  background-color: var(--card-surface);
  border: 1px solid var(--card-border);
}

.measurement-card:hover { transform: translateY(var(--card-lift-distance)); }

.measurement-card--warning { --card-surface: hsl(0 66% 96%); --card-border: var(--warning); }
.measurement-card--still { --card-lift-distance: 0px; }
```

Modifiers now **give a value** instead of overriding a declaration. The
`.measurement-card--warning` rule does not write `background-color`; it only changes the name
the component reads. This has three consequences: no specificity contest arises, the rule
count does not grow as the number of variants grows, and what the component allows to be
adjusted can be read from the first lines of the file.

The name prefix is also a contract: names starting with `--card-` are the component's
interface, unprefixed names like `--surface` come from the page scale. If the two get mixed
up, what the component expects from outside becomes unclear.

#### Theme Management

Theming is filling the same interface with different values. A two-layer scale separates
this: names holding raw values, and names naming a function. A theme changes only the second
layer.

```css
:root {
  --gray-95: hsl(203 18% 97%);
  --gray-15: hsl(211 29% 16%);

  --surface: var(--gray-95);
  --text: var(--gray-15);
}

[data-theme="dark"] {
  --surface: var(--gray-15);
  --text: var(--gray-95);
}
```

Because the theme is declared with an attribute, it can also be applied at the subtree scale:
the whole page can be light while a single section is dark. Inheritance provides this on its
own; no extra rule is needed.

The same structure can also be driven by a media query — the user's color scheme preference
writes the same two declarations inside a query instead of an attribute. If both sources are
used together, which one takes priority is a decision: the user's choice made within the page
usually overrides the system preference, because it is written later and more specifically.

#### Summary

- A custom property is computed at the element it is declared on; the computed value, not the
  raw text, is inherited by the subtree.
- For a derived value to be affected by context, it must be redeclared in that context;
  changing the source name in the subtree does not change a derivative already computed
  further up.
- The fallback value runs only when the name is **undefined**; a defined but nonsensical
  value produces invalidity at computed-value time and puts the property into the `unset`
  state.
- An `@property` registration declares syntax, inheritance, and an initial value; registered
  properties gain type checking and can take part in transitions.
- Once a component exposes its adjustment points as prefixed custom properties, variants give
  a value instead of overriding a declaration; specificity stays flat and the interface
  becomes readable.

#### Next Step

Every name here is resolved in the browser, at runtime. A separate class of tools does part of
the same work before the document ever reaches the browser: flattening nested notation,
keeping repeated blocks in one place, merging files. The next lesson takes up style tools that
run at compile time, and separates which work should be left to runtime.

### Preprocessors and Postprocessors

Source: https://academia.sh/en/courses/layout-and-responsive-design/preprocessors-and-postprocessors

Style tools that run at compile time; flattening nested notation, the cost of body copying, the distinction between a compile-time value and a runtime value, and postprocessor transforms.

Every name in the previous lesson was resolved in the browser, while the page was running. A
separate class of tools does part of the same work earlier: before the file ever reaches the
browser, during compilation.

This lesson takes up what those tools do, which work belongs to compile time and which to
runtime, and the cost of the compile step. Not a specific tool, but two classes of tools are
described.

#### Two Tool Classes

A **preprocessor** takes a non-CSS source language as input and produces CSS. The source
language adds writing conveniences on top of CSS: nested rule notation, compile-time
variables, reused declaration bodies, file splitting and importing, loops and conditions.

A **postprocessor** takes CSS and produces CSS. Its job is transformation: turning one
notation into another, dropping unnecessary characters, merging rules, removing unused rules.

The boundary is not strict; both are program transformers that run at compile time. What makes
the distinction is whether the input is CSS. Both can be used one after the other in a build
chain.

#### Flattening Nested Notation

The most-used convenience is nested notation. In the source, rules are written inside one
another, and at the end of compilation they are expanded into flat rules.

```js
// preprocessing.mjs — flattening nested notation and counting compile-time repetition
// A tree of nested rules is flattened into flat rules at compile time.
const SOURCE = {
  selector: ".measurement-card",
  declarations: ["display: grid", "gap: var(--spacing-0)"],
  nested: [
    { selector: "&__heading", declarations: ["color: var(--text-muted)"], nested: [] },
    { selector: "&__value", declarations: ["font-size: 2rem"], nested: [
      { selector: "&--missing", declarations: ["color: var(--warning)"], nested: [] },
    ] },
    { selector: "&:hover", declarations: ["transform: translateY(-8px)"], nested: [] },
    { selector: ".measurement-cards &", declarations: ["margin: 0"], nested: [] },
    { selector: "p", declarations: ["margin-block: 0"], nested: [] },              // no &: descendant combinator
    { selector: "@media (min-width: 48rem)", declarations: [], nested: [
      { selector: "&", declarations: ["gap: var(--spacing-1)"], nested: [] },
    ] },
  ],
};

function flatten(node, parentSelector = null, condition = null, output = []) {
  const isConditional = node.selector.startsWith("@");
  const newCondition = isConditional ? node.selector : condition;
  let selector = parentSelector;
  if (!isConditional) {
    if (parentSelector === null) selector = node.selector;
    else if (node.selector.includes("&")) selector = node.selector.replaceAll("&", parentSelector);
    else selector = `${parentSelector} ${node.selector}`;
  }
  if (node.declarations.length > 0) output.push({ condition: newCondition, selector, declarations: node.declarations });
  for (const child of node.nested) flatten(child, selector, newCondition, output);
  return output;
}

// specificity triple: a=id, b=class/attribute/pseudo-class, c=type
const specificity = (s) => [
  (s.match(/#[\w-]+/g) || []).length,
  (s.match(/\.[\w-]+/g) || []).length + (s.match(/\[[^\]]+\]/g) || []).length + (s.match(/:(?!:)[\w-]+/g) || []).length,
  (s.replace(/:(?!:)[\w-]+/g, " ").match(/(^|[\s>+~])([a-zA-Z][\w-]*)/g) || []).length,
];

console.log("--- flattening the nested source ---");
for (const rule of flatten(SOURCE)) {
  const [a, b, c] = specificity(rule.selector);
  const prefix = rule.condition ? `${rule.condition} { ` : "";
  const suffix = rule.condition ? " }" : "";
  console.log(`  ${(prefix + rule.selector + " { " + rule.declarations.join("; ") + " }" + suffix).padEnd(78)} (${a}, ${b}, ${c})`);
}

// --- compile-time repetition: the body is copied on every call ---
console.log("\n--- two ways to share the same declaration set ---");
const BODY = ["border: 1px solid var(--line)", "border-radius: 4px", "padding: var(--spacing-1)"];
const bodyBytes = BODY.join("; ").length + 2;
const COMPONENTS = [".measurement-card", ".aside__box", ".masthead", ".location-section__panel", ".measurement-filter"];

let duplicated = 0;
for (const comp of COMPONENTS) duplicated += comp.length + 3 + bodyBytes;
const sharedSelector = COMPONENTS.join(", ");
const shared = sharedSelector.length + 3 + bodyBytes;

console.log(`  body: ${BODY.length} declarations, ${bodyBytes} bytes`);
console.log(`  notation copying the body to ${COMPONENTS.length} components : ${duplicated} bytes`);
console.log(`  single rule with a shared selector list       : ${shared} bytes`);
console.log(`  difference: ${duplicated - shared} bytes (%${(((duplicated - shared) / duplicated) * 100).toFixed(1)} less)`);

// --- the distinction between a compile-time value and a runtime value ---
console.log("\n--- same name, two moments of resolution ---");
const COMPILE = { "$spacing": "1.5rem" };                    // substituted at compile time
const generated = "padding: $spacing;".replace(/\$[\w-]+/g, (m) => COMPILE[m]);
console.log("  compile-time variable  -> generated CSS:", generated);
console.log("  runtime property        -> generated CSS: padding: var(--spacing-1);");
console.log("  in the first notation, the name 'spacing' does not exist in the generated file; the value is fixed.");
```

```
--- flattening the nested source ---
  .measurement-card { display: grid; gap: var(--spacing-0) }                     (0, 1, 0)
  .measurement-card__heading { color: var(--text-muted) }                        (0, 1, 0)
  .measurement-card__value { font-size: 2rem }                                   (0, 1, 0)
  .measurement-card__value--missing { color: var(--warning) }                    (0, 1, 0)
  .measurement-card:hover { transform: translateY(-8px) }                        (0, 2, 0)
  .measurement-cards .measurement-card { margin: 0 }                             (0, 2, 0)
  .measurement-card p { margin-block: 0 }                                        (0, 1, 1)
  @media (min-width: 48rem) { .measurement-card { gap: var(--spacing-1) } }      (0, 1, 0)

--- two ways to share the same declaration set ---
  body: 3 declarations, 78 bytes
  notation copying the body to 5 components : 485 bytes
  single rule with a shared selector list       : 169 bytes
  difference: 316 bytes (%65.2 less)

--- same name, two moments of resolution ---
  compile-time variable  -> generated CSS: padding: 1.5rem;
  runtime property        -> generated CSS: padding: var(--spacing-1);
  in the first notation, the name 'spacing' does not exist in the generated file; the value is fixed.
```

Flattening rests on a single rule: if the nested rule carries `&`, it is substituted for the
parent selector; if not, a descendant combinator is inserted between it and the parent
selector.

This distinction shows up in the output's seventh line. Since the `p` selector carries no `&`,
it becomes `.measurement-card p`; its triple comes to $(0, 1, 1)$. In the fourth line, on the
other hand, the `&--missing` notation **merges** the selector into a single class, and the
triple stays $(0, 1, 0)$. The previous lesson's naming methodology works together with nested
notation only in this second form.

The sixth line shows that the `&` mark can also be written at the end: the `.measurement-cards &` notation
places the component inside a context and grows the triple.

One warning is needed: nested notation is also defined in CSS itself. In that case, the
browser does the flattening, not a compiler, and the rule stays the same. So nested notation
alone does not require a compile step.

#### The Cost of Repetition

Preprocessors' second common convenience is naming a declaration body and calling it in more
than one place. The call is **copied** at compile time; in the generated file, the same
declarations sit separately under every selector.

The output's second block counts this for five components: the copying notation comes to 485
bytes, a single rule written with a shared selector list comes to 169 bytes. The difference
grows in direct proportion to how many times the body is called.

This does not mean sharing a body is forbidden; it gives the criterion for the decision. If
the body is longer than a few declarations and is called in many places, a shared selector
list or a shared class produces fewer bytes. For short bodies called in few places, copying
improves readability.

#### Compile-Time Value vs. Runtime Value

The third block compares the two kinds of variables over the same result. A compile-time
variable leaves **no trace** in the generated file: `padding: 1.5rem` is what is written, the
name `spacing` does not exist. A runtime property, on the other hand, stays in the file and is
resolved in the browser.

The distinction decides which one to pick:

- If the value needs to change with context — with a media query, a user preference, an
  attribute value, a script — a **runtime** property is needed. A compile-time value can see
  none of these.
- If the value is only for organizing the source — a file path, a breakpoint number, a loop
  bound — a compile-time variable is enough and shrinks the generated file.

Mixed use is also common: a source scale is computed at compile time, and the computed values
are written into runtime properties.

#### What Postprocessors Do

Postprocessor transforms can be grouped into four categories.

**Syntax expansion.** A property's notation that is not yet resolved everywhere is generated
together with an equivalent older notation. The source stays in one notation, the generated
file carries both.

**Minification.** Whitespace, comments, and unnecessary semicolons are dropped; colors and
numbers are converted to their shortest equivalent notation. Meaning does not change, byte
count drops.

**Merging.** Media queries carrying the same condition are gathered into a single block;
selectors sharing the same declaration set are merged into a single rule.

**Unused-rule pruning.** Selectors that match nothing in the document are removed. This
transform is risky: if a class name is produced at runtime — assembled from an attribute value
or added by a script — the pruner cannot see that name and deletes the needed rule. This is
why names appearing as full literal text in the source becomes a writing rule.

#### The Cost of the Compile Step

The compile step brings three costs. The first is setup: source files do not run directly,
every change has to go through a build command.

The second is debugging distance. The browser shows the generated file, not the file that was
written. A **source map** closes this distance: a side file is generated that keeps each
position in the generated file's counterpart in the source, and developer tools show the rule
by its source line.

The third is dependency. If the source language is not CSS, files cannot be read without that
language's tool chain. This cost shrinks as the source language's conveniences move into the
platform itself.

#### The Station Page's Source

```css
/* components/measurement-card.css — step 3: the same component in nested notation */
.measurement-card {
  display: grid;
  gap: var(--spacing-0);

  &__heading { color: var(--text-muted); }
  &__value   { font-size: 2rem; font-variant-numeric: tabular-nums; }
  &__value--missing { color: var(--warning); }

  &:hover,
  &:focus-within { transform: translateY(var(--card-lift-distance, -8px)); }

  @media (prefers-reduced-motion: reduce) {
    &:hover,
    &:focus-within { transform: none; }
  }
}
```

All of the component's rules are gathered into a single block, and all of them produce single
class selectors merged with `&`. The media query also sits inside the component; the
reduced-motion rule is no longer in a separate file, it sits next to the component it belongs
to.

This notation's limit is nesting depth. Nesting more than two levels deep makes the generated
selectors hard to read, and an inner rule carrying no `&` silently grows the triple.

#### Summary

- A preprocessor produces CSS from a non-CSS source language; a postprocessor takes CSS and
  produces CSS. Both run at compile time and their boundary is not strict.
- Nested notation flattens by a single rule: if `&` is present it is substituted for the
  parent selector, if not a descendant combinator is inserted and the specificity triple
  grows.
- Every call to a named declaration body is copied into the generated file; at many calls, a
  shared selector list produces fewer bytes.
- A compile-time value leaves no trace in the generated file and cannot see context; any value
  that needs to change with context must be a runtime property.
- Unused-rule pruning can delete needed rules if class names do not appear as full literal
  text in the source.

#### Next Step

The compile step did not solve one problem: names are still global. If the same class name is
written in two separate component files, the rules get mixed up, and the previous lesson's
naming methodology prevents this only by **convention** — if no one checks, a collision
happens. The next lesson takes up approaches that genuinely scope names.

### Scoped Styling Approaches

Source: https://academia.sh/en/courses/layout-and-responsive-design/scoped-styling-approaches

Approaches that genuinely limit the scope of names; generated local names, class sharing by declaration hash, the document scope declaration, shadow tree encapsulation, and comparing the guarantees each approach gives.

A problem was left open at the end of the previous lesson. A naming methodology prevents
collisions, but only by **convention**: if two developers give the same block the same name,
no tool warns them, and the rules silently mix together.

This lesson takes up approaches that move a name's scope out of convention and into a
mechanism. First, what the word "scope" guarantees needs to be separated out.

#### What Scope Guarantees

Style leakage is not a single event; it is three separate events.

**Name collision.** Two separate components use the same class name; one's rules also match
the other's elements.

**Leak-in.** A general rule written outside the component — `p`, `a`, `* + *`, and the like —
matches elements inside the component.

**Leak-out.** A rule written inside the component matches elements outside the component. This
is the risk every rule written with a descendant combinator carries.

An approach being counted as "scoped" does not mean it solves all three. The program below
first computes two generation methods, then compares which direction each of five approaches
blocks.

```js
// scoping.mjs — generating scoped local names, and sharing classes by declaration hash
import { createHash } from "node:crypto";

const hash = (text, length = 5) =>
  createHash("sha256").update(text).digest("hex").slice(0, length);

// --- 1. local name + file path -> unique generated name ---
const generate = (path, localName) => `${localName}_${hash(`${path}#${localName}`)}`;

const INPUTS = [
  ["components/measurement-card.css", "card"],
  ["components/measurement-card.css", "heading"],
  ["components/sidebar.css", "card"],          // same local name, different file
  ["components/sidebar.css", "heading"],
  ["components/measurement-card.css", "card"],         // same input: must generate the same name
];

console.log("--- mapping local names to generated names ---");
const generatedNames = new Map();
for (const [path, name] of INPUTS) {
  const g = generate(path, name);
  console.log(`  ${path.padEnd(34)} .${name.padEnd(10)} -> .${g}`);
  generatedNames.set(`${path}#${name}`, g);
}
const collision = new Set([...generatedNames.values()]).size !== generatedNames.size;
console.log(`  distinct input count: ${generatedNames.size}, generated name count: ${new Set([...generatedNames.values()]).size}, collision: ${collision}`);

// --- 2. sharing a class by the hash of the declaration set ---
const normalize = (declarations) =>
  declarations.map((d) => d.replace(/\s+/g, " ").trim()).sort().join(";");
const className = (declarations) => "b" + hash(normalize(declarations), 7);

const COMPONENTS = {
  "measurement-card": ["padding: 1.5rem", "border: 1px solid #ccd", "border-radius: 4px"],
  "sidebar-box":        ["border-radius: 4px", "padding:  1.5rem", "border: 1px solid #ccd"],  // same set
  "masthead":         ["padding: 1.5rem", "border: 1px solid #ccd"],
  "location-panel":   ["padding: 1.5rem", "border: 1px solid #ccd", "border-radius: 4px"],
};

console.log("\n--- class generated by the hash of the declaration set ---");
const generatedRules = new Map();
for (const [name, declarations] of Object.entries(COMPONENTS)) {
  const cls = className(declarations);
  generatedRules.set(cls, normalize(declarations));
  console.log(`  ${name.padEnd(18)} -> .${cls}`);
}
console.log(`  component count: ${Object.keys(COMPONENTS).length}, generated rule count: ${generatedRules.size}`);
for (const [cls, body] of generatedRules) console.log(`    .${cls} { ${body.replaceAll(";", "; ")} }`);

// --- 3. the guarantees scoping approaches give ---
console.log("\n--- which approach guarantees what ---");
const APPROACHES = [
  { name: "naming convention alone", nameCollision: false, leakIn: false, leakOut: false },
  { name: "generated local name",    nameCollision: true,  leakIn: false, leakOut: true },
  { name: "runtime generation",      nameCollision: true,  leakIn: false, leakOut: true },
  { name: "document scope declaration", nameCollision: false, leakIn: false, leakOut: true },
  { name: "shadow tree encapsulation", nameCollision: true,  leakIn: true,  leakOut: true },
];
console.log("approach".padEnd(26) + "name collision".padStart(16) + "leak-in".padStart(12) + "leak-out".padStart(12));
for (const a of APPROACHES) {
  const blocked = (v) => (v ? "blocked" : "not blocked");
  console.log(a.name.padEnd(26) + blocked(a.nameCollision).padStart(16) + blocked(a.leakIn).padStart(12) + blocked(a.leakOut).padStart(12));
}
```

```
--- mapping local names to generated names ---
  components/measurement-card.css    .card       -> .card_574af
  components/measurement-card.css    .heading    -> .heading_3232f
  components/sidebar.css             .card       -> .card_fb1b0
  components/sidebar.css             .heading    -> .heading_85231
  components/measurement-card.css    .card       -> .card_574af
  distinct input count: 4, generated name count: 4, collision: false

--- class generated by the hash of the declaration set ---
  measurement-card   -> .b3fd5e52
  sidebar-box        -> .b3fd5e52
  masthead           -> .bc3527a6
  location-panel     -> .b3fd5e52
  component count: 4, generated rule count: 2
    .b3fd5e52 { border-radius: 4px; border: 1px solid #ccd; padding: 1.5rem }
    .bc3527a6 { border: 1px solid #ccd; padding: 1.5rem }

--- which approach guarantees what ---
approach                    name collision     leak-in    leak-out
naming convention alone        not blocked not blocked not blocked
generated local name               blocked not blocked     blocked
runtime generation                 blocked not blocked     blocked
document scope declaration     not blocked not blocked     blocked
shadow tree encapsulation          blocked     blocked     blocked
```

#### Generated Local Names

In the first approach, short, readable names are written in the source files: `.card`,
`.heading`. A compile step hashes these names together with the file path into unique names,
and the template that produces the document uses the same mapping.

The output's first block shows two properties. The name `.card` in two separate files is
translated into two different names — collision is structurally impossible. The same input
always generates the same name; generation is **deterministic**, giving the same output across
two separate compiles.

Putting the local name at the front of the generated name is deliberate: seeing
`.card_574af` in developer tools tells you which source name it is. Names made of nothing but a
hash lose this readability.

The approach's limit is that a name **cannot be assembled at runtime**. If a name is produced
by string concatenation, the compiler cannot see that name and cannot map it; names must
appear as full literal text in the source.

#### Sharing by Declaration Hash

In the second approach, the class name is derived from the declarations themselves. The
declaration set is sorted and reduced to a single piece of text, and that text's hash becomes
the class name.

The output's second block produces two rules for four components. Three components'
declarations are the same — the write order and extra whitespace differ, but the normalized
text is identical — and all three share the same class. The one component whose declaration
set differs gets its own class.

The result is that the generated file grows not with the number of components but with the
number of **distinct declaration sets**. Compared to the previous lesson's notation that
copies the same body, repetition disappears on its own.

Its cost falls into three categories. If generation happens at runtime, rules may be added to
the document after the first paint. The cascade order of rules depends on generation order and
cannot be predicted by reading the source. Hashed names are not readable; a debugging tool
shows the rule but not which component it came from.

#### Document Scope Declaration

CSS's own tool is the `@scope` rule. It takes a **scoping root** and an optional **scoping
limit**; the rules inside it match only elements in the root's subtree, outside the limit.

```css
@scope (.measurement-card) to (.measurement-card__body) {
  p { margin-block: 0; }
  a { color: var(--brand-dark); }
}
```

The `p` rule here applies only inside the measurement card, and only before entering the
card's body. Written without scope, the same rule would match every paragraph on the page.

`@scope` brings one more novelty: between two scoped rules writing to the same property, the one whose
scoping root is **closer** to the element wins. This criterion is evaluated after specificity and before
order of appearance. This is why, in two nested theme scopes, the inner theme wins.

The table's limit matters here: `@scope` blocks leak-out, it does not block name collision. If
two components use the same class name and both write unscoped rules, the problem persists.

#### Shadow Tree Encapsulation

The one approach that blocks all three directions at once is the shadow tree. A stylesheet
inside a shadow root built with Web Components applies only to that tree; outside selectors do
not match inward, inside selectors do not match outward.

Encapsulation has three deliberate gaps. Inherited properties cross the boundary: font and
color flow from outside in. Custom properties are inherited too, so a component's style
interface is filled from outside through the same mechanism. The `::part` pseudo-element lets
the parts a component exposes outward be targeted.

These gaps are not a flaw, they are a design: full isolation would close the component off from
theming.

#### The Selection Criterion

The approaches do not exclude each other, and the choice depends on three questions. Are
components written by separate teams — is name collision a real risk? Does style arrive on
the page from outside — is protection against leak-in needed? Is a compile step already in
place?

On a small page, a naming convention together with `@scope` is enough; neither needs a compile
step. Where components are distributed independently, generated names or a shadow tree are
needed.

#### In the Station Page

```css
/* components/measurement-card.css — step 4: scoped notation */
@scope (.measurement-card) {
  :scope {
    display: grid;
    gap: var(--spacing-0);
    background-color: var(--card-surface, var(--surface));
  }

  .measurement-card__value { font-size: 2rem; font-variant-numeric: tabular-nums; }

  p  { margin-block: 0; }
  dt { color: var(--text-muted); }
}
```

The `:scope` pseudo-class targets the scoping root itself. Type selectors can now be written
with confidence: the `p` and `dt` rules apply only inside the card and do not leak into the
rest of the page.

Class names were still written methodically. Scope cuts off leak-out, but if another
`.measurement-card__value` is defined elsewhere on the page, the two rules still compete.

#### Summary

- Style leakage is three separate events: name collision, leak-in, and leak-out; an approach
  may not block all three at once.
- Generated local names structurally prevent collision by hashing the local name together
  with the file path, and are deterministic; they work on the condition that the name is not
  assembled at runtime.
- Classes generated by declaration hash let components share the same declaration set; the
  generated file grows with the number of distinct declaration sets, not the number of
  components.
- `@scope` takes a root and a limit, blocks leak-out, and applies its proximity criterion
  after specificity; it does not prevent name collision.
- The shadow tree closes off all three directions; inherited properties, custom properties,
  and `::part` are deliberately left-open gateways.

#### Next Step

Every approach up to this point shares the same assumption: style is written per component,
and each component has its own rules. There is an approach that argues the opposite —
never writing component-specific rules at all, building the document out of predefined,
single-declaration classes. The next lesson takes up this approach and the constraint
philosophy it rests on.

### The Utility Class Approach

Source: https://academia.sh/en/courses/layout-and-responsive-design/utility-class-approach

Constraint-based style systems; single-declaration classes taking a value from a closed scale, the set growing independent of component count, repetition moving from the stylesheet into the document, and where the approach breaks.

Every approach up to this point has shared the same assumption: each component has its own
rules, and the stylesheet grows component by component. There is an approach that argues the
opposite.

The idea is this: no component-specific rule is ever written. The document is built out of
predefined, single-declaration classes. Box design happens not in the stylesheet, but in the
document.

#### The Constraint Idea

A **utility class** is a class that writes a single declaration and takes its value from a
closed scale. Its name announces what it does: `p-1` gives a padding value, `gap-0` gives a
gap value, `bg-surface` gives a surface color.

The approach's name evokes brevity, but its real justification is not brevity, it is
**constraint**. The scale is closed: if there are five values for padding, there is no way to
write a sixth. Design consistency stops being a review question; the system itself makes an
arbitrary value impossible.

The second consequence concerns naming. In the utility class approach, no new name is
**generated**; the names in the set are known from the start. The previous three lessons'
problems — name collision, ownership, deletability — do not disappear, they become moot: the
class belongs to no one.

#### The Set's Bounded Growth

```js
// utility.mjs — the utility class set's bounded growth, compared against component notation
// Utility class: a class writing a single declaration, taking its value from a closed scale.

// Closed scale: the number of selectable values for each property is fixed.
const SCALE = {
  "padding":       5,   // --spacing-0 .. --spacing-4
  "margin-block":  5,
  "gap":           5,
  "font-size":     6,
  "color":         8,
  "background":    8,
  "border-radius": 3,
  "display":       4,
};
const PREFIXES = ["", "hover:", "focus:", "wide:"];   // wide: wide-screen breakpoint

const utilityCount = Object.values(SCALE).reduce((t, n) => t + n, 0) * PREFIXES.length;
const UTILITY_BYTES = 30;                          // example: ".p-3{padding:var(--spacing-3)}"
const utilityTotal = utilityCount * UTILITY_BYTES;

console.log("--- utility class set (independent of component count) ---");
console.log(`  scale: ${Object.keys(SCALE).length} properties, ${Object.values(SCALE).reduce((t, n) => t + n, 0)} values total`);
console.log(`  prefix count: ${PREFIXES.length} (${PREFIXES.map((p) => p || "(no prefix)").join(", ")})`);
console.log(`  generated class count: ${utilityCount}`);
console.log(`  approximate size: ${utilityTotal} bytes`);

// Component notation: every new component adds new rules and new bytes.
const COMPONENT_RULES = 5;                           // average rules per component
const RULE_BYTES = 62;                               // average selector + declaration body

console.log("\n--- size of the two notations by component count ---");
console.log("components".padStart(10) + "component notation".padStart(20) + "utility notation".padStart(19) + "  smaller");
for (const n of [5, 10, 20, 40, 60, 80, 120]) {
  const componentTotal = n * COMPONENT_RULES * RULE_BYTES;
  console.log(
    String(n).padStart(10) +
    `${componentTotal} bytes`.padStart(20) +
    `${utilityTotal} bytes`.padStart(19) +
    "  " + (componentTotal < utilityTotal ? "component notation" : "utility notation"),
  );
}
const threshold = Math.ceil(utilityTotal / (COMPONENT_RULES * RULE_BYTES));
console.log(`  threshold: past ${threshold} components, the utility set stays smaller`);

// --- how many times the same declaration repeats across components ---
const COMPONENTS = {
  "measurement-card": ["padding: var(--spacing-1)", "border-radius: 4px", "display: grid", "gap: var(--spacing-0)"],
  "sidebar-box":        ["padding: var(--spacing-1)", "border-radius: 4px", "display: grid"],
  "masthead":         ["padding: var(--spacing-1)", "display: flex", "gap: var(--spacing-0)"],
  "measurement-filter": ["display: flex", "gap: var(--spacing-0)", "padding: var(--spacing-0)"],
};
const counter = new Map();
for (const declarations of Object.values(COMPONENTS))
  for (const d of declarations) counter.set(d, (counter.get(d) ?? 0) + 1);

console.log("\n--- declarations repeating across components ---");
const totalDeclarations = [...counter.values()].reduce((t, n) => t + n, 0);
for (const [d, n] of [...counter.entries()].sort((x, y) => y[1] - x[1]))
  console.log(`  ${d.padEnd(30)} in ${n} components`);
console.log(`  total declarations: ${totalDeclarations}, distinct: ${counter.size}, repeated: ${totalDeclarations - counter.size}`);

// --- length of the class list in the document ---
console.log("\n--- two notations of the same box ---");
const cardUtility = "grid gap-0 p-1 br-1 bg-surface";
const cardComponent = "measurement-card";
console.log(`  utility notation  : class="${cardUtility}"   (${cardUtility.length} characters, ${cardUtility.split(" ").length} classes)`);
console.log(`  component notation: class="${cardComponent}"   (${cardComponent.length} characters, 1 class)`);
console.log(`  if the card appears 12 times in the document, difference: ${(cardUtility.length - cardComponent.length) * 12} characters`);
```

```
--- utility class set (independent of component count) ---
  scale: 8 properties, 44 values total
  prefix count: 4 ((no prefix), hover:, focus:, wide:)
  generated class count: 176
  approximate size: 5280 bytes

--- size of the two notations by component count ---
components  component notation   utility notation  smaller
         5          1550 bytes         5280 bytes  component notation
        10          3100 bytes         5280 bytes  component notation
        20          6200 bytes         5280 bytes  utility notation
        40         12400 bytes         5280 bytes  utility notation
        60         18600 bytes         5280 bytes  utility notation
        80         24800 bytes         5280 bytes  utility notation
       120         37200 bytes         5280 bytes  utility notation
  threshold: past 18 components, the utility set stays smaller

--- declarations repeating across components ---
  padding: var(--spacing-1)      in 3 components
  gap: var(--spacing-0)          in 3 components
  border-radius: 4px             in 2 components
  display: grid                  in 2 components
  display: flex                  in 2 components
  padding: var(--spacing-0)      in 1 components
  total declarations: 13, distinct: 6, repeated: 7

--- two notations of the same box ---
  utility notation  : class="grid gap-0 p-1 br-1 bg-surface"   (30 characters, 5 classes)
  component notation: class="measurement-card"   (16 characters, 1 class)
  if the card appears 12 times in the document, difference: 168 characters
```

The first two blocks compare two growth curves. The utility set is **constant**: eight
properties, forty-four values, and four prefixes produce 176 classes, and this number is
unaffected by how many components get written. Component notation, on the other hand, grows
linearly.

The model's threshold comes out at 18 components. This number is not an exact value; it
depends on scale width, prefix count, and rules per component. What it gives is not the
threshold itself but **the shape of the two curves**: one constant, one growing.

The third block counts repetition. Four components carry thirteen declarations, but only six
of them are distinct; seven declarations repeat. Utility classes turn exactly these six
declarations into single rules.

#### Repetition Does Not Disappear, It Moves

The fourth block shows the approach's cost. The same box is 16 characters in component
notation, 30 characters in utility notation; when the card appears 12 times in the document,
the difference comes to 168 characters.

Repetition removed from the stylesheet has moved into the document. When totaling bytes, this
side has to be counted too; the two files ship together.

But the two repetitions are not of the same kind. Repetition in a stylesheet is managed **by
hand**: changing one value requires finding four rules. Repetition in the document is usually
inside a template or component abstraction; the card is written once and generated twelve
times. The utility class approach therefore **assumes** a template-producing layer; in plainly
written documents, the repetition really is managed by hand.

#### State and Breakpoint Prefixes

Since a utility class writes a single declaration, there is no direct place for pseudo-classes
and media queries. The fix is to write the condition into the **name**:

```css
.p-1        { padding: var(--spacing-1); }
.hover\:p-2:hover { padding: var(--spacing-2); }

@media (min-width: 48rem) {
  .wide\:p-2 { padding: var(--spacing-2); }
}
```

The colon inside the name is escaped in the selector with a backslash; the document writes
`hover:p-2`, and the selector matches it as `.hover\:p-2`.

The cost of this is that the set grows by a multiple. In the output's first block, four
prefixes bring forty-four values up to 176 classes. Every new state and every new breakpoint
grows the set by another factor; pruning the generated set down to only the classes that
appear in the document is, for this reason, an inseparable part of the approach — and the
warning from the third lesson applies here too: names must appear as full literal text in the
source.

#### Where It Breaks

In three places. First, when a value outside the scale is needed: a specific aspect ratio for
an image, or a measure coming from content, is not in the closed scale, and a one-off rule
needs to be written.

Second is readability. A long class list says how the box **looks**, not what it **is**. A
document writing `class="measurement-card"` explains itself; a list of ten classes does not.

Third is cases needing a complex selector. Style based on a sibling, style based on a
descendant, or style depending on an ancestor's state does not fit into single-declaration
classes.

#### Mixed Use

The two approaches do not exclude each other, and in practice the boundary is drawn by this
criterion: **if the structure does not repeat, a utility class; if it repeats, a component
class.**

Generating a name to adjust the padding of a section that appears once in the page is
unnecessary; copying a twelve-times-repeated card's five declarations to twelve places is
unnecessary too. The scale is read from the same custom properties in both notations, so the
two layers share the same design constraint.

#### In the Station Page

```css
/* utilities.css — single-declaration classes generated from a closed scale */
.p-0 { padding: var(--spacing-0); }
.p-1 { padding: var(--spacing-1); }
.gap-0 { gap: var(--spacing-0); }
.gap-1 { gap: var(--spacing-1); }
.grid { display: grid; }
.flex { display: flex; }
.bg-surface { background-color: var(--surface); }
.text-muted { color: var(--text-muted); }
```

This set is used for one-off adjustments on the measurement station page: the masthead's
bottom margin, the location section's padding, the source note's color. The measurement card,
on the other hand, stays a component class — it appears twelve times in the page and has its
own variants.

The distinction can be tied to a rule: if a class name appears once in the document, it can be
written with utility classes; if it appears more than twice, it has earned being a component.

#### Summary

- A utility class writes a single declaration and takes its value from a closed scale; the
  approach's real justification is not brevity but the constraint that makes an out-of-scale
  value impossible.
- The utility set's size is independent of component count; component notation grows linearly,
  and the two curves cross at a certain component count.
- Repetition does not disappear, it moves from the stylesheet into the document; the approach
  assumes a template-producing layer.
- State and breakpoint prefixes grow the set by a multiple; pruning unused classes is for this
  reason part of the approach.
- If the structure does not repeat, a utility class; if it repeats, a component class; both
  layers share the same custom-property scale.

#### Next Step

Five separate notation approaches were taken up in this topic, and all of them answered the
same question from a different place: how style is organized. There is one more question, and
it is asked by the browser — how much does the stylesheet itself delay the page opening, and
how much work is done while matching selectors? The next lesson takes up style's performance
cost, and closes the course.

### Style Performance

Source: https://academia.sh/en/courses/layout-and-responsive-design/style-performance

Selector matching being done right to left, invalidation scope depending on selector shape, a stylesheet blocking paint, and computing the critical style subset.

The previous five lessons took up how style is organized, and each answered the same question
from a different place. There is one more question, and the browser asks it.

The stylesheet is downloaded, parsed, which rules match each element is found, and computed
values are produced. This work has a cost, and where the cost comes from also shapes writing
decisions. This lesson establishes that cost as a rule, and closes the course.

#### Matching Is Done Right to Left

The question of whether a selector matches an element can be answered in two directions.
Going left to right — finding the outermost selector first and moving down the subtree — fits
the reading direction. Browsers do not do this; they go **right to left**.

The reason is this: the question is not "which elements does this selector match" but "which
rules match this element." Once the element is in hand, if the selector's rightmost
component — the **key selector** — does not match the element, the rule is eliminated
immediately. If it does match, the walk proceeds toward the ancestors.

```js
// style-performance.mjs — selector matching direction and computing the critical style subset

// --- a small document tree ---
const node = (type, classes, children = []) => ({ type, classes, children, parent: null });
const DOCUMENT = node("body", [], [
  node("header", ["masthead"], [
    node("h1", ["masthead__name"]), node("p", ["masthead__location"]),
    node("nav", ["nav"], [node("ul", [], [node("li", [], [node("a", ["active"])]), node("li", [], [node("a", [])])])]),
  ]),
  node("main", [], [
    node("section", ["measurements-section"], [
      node("h2", ["heading"]),
      node("div", ["measurement-cards"], [
        node("article", ["measurement-card"], [node("h3", ["measurement-card__heading"]), node("p", ["measurement-card__value"])]),
        node("article", ["measurement-card"], [node("h3", ["measurement-card__heading"]), node("p", ["measurement-card__value"])]),
        node("article", ["measurement-card", "measurement-card--wide"], [node("h3", ["measurement-card__heading"]), node("p", ["measurement-card__value"])]),
      ]),
    ]),
    node("section", ["location-section"], [node("h2", ["heading"]), node("p", []), node("p", [])]),
  ]),
  node("footer", ["source-note"], [node("p", [])]),
]);

function link(n, parent = null) {
  n.parent = parent;
  for (const c of n.children) link(c, n);
  return n;
}
link(DOCUMENT);

const allNodes = (n, list = []) => { list.push(n); for (const c of n.children) allNodes(c, list); return list; };
const NODES = allNodes(DOCUMENT);

// --- compound selector check (type and class) ---
let checks = 0;
function matches(n, compound) {
  checks++;
  const type = compound.match(/^[a-z][\w-]*/);
  if (type && n.type !== type[0]) return false;
  for (const cls of compound.match(/\.[\w-]+/g) || []) if (!n.classes.includes(cls.slice(1))) return false;
  return true;
}

// right to left: key selector first, then ancestors
function rightToLeft(selector) {
  const parts = selector.trim().split(/\s+/);
  checks = 0;
  let matched = 0;
  for (const n of NODES) {
    if (!matches(n, parts[parts.length - 1])) continue;
    let i = parts.length - 2, ancestor = n.parent, done = i < 0;
    while (i >= 0 && ancestor) {
      if (matches(ancestor, parts[i])) i--;
      ancestor = ancestor.parent;
      if (i < 0) done = true;
    }
    if (done) matched++;
  }
  return { checks, matched };
}

// left to right: first selector first, then descend into subtrees
function leftToRight(selector) {
  const parts = selector.trim().split(/\s+/);
  checks = 0;
  let matched = 0;
  const descend = (n, i) => {
    for (const c of allNodes(n).slice(1)) {
      if (!matches(c, parts[i])) continue;
      if (i === parts.length - 1) matched++;
      else descend(c, i + 1);
    }
  };
  for (const n of NODES) {
    if (!matches(n, parts[0])) continue;
    if (parts.length === 1) matched++;
    else descend(n, 1);
  }
  return { checks, matched };
}

console.log(`node count in the document: ${NODES.length}`);
console.log("\n--- check count for matching the same selector in two directions ---");
console.log("selector".padEnd(62) + "right-to-left".padStart(14) + "left-to-right".padStart(14) + "  matched");
for (const s of [
  ".measurement-card__heading",
  ".measurements-section .measurement-cards .measurement-card .measurement-card__heading",
  "body main section div article h3",
  ".masthead__name",
]) {
  const rl = rightToLeft(s), lr = leftToRight(s);
  console.log(s.padEnd(62) + String(rl.checks).padStart(14) + String(lr.checks).padStart(14) + String(rl.matched).padStart(10));
}

// --- critical style: rules matching elements visible on first screen ---
const RULES = [
  { selector: ".masthead", bytes: 96 }, { selector: ".masthead__name", bytes: 64 },
  { selector: ".nav", bytes: 88 }, { selector: ".nav a", bytes: 72 },
  { selector: ".measurement-cards", bytes: 120 }, { selector: ".measurement-card", bytes: 148 },
  { selector: ".measurement-card__heading", bytes: 58 }, { selector: ".measurement-card__value", bytes: 86 },
  { selector: ".measurement-card--wide", bytes: 44 }, { selector: ".location-section", bytes: 92 },
  { selector: ".location-section p", bytes: 60 }, { selector: ".source-note", bytes: 54 },
  { selector: ".measurement-filter", bytes: 132 }, { selector: ".sidebar", bytes: 104 },
];

// The section visible on the first screen: masthead and the measurements section (first 20 nodes in document order).
const VISIBLE = new Set(NODES.slice(0, 20));
const anyMatches = (selector) => {
  const parts = selector.trim().split(/\s+/);
  return [...VISIBLE].some((n) => {
    if (!matches(n, parts[parts.length - 1])) return false;
    let i = parts.length - 2, ancestor = n.parent;
    while (i >= 0 && ancestor) { if (matches(ancestor, parts[i])) i--; ancestor = ancestor.parent; }
    return i < 0;
  });
};

const critical = RULES.filter((r) => anyMatches(r.selector));
const total = RULES.reduce((t, r) => t + r.bytes, 0);
const criticalBytes = critical.reduce((t, r) => t + r.bytes, 0);

console.log("\n--- the critical style subset ---");
console.log(`  node count visible on first screen: ${VISIBLE.size} / ${NODES.length}`);
console.log(`  critical rules: ${critical.length} / ${RULES.length}`);
console.log(`  critical bytes: ${criticalBytes} / ${total}  (%${((criticalBytes / total) * 100).toFixed(1)})`);
console.log("  non-critical:", RULES.filter((r) => !critical.includes(r)).map((r) => r.selector).join(", "));
```

```
node count in the document: 29

--- check count for matching the same selector in two directions ---
selector                                                       right-to-left left-to-right  matched
.measurement-card__heading                                                29            29         3
.measurements-section .measurement-cards .measurement-card .measurement-card__heading            38            55         3
body main section div article h3                                          44           102         3
.masthead__name                                                           29            29         1

--- the critical style subset ---
  node count visible on first screen: 20 / 29
  critical rules: 8 / 14
  critical bytes: 732 / 1218  (%60.1)
  non-critical: .measurement-card--wide, .location-section, .location-section p, .source-note, .measurement-filter, .sidebar
```

Four selectors are tried on a 29-node document. For single-class selectors, the two directions
run the same number of checks: every node is tested once.

The distinction shows up in long selectors. A four-part selector needs 38 checks right to
left, 55 left to right; a six-part type-selector chain needs 44 versus 102. The right-to-left
walk eliminates a node whose key selector does not match in **a single check**; the
left-to-right walk visits the entire subtree for every matching starting point.

The writing rule that follows from this is that the key selector should be **distinguishing**.
A selector ending in `... h3` starts an ancestor walk at every heading in the document; a
selector ending in `... .measurement-card__heading` starts one at only three nodes.

A sense of scale is needed: selector matching is a small cost next to the layout and paint
stages. The real cost of long selectors is not the match count, it is the invalidation scope
in the next section.

#### Invalidation Scope

When an element's class changes, the browser has to determine which elements' style needs to
be recomputed. This set is called the **invalidation scope**, and it is derived from the
shape of the selectors.

The rule is this: if a class occurs only in rules matching its own element, the scope is that
element. If the same class occurs to the **left** of a descendant combinator, the scope is the
element's entire subtree.

```css
.measurement-card--warning { border-color: var(--warning); }          /* scope: the element itself */
.measurement-card--warning .measurement-card__value { color: var(--warning); }  /* scope: the whole subtree */
```

In the second notation, adding the class to the card forces every element inside the card to
have its style recomputed. The custom-property notation built in an earlier lesson lowers this
cost too: the modifier writes only a value, and the descendants are already reading that
value.

Selectors working in the ancestor direction widen the scope upward: in notations where an
element's state also affects its parent, the change's result travels up into the tree. Such
selectors are powerful and are used when needed; used without measure, they make the
invalidation scope unpredictable.

#### A Stylesheet Blocks Paint

A document's first paint waits until the stylesheet is downloaded and parsed. The reason is
clear: a page painted before style is known would be repainted from scratch once style
arrives, and the user would see an unstyled page.

This has three consequences. A stylesheet is declared in the document's head; declaring it at
the end delays parsing from proceeding. A file carrying a `media` attribute whose condition is
not met does not block paint; print style is separated this way. An `@import` rule that calls
another file from inside a stylesheet, on the other hand, puts the two downloads in
**sequence**; the second request starts only after the first file is parsed.

#### Critical Style

The rules needed for the first paint are not all of the rules in the file. Only rules matching
elements visible on the first screen are needed; the rest can load later.

The output's second block computes this subset: when the first twenty of the twenty-nine nodes
are taken as visible, eight of fourteen rules turn out to be needed. In bytes, that is 60
percent of the total.

The critical subset is written directly into the document; the remaining rules load in a way
that does not block paint. This notation has two costs. The first is repetition: the critical
rules exist both in the document and in the file. The second is staleness; when the page
structure changes, the critical set has to be recomputed, and it cannot be maintained by hand.

For this reason, critical style is a **generated** output, not a hand-written one — its
computation is exactly as mechanical as above: given the set of visible elements and the rule
list, the subset is determined.

#### Layers and Maintenance Cost

The `@layer` rule's runtime cost is small, but there is an ordering rule: layer order is set
where the layer names **first appear**. This is why layer names are declared on a single line
at the top of the stylesheet:

```css
@layer base, layout, components, utilities;
```

This line fixes the order; which file loads in which order no longer changes the result. What
it gains is not a performance measure but avoiding an unmeasurable cost — specificity creep
and `!important` chains.

Utility classes sit in the topmost layer; this lets a single-declaration class override a
component rule without a specificity trick. This is the arrangement needed for the fifth
lesson's mixed use to actually work.

#### Summary

- Selector matching is done right to left; nodes whose key selector does not match are
  eliminated in a single check, which is why a distinguishing key selector makes long
  selectors cheap too.
- The real cost of long selectors is not the match count but the invalidation scope: if a
  class occurs to the left of a descendant combinator, the scope is the entire subtree.
- A stylesheet blocks first paint; a `media` declaration whose condition is not met does not
  block it, while `@import` puts downloads in sequence.
- Critical style is the subset of rules matching elements visible on the first screen; its
  computation is mechanical and, because it cannot be maintained by hand, it must be
  generated.
- Layer order is set where the names first appear; declaring it in a single line up front
  makes load order independent of the result.

#### Course Wrap-Up

This course started with layout. Flexbox distributed empty space on a single axis, grid
defined lines on two axes, and the choice between the two was tied to a criterion. Responsive
design derived breakpoints not from device lists but from content itself; media queries asked
about the viewport, container queries asked about the component's own context, and user
preferences became an input to the design.

The motion topic separated geometry from time: a transform matrix determined what moved
where, a timing curve determined how long it took. The rendering pipeline showed which
declaration ran which stage, and it became clear that motion also carries a cost that reaches
the user.

The last topic asked where all of this should be written. A naming methodology kept
specificity flat, custom properties gave a component an interface, compile tools organized the
source, scoping approaches moved names from convention into mechanism, and utility classes
embedded the constraint into the system itself. The North Slope Measurement Station page moved
one step further in each of these lessons, and ended up with a style layer split into
component files, a scale definition, and a layer order.

There is one thing the page still cannot do: it does not do anything itself. Measurement cards
show up because they are written in the document, the status indicator changes only when an
attribute is edited by hand, the filter filters nothing. Modifying the document tree with a
program, capturing user actions as events, and using the interfaces the browser offers are the
subject of the next course: **The Browser and the Web Platform**. There, the document stops
being formatted text and becomes a data structure that is worked on.

