---
title: 'Fundamentals of Interface Design'
source: 'https://academia.sh/en/courses/interface-fundamentals'
language: en
updated: '2026-08-17T14:55:17+00:00'
license: 'CC BY-SA 4.0'
lessons: 22
outcomes:
    - 'Builds visual hierarchy with typography, spacing and contrast.'
    - 'Grounds grid and layout decisions in the structure of the content.'
    - 'Designs component states completely.'
---

# Fundamentals of Interface Design

An introductory course that ties visual hierarchy, grid and spacing, typography and color decisions to perceptual foundations and measurable thresholds, and teaches how to define component states completely.

## Visual Principles

The perceptual foundations of hierarchy, grouping, contrast, alignment, and repetition; what distinction each visual decision produces, and at what cost.

### Visual Hierarchy

Source: https://academia.sh/en/courses/interface-fundamentals/visual-hierarchy

Hierarchy as an ordering of attention; the measurable contribution of the size, weight, contrast, and position channels, and how the distinction between levels is audited.

From the outside, interface design looks like a matter of taste. A screen is judged
"good" or "bad," and asking why usually surfaces a personal preference. This course
rejects that view. Every visual decision carries a function: it produces a distinction,
establishes a relationship, or declares an order. A decision is design if it can be
justified; if it cannot, it is decoration. The question asked throughout the course is
not "is it beautiful" but **what distinction it produces, and at what cost**.

The examples will proceed through a single interface: a library's **catalog interface**.
A search field, a search results list, a single record's detail view, and a borrowing
action. This interface will become one step more deliberate with each lesson. The first
question is the most basic one: what does a person looking at a screen see first, and
who decides that?

#### Hierarchy Is a Promise About Reading Order

**Visual hierarchy** is the ordering of the elements on a plane by their power to draw
attention. The designer cannot control this ordering directly; they can only steer it by
changing the elements' visual properties.

This definition has two consequences. First, hierarchy is not a property but a
**relationship**. There is no such thing as a heading being "strong" on its own; it can
only be strong relative to the elements around it. If every piece of text is enlarged to
32 pixels, the hierarchy does not rise — it disappears.

Second, hierarchy is a **promise**, and when it is broken, the reader pays the cost. If
an interface shows a record's title more prominently than its shelf-availability status,
a person who comes to the catalog asking "which books are on the shelf right now" has to
look twice at every record. A wrong hierarchy does not erase information; it raises the
cost of finding it.

The question that must be answered before building a hierarchy is this: **which task**
comes first on this screen? In the catalog interface, the search results list's task is
to make the question "is this result the thing I am looking for" cheap to answer. The
strongest element is therefore the record title, the author's name comes second, and
the shelf code is needed only after a record is selected.

#### The Channels That Direct Attention

Distinguishing one element from another can draw on several independent **channels**.
Each channel carries its own cost and its own limit.

- **Size.** The strongest and most expensive channel. Enlarging an element does not just
  bring it forward; it also consumes screen space and pushes everything below it down.
- **Weight.** The thickness of the type. It consumes no space, which makes it more
  useful than size in dense lists. Its limit is the number of weights the typeface in
  use offers.
- **Contrast.** The degree to which text separates from its background. It also works by
  weakening a distinction: fading secondary information brings primary information
  forward without enlarging it. Its cost is the accessibility threshold, which will be
  measured in the Contrast and Accessibility lesson.
- **Position.** An element close to the start of the reading direction is scanned
  earlier. It is not free: position constrains the rest of the layout.
- **Space.** The space around an element isolates it and brings it forward. This is the
  subject of the lessons that follow.

The channels being independent matters. Expressing the same distinction on two channels
at once does not double its strength, but it does make it **resilient**: when one
channel stops working, the other still holds. Color blindness, reading under low
brightness, a small screen, and a typeface failing to load are all instances of a
channel ceasing to work.

#### Measuring the Distinction

A discussion of hierarchy stays inconclusive as long as the question "is it distinct
enough" remains subjective. The way to make the question measurable is to compute the
difference between two adjacent levels channel by channel and compare it against a
criterion.

The program below takes the catalog interface's text levels, computes each level's
**contrast ratio** against the background, and lists the differences between adjacent
levels. The contrast ratio is computed from the **relative luminance** defined in the
Visual Presentation with CSS course.

```js
// hierarchy.mjs — measures the distinction between text levels channel by channel

const levels = [
  { name: "page title",    size: 32, weight: 700, color: "#1a1a1a" },
  { name: "section title", size: 20, weight: 600, color: "#1a1a1a" },
  { name: "record title",  size: 18, weight: 600, color: "#1a1a1a" },
  { name: "author",        size: 16, weight: 400, color: "#3d3d3d" },
  { name: "metadata",      size: 14, weight: 400, color: "#5c5c5c" },
];

const BACKGROUND = "#ffffff";

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

// Course threshold: two adjacent levels must clear these thresholds in at least one channel.
const THRESHOLD = { sizeRatio: 1.15, weightGap: 200, contrastRatio: 1.5 };

console.log("level            size  weight  contrast(bg)");
for (const l of levels) {
  console.log(
    `${l.name.padEnd(16)} ${String(l.size).padStart(4)}px ${String(l.weight).padStart(7)}  ` +
      `${contrast(l.color, BACKGROUND).toFixed(2)}:1`
  );
}

console.log("");
console.log("adjacent level pair             size ratio  weight gap  contrast ratio  distinction");
for (let i = 0; i < levels.length - 1; i++) {
  const upper = levels[i];
  const lower = levels[i + 1];
  const sizeRatio = upper.size / lower.size;
  const weightGap = upper.weight - lower.weight;
  const cUpper = contrast(upper.color, BACKGROUND);
  const cLower = contrast(lower.color, BACKGROUND);
  const contrastRatio = cUpper / cLower;

  const passed = [];
  if (sizeRatio >= THRESHOLD.sizeRatio) passed.push("size");
  if (weightGap >= THRESHOLD.weightGap) passed.push("weight");
  if (contrastRatio >= THRESHOLD.contrastRatio) passed.push("contrast");

  const label = `${upper.name} > ${lower.name}`;
  console.log(
    `${label.padEnd(30)} ${sizeRatio.toFixed(3).padStart(11)} ` +
      `${String(weightGap).padStart(14)} ${contrastRatio.toFixed(3).padStart(15)}  ` +
      (passed.length ? passed.join("+") : "WEAK")
  );
}
```

```
level            size  weight  contrast(bg)
page title         32px     700  17.40:1
section title      20px     600  17.40:1
record title       18px     600  17.40:1
author             16px     400  10.86:1
metadata           14px     400  6.69:1

adjacent level pair             size ratio  weight gap  contrast ratio  distinction
page title > section title           1.600            100           1.000  size
section title > record title         1.111              0           1.000  WEAK
record title > author                1.125            200           1.602  weight+contrast
author > metadata                    1.143              0           1.624  contrast
```

The threshold values are not a law of nature; they are criteria this course adopts and
quantifies so that they can be audited. What matters is not the number itself but that
the decision is tied to a number: the sentence "it does not look distinct enough" can be
argued with, while the sentence "size ratio 1.111, threshold 1.15" can be checked.

The second row of the output is the real finding. Between the section title and the
record title, no channel clears the threshold: their sizes are nearly identical, their
weights are equal, their colors are the same. These two levels are written on separate
lines in the design document, but on screen they sit at the same level. The hierarchy
exists in the document; it does not exist in the interface.

The fix for this finding is not merely to enlarge the section title. The question to ask
is whether the distinction is actually needed. If the catalog interface's search results
list has only one section, the section title is a label that carries no information and
can be removed. The cheapest solution to a hierarchy problem is often reducing the
number of levels.

#### The Cost of Relying on a Single Channel

The last row of the output shows another weakness: the distinction between the author
and the metadata rests on the contrast channel alone. The size ratio is 1.143, below the
threshold; the weight gap is zero. The distinction stands on one leg.

A one-legged distinction collapses predictably. A distinction that relies on contrast
disappears on a screen in sunlight or on a panel set to low brightness. A distinction
that relies on color disappears for a reader with limited color perception. A
distinction that relies on weight disappears when only one weight of the typeface can be
loaded.

The rule can be written as follows: **if the information it carries is critical, the
distinction is expressed on at least two channels.** The distinction between the record
title and the author satisfies this condition (weight and contrast). The distinction
between the author and the metadata does not; if the metadata's font size is reduced to
13 pixels, the size ratio becomes 16/13, about 1.231, and clears the threshold.

#### How Many Levels a Hierarchy Carries

Adding a level is not free. Every new level squeezes in between the existing ones and
narrows the difference to its neighbors. If a five-level scale's size range runs from 14
to 32 pixels, a sixth level either shrinks the lower end past the point of legibility or
drops one level pair's distinction below the threshold.

In practice the number of levels is therefore small: one primary, one secondary, one
tertiary, and, if needed, one suppressed level. If two elements on the same screen
compete as primary, neither one is primary.

The same limit applies to actions. In a catalog record, if "Borrow" is the primary
action, "Add to List" and "Share" cannot carry the same visual weight. When all three
buttons are drawn filled and colored, the user cannot read which one is the expected
action from the interface; they decide by reading the labels. That means the hierarchy
is not doing its job.

#### Summary

- Visual hierarchy is the ordering of elements by their power to draw attention; it is a
  relationship between elements, not a property, and strengthening everything destroys
  it.
- Hierarchy is derived from the screen's primary task: the strongest element is the one
  that makes that task cheaper.
- Distinction is expressed on the size, weight, contrast, position, and space channels;
  the channels are independent, and each carries a separate cost.
- The distinction between two levels can be audited numerically; even when the criterion
  is chosen arbitrarily, the decision moves from argument to audit.
- Critical information's distinction is expressed on at least two channels; a
  single-channel distinction disappears entirely under conditions where that channel
  fails.
- As the number of levels grows, the difference between adjacent levels narrows; the
  cheapest fix for hierarchy problems is not adding a level but removing one.

#### Next Step

This lesson dealt with separating elements from one another. The interface's second job
is **connecting** elements to one another: how is it understood, on screen, that a
record title and an author's name belong to the same record while the shelf code is a
separate piece of information? The next lesson shows that this relationship is built
with space rather than lines or frames, and it uses computation to show how spacing
ratios determine perceived grouping.

### Proximity and Grouping

Source: https://academia.sh/en/courses/interface-fundamentals/proximity-and-grouping

How relationship is declared through space; grouping is established by spacing ratio rather than absolute space, and nested grouping levels are audited numerically.

The previous lesson dealt with separating elements from one another: which element is
read first, and on which channels that order is expressed. The interface's second job is
the reverse. In a catalog record, the title, the author's name, and the shelf code are
drawn separately but belong to the same book. The next record's title sits only a few
pixels below and belongs to a different book. On screen, what tells these two situations
apart?

The answer is not a line or a frame but space. This lesson shows how space establishes a
relationship, why that relationship depends not on an absolute pixel value but on the
**ratio** between gaps, and how that ratio is audited.

#### Proximity Declares a Relationship

**Proximity** is the tendency for elements standing close to one another to be perceived
as a single unit. This tendency is a member of a group known in perceptual psychology as
the **Gestalt principles**, which also includes elements that look alike being perceived
together (**similarity**) and elements sharing a common background or frame being
perceived together (**common region**).

These principles matter for interface design in a practical, not a theoretical, sense:
they do not offer a choice, they impose a constraint. The moment elements are placed on
a plane, they are grouped. The designer's decision is not "should I group these" but
"does the perceived grouping match the intended grouping." When no space is assigned,
grouping does not disappear; a random grouping settles in instead.

There is a ranking of strength among the proximity principles. When two elements are
close but differ in color, proximity beats similarity: the elements are read as a single
group. In a catalog record, if the title is dark and bold while the author's name is
pale and normal weight, the two are still perceived as a single record when only 4
pixels separate them. This means hierarchy and grouping do not coincide: elements inside
a group can differ in weight from one another; what carries the group's identity is the
space.

#### Grouping Is Established by Ratio, Not Absolute Space

Proximity is a relative measure. The sentence "24 pixels of space separates the records"
is neither true nor false on its own; it cannot be evaluated without knowing how many
pixels of space sit inside the record. If the internal spacing is 4 pixels, 24 pixels is
a clear boundary; if the internal spacing is 20 pixels, the same 24 pixels separates
nothing.

Making this auditable requires a criterion. This course adopts the following one: a gap
counts as a group boundary if it is **at least twice** the smallest gap in the same
stack. The program below tests two different spacing decisions for the catalog results
list against this criterion.

```js
// 02-grouping.mjs — checks grouping in a vertical stack by gap ratio

// Two rows of the catalog result list: each record carries a title + author + metadata.
// Each item is given as (name, height, topGap); topGap is its distance from the previous item.
const designA = [
  { name: "record-1 title", height: 24, topGap: 0, group: 1 },
  { name: "record-1 author", height: 20, topGap: 4, group: 1 },
  { name: "record-1 metadata", height: 18, topGap: 4, group: 1 },
  { name: "record-2 title", height: 24, topGap: 24, group: 2 },
  { name: "record-2 author", height: 20, topGap: 4, group: 2 },
  { name: "record-2 metadata", height: 18, topGap: 4, group: 2 },
];

const designB = [
  { name: "record-1 title", height: 24, topGap: 0, group: 1 },
  { name: "record-1 author", height: 20, topGap: 12, group: 1 },
  { name: "record-1 metadata", height: 18, topGap: 12, group: 1 },
  { name: "record-2 title", height: 24, topGap: 16, group: 2 },
  { name: "record-2 author", height: 20, topGap: 12, group: 2 },
  { name: "record-2 metadata", height: 18, topGap: 12, group: 2 },
];

// Course criterion: a gap counts as a group boundary if it is SEPARATION_RATIO times
// the smallest gap in the stack. Below a ratio of 2 the boundary is ambiguous.
const SEPARATION_RATIO = 2;

function groupItems(items) {
  const gaps = items.slice(1).map((o) => o.topGap);
  const smallest = Math.min(...gaps);
  const groups = [[items[0].name]];
  const ratios = [];
  for (let i = 1; i < items.length; i++) {
    const ratio = items[i].topGap / smallest;
    ratios.push({ name: items[i].name, gap: items[i].topGap, ratio });
    if (ratio >= SEPARATION_RATIO) groups.push([items[i].name]);
    else groups[groups.length - 1].push(items[i].name);
  }
  return { smallest, ratios, groups };
}

function report(title, items) {
  const { smallest, ratios, groups } = groupItems(items);
  const intended = new Set(items.map((o) => o.group)).size;
  console.log(`${title}  (smallest gap: ${smallest}px, threshold ratio: ${SEPARATION_RATIO})`);
  for (const o of ratios) {
    const boundary = o.ratio >= SEPARATION_RATIO ? "BOUNDARY" : "in group";
    console.log(
      `  ${o.name.padEnd(18)} above ${String(o.gap).padStart(2)}px  ratio ${o.ratio
        .toFixed(2)
        .padStart(5)}  ${boundary}`
    );
  }
  console.log(`  intended group count: ${intended}   perceived group count: ${groups.length}`);
  groups.forEach((g, i) => console.log(`    group ${i + 1}: ${g.join(", ")}`));
  console.log("");
}

report("design A  in-group 4px / separator 24px", designA);
report("design B  in-group 12px / separator 16px", designB);
```

```
design A  in-group 4px / separator 24px  (smallest gap: 4px, threshold ratio: 2)
  record-1 author    above  4px  ratio  1.00  in group
  record-1 metadata  above  4px  ratio  1.00  in group
  record-2 title     above 24px  ratio  6.00  BOUNDARY
  record-2 author    above  4px  ratio  1.00  in group
  record-2 metadata  above  4px  ratio  1.00  in group
  intended group count: 2   perceived group count: 2
    group 1: record-1 title, record-1 author, record-1 metadata
    group 2: record-2 title, record-2 author, record-2 metadata

design B  in-group 12px / separator 16px  (smallest gap: 12px, threshold ratio: 2)
  record-1 author    above 12px  ratio  1.00  in group
  record-1 metadata  above 12px  ratio  1.00  in group
  record-2 title     above 16px  ratio  1.33  in group
  record-2 author    above 12px  ratio  1.00  in group
  record-2 metadata  above 12px  ratio  1.00  in group
  intended group count: 2   perceived group count: 1
    group 1: record-1 title, record-1 author, record-1 metadata, record-2 title, record-2 author, record-2 metadata
```

Both designs share the same goal: two records, two groups. Design A produces it; design
B does not. What stands out is that design B is **more spacious**. Its total spacing is
greater — it leaves 16 pixels between records — and yet it erases the record boundary.
Adding space does not improve grouping; **differentiating** space does.

This also explains why spacing decisions cannot be made one at a time. The decision to
place 16 pixels between records cannot be evaluated in isolation, because its meaning
depends on neighboring decisions. Spacing decisions are made as a set.

#### Nested Grouping Levels

The catalog screen does not have a single grouping level. The title and the author's
name are grouped at one level, record blocks at a level above that, records at a level
above that, and the screen's sections at the top. Every level must be distinguishable
from the level below it; otherwise one rung of the hierarchy becomes invisible.

```js
// 02-nested.mjs — checks the gap ratio of nested grouping levels

// Four grouping levels on the catalog screen, innermost to outermost.
const levels = [
  { name: "within row (title-author)", gap: 4 },
  { name: "blocks within record", gap: 8 },
  { name: "between records", gap: 24 },
  { name: "between sections", gap: 48 },
];

const SEPARATION_RATIO = 2;

console.log("level                      gap  ratio to level below  status");
for (let i = 0; i < levels.length; i++) {
  const l = levels[i];
  if (i === 0) {
    console.log(`${l.name.padEnd(26)} ${String(l.gap).padStart(4)}px  ${"-".padStart(20)}  base`);
    continue;
  }
  const ratio = l.gap / levels[i - 1].gap;
  const status = ratio >= SEPARATION_RATIO ? "separates" : "AMBIGUOUS";
  console.log(
    `${l.name.padEnd(26)} ${String(l.gap).padStart(4)}px  ${ratio.toFixed(2).padStart(20)}  ${status}`
  );
}
```

```
level                      gap  ratio to level below  status
within row (title-author)     4px                     -  base
blocks within record          8px                  2.00  separates
between records              24px                  3.00  separates
between sections             48px                  2.00  separates
```

Four levels, a base spacing of 4 pixels, an outermost spacing of 48 pixels. Every ratio between them is at
least two, so every level is distinguishable from the one below it. The real finding here is not the
numbers themselves but their **multiplicative** progression: 4, 8, 24, 48. An additive sequence (4, 8, 12,
16) cannot do the same job, because toward the outer levels the ratios rapidly approach one.

This observation also sets a limit on the number of grouping levels. If the base spacing
is 4 pixels and every level must be at least twice the one below it, six levels require
$4 \cdot 2^{5} = 128$ pixels at the outermost level. Considering how expensive 128
vertical pixels are on a screen, the practical number of grouping levels turns out to sit
around four. As with hierarchy levels, the fix here is not adding a level but removing
one that is not needed.

#### Space or a Frame

The common-region principle is a second way to group: enclosing elements in a frame or
giving them a shared background color also turns them into a single unit. This route is
stronger than proximity — a frame beats proximity: two elements that are far apart
inside a box are perceived as more related than a nearby element outside the box.

Being stronger does not make it the default choice. A frame's cost is visual noise. If
every record in a twenty-record results list is framed, 20 rectangles — 80 lines — are
added to the screen. None of these lines carries information; every one of them just
declares the record boundary. If the same boundary were declared with space, no line
would be added to the screen at all. Space is the only grouping tool that works with
zero ink.

The criterion can be written as follows: **common region is reserved for establishing a
relationship that proximity cannot build.** There are three situations where proximity
falls short. The first is when elements are not separated horizontally and vertically;
giving a table's rows space would inflate row height, so separating them with a
background color can be cheaper. The second is when a group carries a function different
from the rest of the screen; the section holding the borrowing action is an action area,
and separating it from the reading area with space alone loses information. The third is
when the group is a scrollable area; a scroll area with no visible boundary hides where
the content ends.

#### The Mislinked Label

The most expensive form of a proximity error appears in the section holding the search
field. The catalog interface has three filter fields: publication year, language, and
material type. Above each field is a label, and below it a description line.

If the space between the label and the field is 8 pixels, the space between the field
and the description is 8 pixels, and the space between the description and the next
label is 12 pixels, the ratio to the smallest gap is 12 / 8 = 1.5, which stays below the
threshold. The result is three separate filters turning into a single block; the reader
cannot tell which description belongs to which field from the space and has to find out
by trial.

The correct arrangement is to bind the label to the field and separate the group from
the outside: label–field 4 pixels, field–description 4 pixels, between groups 24 pixels.
The ratio becomes 24 / 4 = 6. This arrangement looks more "cramped" on paper, but it
reads more clearly on screen, because the cramping sits inside the group while the
spaciousness sits between groups.

The same rule applies to the label's position. When the label is placed to the left of
the field, the horizontal space between the label and its own field must be smaller than
the vertical space between the label and the next row. In a grouping that operates on
two axes, the ratio criterion is tested separately on each axis.

#### Summary

- Proximity is the perception of nearby elements as a single unit; elements are grouped
  the moment they are placed on a plane, so the question is not whether grouping exists
  but whether it matches the intended grouping.
- Grouping is established by the ratio between neighboring gaps, not by an absolute
  spacing value; this course's criterion is that a group boundary is at least twice the
  smallest gap.
- Adding space does not improve grouping; differentiating space does. A design with more
  total spacing can group more weakly.
- Nested grouping levels progress multiplicatively; an additive sequence erases
  boundaries at the outer levels because it drives the ratio toward one.
- Common region is stronger than proximity but carries an ink cost; it is reserved for
  relationships proximity cannot build.
- Spacing decisions are made as a set rather than one at a time, because a gap's meaning
  depends on its neighboring gaps.

#### Next Step

This lesson dealt with establishing relationships. What remains is establishing
distinction with **restraint**. In the previous two lessons, distinction was always
treated in one direction: bringing an element forward. But emphasis is a budget; every
emphasized element on screen reduces the share left for the other emphasized elements.
The next lesson computes visual weight from area and luminance difference, extracts the
emphasis shares on the catalog screen, and uses numbers to show why three primary
actions leave none of them primary.

### Contrast and Emphasis

Source: https://academia.sh/en/courses/interface-fundamentals/contrast-and-emphasis

Separating contrast as a perceptual principle from the contrast ratio as a measured quantity; computing visual weight from area and luminance difference, and auditing emphasis shares as a budget.

The previous two lessons always treated distinction in one direction: bringing an
element forward. That view behaves as if a single element existed on screen. But
emphasis is a shared resource. The action row beneath a catalog record holds three
buttons — Borrow, Add to List, Share — and bringing all three forward produces the same
result as bringing none of them forward.

This lesson treats emphasis as a budget. Measuring the budget requires computing visual
weight first, and computing that requires separating contrast from the contrast ratio.

#### Contrast Is a Principle, Contrast Ratio Is a Measurement

In everyday language, two distinct concepts often collapse into a single word, so this
course keeps the distinction throughout.

**Contrast** is a perceptual principle: the difference between two elements declares
that they are separate things. Contrast can be established through color, but also
through size, shape, direction, texture, or space. A large heading's relationship to a
small metadata line is a size contrast; a single vertical element in a horizontal list
is a direction contrast.

The **contrast ratio**, by contrast, is a measured quantity: a single number derived
from two colors' relative luminances, ranging from 1:1 to 21:1. A contrast ratio is
defined only for a pair of colors; a size contrast has no contrast ratio.

The practical consequence of this distinction is that the question "is there enough
contrast between these two elements" cannot be answered by measuring the contrast ratio
alone. The contrast ratio measures a single channel; the principle covers every channel.

#### Computing Visual Weight

An element's power to draw attention was treated qualitatively, through channels, in the
previous lesson. In a region with filled and bordered elements, such as an action row,
this power can be quantified. The model this course uses is the following:

**Visual weight is the product of the area of the surfaces that stand apart from the
background and those surfaces' relative luminance difference from the background.**
Fill and border are computed separately and summed.

The model ignores text; what it measures is the colored surface. This is not a gap but a
deliberate simplification: in an action row, what determines the decision is the
button's fill and border, not the label text itself.

```js
// 03-emphasis.mjs — computes visual weight and emphasis share in the action row

// Model: an item's visual weight is the sum of the areas that stand out from the
// background (area x relative luminance difference). Fill and border are computed
// separately. Text weight is ignored in this model; what is measured is the colored surface.

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

const BACKGROUND = "#ffffff";
const ACCENT = "#1b4d3e";

function weight(item) {
  const area = item.width * item.height;
  const fill = item.fill ? area * Math.abs(luminance(item.fill) - luminance(BACKGROUND)) : 0;
  const perimeter = 2 * (item.width + item.height);
  const border = item.border ? perimeter * item.borderWidth * Math.abs(luminance(item.border) - luminance(BACKGROUND)) : 0;
  return fill + border;
}

function scenario(name, items) {
  const weights = items.map((o) => ({ name: o.name, value: weight(o) }));
  const total = weights.reduce((t, a) => t + a.value, 0);
  console.log(`${name}`);
  console.log("  item             visual weight   share");
  for (const a of weights) {
    console.log(
      `  ${a.name.padEnd(15)} ${a.value.toFixed(0).padStart(14)} ${((100 * a.value) / total)
        .toFixed(1)
        .padStart(5)}%`
    );
  }
  console.log(`  total weight: ${total.toFixed(0)}   largest share: ${(
    (100 * Math.max(...weights.map((a) => a.value))) / total
  ).toFixed(1)}%`);
  console.log("");
}

// A: one primary action. Secondary is bordered, tertiary is bare.
scenario("scenario A  one primary action", [
  { name: "Borrow", width: 120, height: 40, fill: ACCENT, border: null, borderWidth: 0 },
  { name: "Add to List", width: 128, height: 40, fill: null, border: ACCENT, borderWidth: 1 },
  { name: "Share", width: 76, height: 40, fill: null, border: null, borderWidth: 0 },
]);

// B: all three actions filled.
scenario("scenario B  three filled actions", [
  { name: "Borrow", width: 120, height: 40, fill: ACCENT, border: null, borderWidth: 0 },
  { name: "Add to List", width: 128, height: 40, fill: ACCENT, border: null, borderWidth: 0 },
  { name: "Share", width: 76, height: 40, fill: ACCENT, border: null, borderWidth: 0 },
]);

// C: primary unchanged, secondary suppressed to neutral gray.
scenario("scenario C  secondary suppressed", [
  { name: "Borrow", width: 120, height: 40, fill: ACCENT, border: null, borderWidth: 0 },
  { name: "Add to List", width: 128, height: 40, fill: null, border: "#767676", borderWidth: 1 },
  { name: "Share", width: 76, height: 40, fill: null, border: null, borderWidth: 0 },
]);

console.log("border color's contrast ratio with the background (WCAG 1.4.11 threshold 3:1)");
for (const color of [ACCENT, "#c9c9c9", "#767676"]) {
  const o = contrast(color, BACKGROUND);
  console.log(`  ${color}  ${o.toFixed(2)}:1  ${o >= 3 ? "passes" : "fails"}`);
}
```

```
scenario A  one primary action
  item             visual weight   share
  Borrow                    4517  93.5%
  Add to List                316   6.5%
  Share                        0   0.0%
  total weight: 4834   largest share: 93.5%

scenario B  three filled actions
  item             visual weight   share
  Borrow                    4517  37.0%
  Add to List               4819  39.5%
  Share                     2861  23.5%
  total weight: 12197   largest share: 39.5%

scenario C  secondary suppressed
  item             visual weight   share
  Borrow                    4517  94.3%
  Add to List                275   5.7%
  Share                        0   0.0%
  total weight: 4792   largest share: 94.3%

border color's contrast ratio with the background (WCAG 1.4.11 threshold 3:1)
  #1b4d3e  9.64:1  passes
  #c9c9c9  1.66:1  fails
  #767676  4.54:1  passes
```

#### Emphasis Is a Budget

The comparison between scenario A and scenario B is this lesson's main finding. The
Borrow button's visual weight is identical in both scenarios: 4517. An element that has
not changed at all sees its share drop from 93.5 percent to 37.0 percent. Nothing was
done to the button; what changed sat beside it.

Emphasis share is a ratio, and the ratio's denominator is the total weight on screen.
There are two ways to bring an element forward: enlarging its share or shrinking the
denominator. The second route is usually cheaper, because it consumes no screen space.

Scenario B's second finding is more unsettling. When all three buttons are drawn the
same way, the highest share does not go to Borrow but to **Add to List: 39.5 percent.**
The reason is plain geometry: its label is longer, so its button is wider — 128 pixels
against 120. When every action is drawn the same way, what determines the emphasis order
is not the action's importance but its label's character count.

This shows why the decision to "bring everything forward" does not merely erase the
hierarchy but also builds a **wrong** one. The absence of hierarchy is not a neutral
state; a random ordering fills the vacancy.

#### The Limit of Suppression

The denominator-shrinking route is tested in scenario C: the secondary action's border
is pulled from the accent color to neutral gray. The result rises from 93.5 percent to
94.3 percent. The gain is 0.8 points.

This small number matters, because it runs counter to intuition. Fading the secondary
action does not bring the primary action forward by any noticeable amount; the real
difference comes from **not drawing the secondary action filled** at all. The gap
between scenario A and B is 56.5 points; the gap between A and C is 0.8 points. In the
emphasis budget, the gain comes not from turning noise down but from never adding it.

Suppression also has a floor. If the border color is pulled even lighter, visual weight
keeps dropping, but at some point the border becomes invisible. The output's last block
measures exactly this: the `#c9c9c9` border gives a 1.66:1 contrast ratio against the
background, below the 3:1 threshold that WCAG 1.4.11 requires for non-text elements.
`#767676`, at 4.54:1, clears the threshold.

The rule is this: **suppression stops at the element's perceptibility threshold.**
Suppressing an element means placing it second, not making it invisible. How the
thresholds are derived and which criterion applies to which class of element is the
subject of the Contrast and Accessibility lesson; here it is only established that the
budget has a floor.

#### Building Contrast on Channels Other Than Color

Because the emphasis budget is tight, forms of contrast that do not spend it are
valuable.

- **Size contrast.** In the catalog, the distinction between the record title and the
  metadata is established without spending any extra color. Its cost is vertical space.
- **Shape contrast.** An element with rounded corners stands apart among sharp-cornered
  ones. Its cost is that the shape decision starts to carry meaning: once a rounded
  corner has started saying "action," it cannot be used as decoration.
- **Direction contrast.** A horizontal band stands out strongly in a vertically flowing
  list. Its cost is that the change in direction interrupts the reading flow.
- **Space contrast.** An element left with empty space around it stands apart from
  elements surrounded by content. Its cost is the risk of disrupting the grouping ratios
  computed in the previous lesson.

What these channels have in common is that none of them enters the contrast ratio
measurement. An interface passing an accessibility audit does not mean its contrast is
sufficient; the audit measures only the color channel. The reverse also holds: a
distinction that fails to clear the color contrast threshold cannot be rescued by
reinforcing it with size or shape contrast — the criterion must be satisfied for the
color channel separately.

#### Summary

- Contrast is a perceptual principle that can be built on the color, size, shape,
  direction, and space channels; the contrast ratio is a measured quantity defined only
  for pairs of colors.
- Visual weight can be modeled as the product of the area of surfaces standing apart
  from the background and their relative luminance difference, which ties the emphasis
  discussion to a number.
- Emphasis share is a ratio; an element's share can drop purely because its neighbors
  changed, with the element itself untouched. The measured drop runs from 93.5 percent
  to 37.0 percent.
- When every action is drawn the same way, what determines the emphasis order is the
  label's length, not the action's importance; the absence of hierarchy is not neutral
  but random.
- The budget's real gain comes not from fading a secondary element (0.8 points) but from
  never drawing it filled (56.5 points).
- Suppression's floor is the perceptibility threshold; the WCAG 1.4.11 criterion
  requires a 3:1 contrast ratio for non-text elements, and this threshold marks the
  limit the budget calculation cannot go below.

#### Next Step

Up to this point, elements were separated, grouped, and emphasized; every step ran
through individual elements' own properties. What holds a screen together, though, is
not the elements themselves but the **axes** between them: the alignment of left edges,
column starting points, the distribution of weight across the two sides of the screen.
The next lesson uses the number of alignment axes as a measure, computes the catalog
interface's balance moment, and measures on real text how irregular a ragged right edge
actually is.

### Balance and Alignment

Source: https://academia.sh/en/courses/interface-fundamentals/balance-and-alignment

Defining alignment as a shared invisible axis; computing the number of alignment axes and area-weighted horizontal balance, and measuring optical alignment and the ragged right edge.

Up to this point, every decision was made through individual elements: how large this
element is, how close these two are, how heavy this button is. What holds a screen
together, though, is not the elements themselves but the invisible lines between them.
If the filter panel's left edge and the page title's left edge line up on the catalog
page, a relationship is established even though no line exists between them.

This lesson ties three questions to a number: how many alignment axes a layout has, how
weight is distributed along the horizontal axis of the screen, and how irregular a text
block's right edge is.

#### Alignment Is a Shared Axis

**Alignment** is multiple elements sharing the same invisible line. That line can be the
elements' left edge, right edge, vertical center axis, or text baseline. The term also
appeared in the How Computers Work course, in the sense of memory addresses landing on a
word boundary; here it refers to a visual axis, and the only thing the two uses share is
the idea of "landing on a common boundary."

Alignment's function is to establish a relationship. The proximity principle spends
space to bind elements together; alignment does the same job between distant elements
while spending nothing. If the page title at the very top of the screen and a footnote
at the very bottom sit on the same left axis, they read as belonging to the same column
even with 500 pixels between them.

In exchange, alignment produces a cost: **every new axis is one more rule.** Every
distinct left edge on screen is one more starting point the reader has to learn. The
axis count is therefore a directly measurable property of a layout.

#### Axis Count and Horizontal Balance

The program below takes the blocks on the catalog results page, counts how many
distinct left and right axes are used, and computes the layout's **area-weighted
horizontal center**. The second measure gives balance: each block's area is treated as
weight, the blocks' shared center of weight is found, and its deviation from the
container's center axis is measured.

```js
// 04-alignment.mjs — alignment axis count and area-weighted horizontal balance

const CONTAINER = 1200;

// Blocks on the catalog results page: (name, left edge, width, height)
const before = [
  { name: "page title", x: 24, w: 600, h: 40 },
  { name: "search field", x: 32, w: 520, h: 44 },
  { name: "filter panel", x: 24, w: 264, h: 480 },
  { name: "filter heading", x: 40, w: 232, h: 24 },
  { name: "results list", x: 312, w: 600, h: 480 },
  { name: "record title", x: 328, w: 560, h: 24 },
  { name: "record metadata", x: 336, w: 540, h: 18 },
];

const after = [
  { name: "page title", x: 24, w: 1152, h: 40 },
  { name: "search field", x: 24, w: 1152, h: 44 },
  { name: "filter panel", x: 24, w: 264, h: 480 },
  { name: "filter heading", x: 24, w: 264, h: 24 },
  { name: "results list", x: 312, w: 864, h: 480 },
  { name: "record title", x: 312, w: 864, h: 24 },
  { name: "record metadata", x: 312, w: 864, h: 18 },
];

function axes(blocks) {
  const left = new Set(blocks.map((b) => b.x));
  const right = new Set(blocks.map((b) => b.x + b.w));
  return { left: [...left].sort((a, b) => a - b), right: [...right].sort((a, b) => a - b) };
}

function balance(blocks) {
  const totalArea = blocks.reduce((t, b) => t + b.w * b.h, 0);
  const moment = blocks.reduce((t, b) => t + b.w * b.h * (b.x + b.w / 2), 0);
  const center = moment / totalArea;
  const deviation = center - CONTAINER / 2;
  return { center, deviation, ratio: (100 * Math.abs(deviation)) / (CONTAINER / 2) };
}

function report(name, blocks) {
  const a = axes(blocks);
  const b = balance(blocks);
  console.log(name);
  console.log(`  left axes (${a.left.length}): ${a.left.join(", ")}`);
  console.log(`  right axes (${a.right.length}): ${a.right.join(", ")}`);
  console.log(`  total axis count: ${a.left.length + a.right.length}`);
  console.log(
    `  area-weighted center: ${b.center.toFixed(1)}px   container center: ${CONTAINER / 2}px`
  );
  console.log(`  deviation: ${b.deviation.toFixed(1)}px   ${b.ratio.toFixed(1)}% of half-width`);
  console.log("");
}

report("before", before);
report("after", after);
```

```
before
  left axes (6): 24, 32, 40, 312, 328, 336
  right axes (7): 272, 288, 552, 624, 876, 888, 912
  total axis count: 13
  area-weighted center: 459.7px   container center: 600px
  deviation: -140.3px   23.4% of half-width

after
  left axes (2): 24, 312
  right axes (2): 288, 1176
  total axis count: 4
  area-weighted center: 608.6px   container center: 600px
  deviation: 8.6px   1.4% of half-width
```

The "before" layout looks flawless when examined element by element: every block sits
neatly inside its own container, the internal spacing is reasonable, nothing overflows.
The number says something different. Seven blocks use thirteen distinct axes; the left
edges start at 24, 32, 40, 312, 328, and 336 pixels. None of these values is wrong on
its own; the problem is that all of them exist **at the same time**. An 8-pixel
difference is not perceived as alignment, and it is not perceived as deliberate
indentation either; it just blurs the edge.

The "after" layout carries the same information on four axes. The record title now
aligns with the results list's left axis rather than with its own container's internal
spacing. The internal spacing has not disappeared; it moved outside the list, between
the blocks.

The second measure also shows another flaw in the first layout. The area-weighted
center sits at 459.7 pixels, 140.3 pixels left of the container's center axis. There is
a 288-pixel-wide strip of empty space on the right, and that strip serves no function.
When the results list is widened to the container's right edge, the deviation drops to
8.6 pixels.

#### Zero Deviation Is Not the Goal

The balance measure's purpose is not to impose symmetry. A symmetric layout is balanced,
but not every balanced layout is symmetric: an asymmetric layout with a narrow, tall
panel on the left and a wide, short block on the right can also produce a deviation
close to zero.

The measure's function is to catch **unintended** imbalance. A 23 percent deviation is
usually not a decision but a leftover: content was not extended to the right, the
container's width changed, but the blocks were never updated. The criterion used
throughout this course is: **a deviation exceeding 10 percent of the half-width requires
a justification.** If a justification exists, the deviation stays — a reading column may
be deliberately pushed left, or the space on the right may be deliberately reserved. If
no justification exists, it is corrected.

#### Optical Alignment

Mathematical alignment does not always produce perceived alignment. Shapes with edges
that are not straight look misaligned when their boxes are aligned. Correcting this
difference is called **optical alignment**.

There are three common cases. The first is round shapes: a circle looks smaller when it
sits in the same box as a square, because its edge touches the box at only four points.
The second is punctuation: a line beginning with a quotation mark looks indented by one
step, because of the open space inside the mark. The third is triangular shapes: a
triangle pointing right looks shifted left when centered exactly in its box, because its
mass is concentrated at its base.

What these three cases share is that the fix is not **immeasurable** but
**uncomputable**: the difference is real, but it is specific to the shape and has no
general formula. The applicable rule is this: an optical correction is applied only to
shapes with edges that are not straight, only at large sizes, and only with the amount
of the shift documented. Undocumented optical corrections are the most common source of
the consistency problem taken up in the next lesson: nobody knows why that 2 pixels is
there, and nobody dares to remove it.

#### The Right Edge of Text

A text block's left edge is an axis; its right edge is an irregular profile produced by
the line lengths. This profile is called the **ragged right edge**, and the degree of
its irregularity can be measured.

The program below wraps a paragraph at three different line lengths, extracts the
distribution of the line lengths, and computes how far the word gaps would stretch if
**justification** were applied. The measurement uses a fixed-width assumption: each
character is taken as 8 pixels, normal word spacing as 4 pixels.

```js
// 04-ragged-right.mjs — depth of the ragged right edge and the spacing cost of justification

const text =
  "A catalog record's detail view brings together the book's title, the author's " +
  "name, the publication year, the shelf code, and whether the book is currently " +
  "on the shelf, all within one single screen. A reader arrives at this screen " +
  "asking two separate questions: is this the exact book being sought, and can " +
  "it be borrowed right now today. The layout of the view answers both apart.";

// Fixed-width measurement: every character is assumed to have equal width.
function wrap(text, measure) {
  const words = text.split(" ");
  const lines = [];
  let current = "";
  for (const w of words) {
    const candidate = current ? `${current} ${w}` : w;
    if (candidate.length <= measure) current = candidate;
    else {
      lines.push(current);
      current = w;
    }
  }
  if (current) lines.push(current);
  return lines;
}

const CHARACTER_WIDTH = 8; // average character width in 16px body text
const NORMAL_SPACE = 4; // 0.25em word spacing

function measureColumn(measure) {
  const lines = wrap(text, measure);
  const bodyLines = lines.slice(0, -1); // the last line is not part of the rag calculation
  const lengths = bodyLines.map((s) => s.length);
  const average = lengths.reduce((t, u) => t + u, 0) / lengths.length;
  const deviation = Math.sqrt(
    lengths.reduce((t, u) => t + (u - average) ** 2, 0) / lengths.length
  );
  const shortest = Math.min(...lengths);
  const ragDepth = measure - shortest;

  // Justification: the missing width is distributed across the word gaps.
  const stretches = bodyLines.map((s) => {
    const gaps = s.split(" ").length - 1;
    const shortfall = (measure - s.length) * CHARACTER_WIDTH;
    return gaps > 0 ? shortfall / gaps : 0;
  });
  const maxStretch = Math.max(...stretches);

  console.log(`measure ${measure} characters  (${measure * CHARACTER_WIDTH}px)`);
  console.log(`  line count: ${lines.length}  (in rag calculation: ${bodyLines.length})`);
  console.log(`  line lengths: ${lengths.join(", ")}`);
  console.log(`  average ${average.toFixed(1)}  standard deviation ${deviation.toFixed(2)}`);
  console.log(`  rag depth: ${ragDepth} characters  (${((100 * ragDepth) / measure).toFixed(1)}% of the measure)`);
  console.log(
    `  largest word gap when justified: ${(NORMAL_SPACE + maxStretch).toFixed(1)}px  ` +
      `(${((NORMAL_SPACE + maxStretch) / NORMAL_SPACE).toFixed(2)} times normal)`
  );
  console.log("");
}

measureColumn(45);
measureColumn(68);
measureColumn(92);
```

```
measure 45 characters  (360px)
  line count: 10  (in rag calculation: 9)
  line lengths: 37, 45, 41, 43, 38, 42, 39, 40, 42
  average 40.8  standard deviation 2.39
  rag depth: 8 characters  (17.8% of the measure)
  largest word gap when justified: 16.8px  (4.20 times normal)

measure 68 characters  (544px)
  line count: 6  (in rag calculation: 5)
  line lengths: 68, 68, 63, 68, 68
  average 67.0  standard deviation 2.00
  rag depth: 5 characters  (7.4% of the measure)
  largest word gap when justified: 7.6px  (1.91 times normal)

measure 92 characters  (736px)
  line count: 5  (in rag calculation: 4)
  line lengths: 87, 92, 89, 91
  average 89.8  standard deviation 1.92
  rag depth: 5 characters  (5.4% of the measure)
  largest word gap when justified: 7.1px  (1.77 times normal)
```

The narrow column's right edge is deep: one line ends at 37 characters, leaving 17.8
percent of the measure empty. Here the right edge is not an axis but a jagged profile.

Justification promises to flatten this profile, but it pays for that with word spacing.
In the narrow column, the widest word gap grows to 4.20 times normal. When word gaps
exceed letter spacing, the gaps stack up between lines into vertical channels inside the
text, and reading no longer advances along the line but jumps between the gaps.

This ties a design decision to a number: justification is not applied unless the line
length is large enough. The measurement brings the stretch down to 1.77 times in the
92-character column; it climbs to 4.20 times in the 45-character column. The description
text in the catalog interface's filter panel lives in narrow columns, so the ragged
right edge is kept. The same calculation has a flip side: if the rag depth exceeds a
quarter of the measure, the problem is not a lack of justification but a column that is
too narrow.

#### Summary

- Alignment is an invisible axis shared by elements; it establishes a relationship
  between distant elements without spending space, and in exchange every new axis loads
  one more rule onto the reader.
- Axis count is a directly countable layout property; in the measured layout, seven
  blocks were brought down from thirteen axes to four, and no information was lost.
- The area-weighted horizontal center's deviation from the container's center measures
  balance; a deviation exceeding 10 percent of the half-width requires a justification,
  but zero deviation is not the goal.
- Optical alignment is needed only for shapes with edges that are not straight, has no
  formula, and when applied, the amount of the shift is documented.
- The depth of a ragged right edge can be measured; if the rag depth exceeds a quarter
  of the line length, the column is too narrow.
- Justification stretches a narrow column's word gaps to several times normal — 4.20
  times in the case measured here; it is this stretch ratio, not aesthetics, that
  determines the decision.

#### Next Step

This lesson measured a single screen's internal consistency. The real question sits
between screens: what is lost when a gap that is 8 pixels on the catalog interface's
search page becomes 10 pixels on the record detail page? The next lesson treats
repetition as the source of learnability, extracts an interface's decision inventory,
and counts how many different values are used to do the same job.

### Repetition and Consistency

Source: https://academia.sh/en/courses/interface-fundamentals/repetition-and-consistency

How repetition produces learnability; extracting a decision inventory, measuring value variety below the just-noticeable-difference threshold, and the cost of snapping to a scale.

The previous lesson measured a single screen's internal consistency. An interface,
though, is not a single screen. The catalog application's search page, results list,
record detail, and borrowing confirmation are drawn at different times, often by
different people. On the search page, 8 pixels were left between the label and the
field; on the record detail page, the same relationship uses 10 pixels. Nobody's eye
catches those two pixels. The problem is that those two pixels are a decision: made,
never defended, and now impossible to remove.

This lesson counts the interface's decisions. The number itself is not a quality
measure; the quality measure is the question of how many decisions actually produce a
distinction versus how many produce only variety.

#### Repetition Is a Promise About Prediction

**Repetition** is elements that carry the same function being drawn the same way.
**Consistency** is that repetition spread across the entire interface.

Repetition's value is cognitive, not aesthetic. A user runs a constant stream of
predictions while using an interface: this dark-green filled rectangle is a button, this
underlined text is a link, this pale 14-pixel line is metadata. When these predictions
are confirmed every time, learning accumulates; half the screen becomes understandable
without a second look. A single counterexample — a filled green rectangle turning out to
be an unclickable label — breaks the chain of prediction, and the user goes back to
testing every element again.

Two rules follow from this, and the two rules are not symmetric:

- **What looks the same behaves the same.** Violating this rule leads directly to an
  error; the user acts on a pattern they have learned and gets it wrong.
- **What behaves differently looks different.** Violating this rule leads not to an
  error but to slowness; the user cannot read the distinction from the form and has to
  find it by reading the text.

The second rule also draws consistency's boundary. Consistency does not mean making
everything look alike. If the **Borrow** button and the **Delete Record** button look
the same, consistency has not been achieved — an important distinction has been erased.

#### The Decision Inventory

The way to make a consistency discussion measurable is to count the values actually used
in the interface. The program below takes the catalog interface's raw values for four
properties and computes three things: how many distinct values are used, how many pairs
among those values fall below the **just noticeable difference** threshold, and how many
values would change perceptibly if snapped to a scale.

```js
// 05-inventory.mjs — the interface's decision inventory, indistinguishable pair count, and snapping to a scale

// Raw values found across the catalog interface's four screens.
const inventory = [
  {
    property: "spacing (px)",
    used: [4, 6, 8, 10, 12, 14, 16, 20, 24, 28, 32, 40, 48],
    scale: [4, 8, 12, 16, 24, 32, 48],
    jndThreshold: 2,
  },
  {
    property: "corner radius (px)",
    used: [2, 3, 4, 6, 8],
    scale: [4, 8],
    jndThreshold: 2,
  },
  {
    property: "border width (px)",
    used: [1, 1.5, 2, 3],
    scale: [1, 2],
    jndThreshold: 0.5,
  },
  {
    property: "font size (px)",
    used: [12, 13, 14, 15, 16, 18, 20, 24, 32],
    scale: [14, 16, 20, 24, 32],
    jndThreshold: 1,
  },
];

function indistinguishablePairs(values, threshold) {
  const pairs = [];
  for (let i = 0; i < values.length; i++) {
    for (let j = i + 1; j < values.length; j++) {
      if (Math.abs(values[i] - values[j]) <= threshold) pairs.push([values[i], values[j]]);
    }
  }
  return pairs;
}

function nearest(value, scale) {
  return scale.reduce((a, b) => (Math.abs(b - value) < Math.abs(a - value) ? b : a));
}

let beforeTotal = 0;
let afterTotal = 0;

for (const e of inventory) {
  const pairs = indistinguishablePairs(e.used, e.jndThreshold);
  const fitted = e.used.map((d) => {
    const target = nearest(d, e.scale);
    return { value: d, target, shift: Math.abs(target - d) };
  });
  const visible = fitted.filter((o) => o.shift > e.jndThreshold);
  const maxShift = Math.max(...fitted.map((o) => o.shift));

  beforeTotal += e.used.length;
  afterTotal += e.scale.length;

  console.log(e.property);
  console.log(`  values used: ${e.used.length}   scale steps: ${e.scale.length}`);
  console.log(
    `  indistinguishable pairs (diff <= ${e.jndThreshold}px): ${pairs.length}   ` +
      `example: ${pairs.slice(0, 3).map((c) => c.join("/")).join("  ")}`
  );
  console.log(`  largest snap shift: ${maxShift}px`);
  console.log(`  values requiring a visible change: ${visible.length}`);
  console.log(`    ${fitted.map((o) => `${o.value}->${o.target}`).join("  ")}`);
  console.log("");
}

console.log(`total values to learn: before ${beforeTotal}   after ${afterTotal}`);
console.log(`reduction: ${(100 * (1 - afterTotal / beforeTotal)).toFixed(1)}%`);
```

```
spacing (px)
  values used: 13   scale steps: 7
  indistinguishable pairs (diff <= 2px): 6   example: 4/6  6/8  8/10
  largest snap shift: 8px
  values requiring a visible change: 3
    4->4  6->4  8->8  10->8  12->12  14->12  16->16  20->16  24->24  28->24  32->32  40->32  48->48

corner radius (px)
  values used: 5   scale steps: 2
  indistinguishable pairs (diff <= 2px): 5   example: 2/3  2/4  3/4
  largest snap shift: 2px
  values requiring a visible change: 0
    2->4  3->4  4->4  6->4  8->8

border width (px)
  values used: 4   scale steps: 2
  indistinguishable pairs (diff <= 0.5px): 2   example: 1/1.5  1.5/2
  largest snap shift: 1px
  values requiring a visible change: 1
    1->1  1.5->1  2->2  3->2

font size (px)
  values used: 9   scale steps: 5
  indistinguishable pairs (diff <= 1px): 4   example: 12/13  13/14  14/15
  largest snap shift: 2px
  values requiring a visible change: 2
    12->14  13->14  14->14  15->14  16->16  18->16  20->20  24->24  32->32

total values to learn: before 31   after 16
reduction: 48.4%
```

#### The Invisible Cost of Inconsistency

The first reading of the inventory is a reduction in count: 31 distinct values come down
to 16 scale steps, a 48.4 percent reduction. On its own, that is not an interesting
number; the real finding sits in the second rows.

In the corner-radius row, five distinct values are used, and among those five values, **five pairs** fall
below the just-noticeable-difference threshold. That is, each of the five radii is indistinguishable from
at least one neighbor. Five separate decisions were made, and none of them produces a visible distinction.
In the same row, the number of values requiring a visible change when snapped to the scale is zero: all
five values slide within the threshold.

This shows what kind of cost inconsistency produces. The cost is not visual — nobody
notices the difference between 2 pixels and 3 pixels. The cost falls on the
**decision**: for every new element, the radius value is reconsidered, argued over in
reviews, and chosen differently by different teams. A visible price paid for an
invisible difference.

Looking at the total across the four properties, only 6 of the 31 values (3 + 0 + 1 + 2)
change perceptibly when snapped to the scale. The remaining 25 settle into place within
the threshold. This says that moving to a scale is not as destructive as it seems: most
of the inconsistency is invisible inconsistency, and it can be removed at no cost.

The 6 values that do require a visible change are handled separately. In the spacing
row, snapping 40 pixels to 32 pixels is an 8-pixel shift, and it changes the layout. For
these values, the question becomes "is the scale wrong": if 40 pixels is genuinely
needed, a step is added to the scale; if not, the value is snapped. A scale is not a
dogma — it is a tool that makes exceptions visible.

#### When Consistency Is Deliberately Broken

Repetition has exceptions, and exceptions are not harmful; **unrecorded** exceptions
are.

In the catalog interface, the button on the borrowing confirmation screen is larger than
the buttons in the results list. This looks like an inconsistency, but it has a
justification: the action on the confirmation screen is the only action that is
expensive to undo, and no other action exists on that screen. The size difference
produces a distinction here.

The distinguishing criterion is this: **an exception is an exception if it can state the
condition under which the rule does not apply; if it cannot, it is an error.** "A larger
size is used for the single action on a confirmation screen" is a condition — it can be
audited and repeated. "40 pixels just looked better here" is not a condition.

In practice, this distinction requires an **exception record**: next to every value that
departs from the rule, the condition for the departure is written down. When no record
is kept, two things happen at once — exceptions multiply, and nobody can tell anymore
which value is the rule and which is the exception. The previous lesson asked for
optical-alignment corrections to be documented; the reasoning is the same.

#### Internal Consistency and External Consistency

Consistency has two directions, and which one wins when they conflict is decided in
advance.

**Internal consistency** is the interface following its own rule. In the catalog, a
selected filter is shown the same way everywhere.

**External consistency** is the interface following the established rules of the
environment it runs in. Rules like underlined blue text being a link, confirmation
actions sitting on the right, and the way back sitting at the top left are not any
single interface's invention; the user has learned them elsewhere.

When a conflict arises, external consistency takes priority, because the user's prior
learning outweighs what a single interface teaches on its own. An interface can,
internally consistently, show links without underlines and in the same color as the
surrounding text; that is internal consistency, and it still makes it harder for the
user to find the clickable element.

#### Summary

- Repetition is elements that carry the same function being drawn the same way; its
  value is cognitive, not aesthetic, because it confirms the user's predictions and
  accumulates learning.
- The pair of rules is not symmetric: something that looks the same behaving
  differently leads to an error, and something that behaves differently looking the
  same leads to slowness.
- The decision inventory ties consistency to a number; in the measured interface, four
  properties use 31 distinct values, and a scale brings that down to 16 steps.
- Most of the inconsistency is invisible: all five pairs among the corner radius's five
  values fall below the just-noticeable-difference threshold, meaning the decision cost
  was paid but no visual distinction was produced.
- Moving to a scale changes only 6 of the 31 values perceptibly; the rest settle into
  place at no cost, so the consistency debt closes more cheaply than it appears to.
- An exception is legitimate, and gets recorded, if it can state the condition under
  which the rule does not apply; in a conflict, external consistency beats internal
  consistency.

#### Next Step

The visual principles section is complete here: distinction, grouping, emphasis,
alignment, and repetition. The last two lessons pointed to a shared gap — where the
alignment axes and the scale steps come from has not yet been determined. As long as
values are chosen one at a time, the inventory swells right back up. The next section
fills that gap, starting with the **grid**: it establishes the arithmetic relationship
between container width, column count, gutter, and margin, turning alignment axes from
chosen values into derived ones.

## Layout and Spacing

Grid systems, the spacing scale, density decisions, responsive and adaptive layout, and how input type affects sizing.

### Grid Systems

Source: https://academia.sh/en/courses/interface-fundamentals/grid-systems

The arithmetic relationship among container, grid margin, column count, and gutter; deriving column width, computing span widths, and testing the grid against content criteria.

The Visual Principles topic required reducing the number of alignment axes and fitting
values to a scale, but it did not say where those axes and values should come from. As
long as values are chosen one at a time, the inventory swells again: 24 pixels gets
chosen today, 28 pixels on another screen tomorrow, and within two weeks no one can tell
which one is correct.

The **grid** is the structure that turns this choice into a derivation. Once four numbers
are settled, every remaining horizontal measure is computed from those four numbers. This
lesson builds that arithmetic and tests the catalog interface's column division against
content criteria.

#### The Grid's Four Variables

A column grid is defined by four numbers:

- **Container width**, the total width of the area the content occupies.
- **Grid margin**, the strip between the container's edge and the first and last column.
  The Visual Presentation with CSS course established `margin` and `padding` as distinct
  properties; the grid margin, however, is a layout decision, and in practice it can be
  built with either property.
- **Column count**, how many equal shares the content area is divided into.
- **Gutter**, the space between two adjacent columns.

The relationship among these four is fixed:

$$
\text{column} = \frac{\text{container} - 2 \cdot \text{grid margin} - (n - 1) \cdot \text{gutter}}{n}
$$

What matters about this relationship is that column width is **not an independent
decision**. Three of the four numbers are chosen, and the fourth is computed. A design
never decides that "the column should be 74 pixels"; 74 comes out as a result.

The width of a block that spans multiple columns is derived the same way. The number of
columns a block covers is called its **span**, and its width includes not only the
columns it covers but the gutters between them: a span of $n$ columns occupies $n$
column widths plus $n-1$ gutters.

#### Column Width Is Derived

The program below computes column width for different container widths at fixed grid
margin and gutter values, derives span widths, and compares how divisible different
column counts are.

```js
// 06-grid.mjs — column width arithmetic, span widths, and divisibility

function grid(container, margin, columnCount, gutter) {
  const content = container - 2 * margin;
  const totalGutter = (columnCount - 1) * gutter;
  const column = (content - totalGutter) / columnCount;
  return { content, totalGutter, column };
}

function span(n, column, gutter) {
  return n * column + (n - 1) * gutter;
}

const MARGIN = 24;
const GUTTER = 24;
const COLUMN_COUNT = 12;

console.log("container  content  total gutter  column width  is integer");
for (const k of [1440, 1280, 1200, 1024, 960, 768]) {
  const g = grid(k, MARGIN, COLUMN_COUNT, GUTTER);
  console.log(
    `${String(k).padStart(9)}  ${String(g.content).padStart(7)}  ${String(g.totalGutter).padStart(12)}  ` +
      `${g.column.toFixed(3).padStart(12)}  ${Number.isInteger(g.column) ? "yes" : "NO"}`
  );
}

console.log("");
const base = grid(1200, MARGIN, COLUMN_COUNT, GUTTER);
console.log(`container 1200, margin ${MARGIN}, gutter ${GUTTER}, ${COLUMN_COUNT} columns -> column ${base.column}px`);
console.log("span  width  share of content");
for (const n of [3, 4, 6, 8, 9, 12]) {
  const g = span(n, base.column, GUTTER);
  console.log(
    `${String(n).padStart(4)}  ${String(g).padStart(5)}  ${((100 * g) / base.content).toFixed(1).padStart(15)}%`
  );
}

console.log("");
console.log("column count  divisors (excluding 1 and itself)  equal-share options");
for (const s of [8, 10, 12, 16]) {
  const divisors = [];
  for (let d = 2; d < s; d++) if (s % d === 0) divisors.push(d);
  console.log(`${String(s).padStart(12)}  ${divisors.join(", ").padEnd(34)}  ${divisors.length}`);
}

console.log("");
console.log("content-based check: filter panel >= 240px, results list >= 480px");
for (const container of [1200, 768]) {
  const g = grid(container, MARGIN, COLUMN_COUNT, GUTTER);
  console.log(`  container ${container}px  (column ${g.column}px)`);
  for (const [left, right] of [
    [3, 9],
    [4, 8],
    [5, 7],
  ]) {
    const leftSpan = span(left, g.column, GUTTER);
    const rightSpan = span(right, g.column, GUTTER);
    const status = leftSpan >= 240 && rightSpan >= 480 ? "passes" : "fails";
    console.log(`    ${left}+${right} columns -> ${leftSpan}px + ${rightSpan}px  ${status}`);
  }
}
```

```
container  content  total gutter  column width  is integer
     1440     1392           264        94.000  yes
     1280     1232           264        80.667  NO
     1200     1152           264        74.000  yes
     1024      976           264        59.333  NO
      960      912           264        54.000  yes
      768      720           264        38.000  yes

container 1200, margin 24, gutter 24, 12 columns -> column 74px
span  width  share of content
   3    270             23.4%
   4    368             31.9%
   6    564             49.0%
   8    760             66.0%
   9    858             74.5%
  12   1152            100.0%

column count  divisors (excluding 1 and itself)  equal-share options
           8  2, 4                                2
          10  2, 5                                2
          12  2, 3, 4, 6                          4
          16  2, 4, 8                             3

content-based check: filter panel >= 240px, results list >= 480px
  container 1200px  (column 74px)
    3+9 columns -> 270px + 858px  passes
    4+8 columns -> 368px + 760px  passes
    5+7 columns -> 466px + 662px  passes
  container 768px  (column 38px)
    3+9 columns -> 162px + 534px  fails
    4+8 columns -> 224px + 472px  fails
    5+7 columns -> 286px + 410px  fails
```

In the first table, the rows that stand out are the 1280 and 1024 containers: column
width does not come out as an integer, giving 80.667 and 59.333. This means the grid's
alignment axes do not land on exact pixel positions.

The conclusion to draw from this is not that the grid is broken. The conclusion is this:
**a grid is not a pixel specification, it is a division rule.** When column width comes
out fractional, the design decision attaches not to a measured value like "74 pixels" but
to a ratio like "a four-column span." A design tied to a measured value has to be
re-measured at every container width; a design tied to a ratio adapts on its own.
Whether a fractional width leaves a visible seam depends on the browser's layout phase,
and the design should not depend on it.

The second table gives span widths. In the catalog interface, the filter panel occupies
270 pixels with a 3-column span, and the results list occupies 858 pixels with a
9-column span. The total, 270 + 24 + 858 = 1152, is exactly the content width. The 24
pixels between them is a gutter; no additional space is added between the two blocks.
The most common mistake when using a grid is adding a margin on top of the gutter; the
result produces an axis that is not part of the grid.

#### Why Twelve Columns

The third table shows why column count is not arbitrary. A grid's job is to be able to
divide content into equal shares; what has value, then, is not the column count itself
but how many distinct equal divisions that count permits.

Twelve columns divide by 2, 3, 4, and 6: halves, thirds, quarters, and sixths can all be
built. Eight columns divide only by 2 and 4; a three-way division is not possible.
Sixteen columns divide by 2, 4, and 8, which again does not give a three-way division,
but it does provide finer adjustment.

This does not mean twelve is a rule. The criterion is this: **column count is the
smallest number that satisfies the divisions the content requires.** If the catalog
interface requires both two-way and three-way divisions, twelve is enough; if it requires
only two-way division, eight produces fewer decisions, and fewer decisions mean less
inconsistency.

#### Testing the Grid Against Content

A grid is not correct or incorrect on its own; it is evaluated once tested against
content. The catalog interface has two criteria: the filter panel needs at least 240
pixels so filter labels fit without wrapping; the results list needs at least 480 pixels
so record titles keep a readable line length.

The fourth table applies this criterion to three divisions. At a 1200-pixel container,
all three divisions pass; the choice among them is made on other grounds. The 3+9
division is preferred in the catalog because it gives the results list the most room: the
screen's primary task is scanning results, and the filter is a secondary tool.

At a 768-pixel container, though, **no division passes.** The 5+7 division gives the
filter 286 pixels, but only 410 pixels are left for the results list; the 4+8 division
fails both criteria. This is not a failure of the grid — it is information the grid
provides: a two-column layout cannot be built at this width. Insisting on keeping the two
blocks side by side makes both of them unusable.

The same calculation also says where the layout change should happen. The smallest
container width at which the results list can hold 480 pixels is found at the point
where an 8-column span equals 480; below that, the layout switches to a single column.
How this transition is defined is the subject of the Responsive and Adaptive Layout
lesson.

#### Grid Types

A column grid is not the only type; other grids are chosen depending on content
structure.

- **Manuscript grid** consists of a single text block and the margins around it. The
  description text in the record detail view uses this grid; it does not need division.
- **Column grid** is the structure computed above: horizontal division, vertical freedom.
- **Modular grid** adds one more division to the column grid, this time horizontal; rows
  are also split into equal heights. Results lists presented as cards use this grid,
  because a card's width and height both need to align.
- **Baseline grid** ties the vertical measure to the text's line height; every line of
  text sits on a shared horizontal line. Its cost is high: the line height of every text
  level, at every size, has to conform to a shared divisor.

The selection criterion is which axis the content needs to align on. If only horizontal
alignment is needed, a column grid is enough; if vertical alignment is needed too, the
cost of a modular or baseline grid is worth accepting.

#### Summary

- A grid is defined by four numbers: container width, grid margin, column count, and
  gutter. Column width is not an independent decision; it is a result derived from these
  four.
- A span's width is the sum of the columns it covers and the gutters between them; no
  additional space is added between blocks placed on the grid — the gutter already does
  that job.
- Column width does not come out as an integer at every container; the design attaches
  to the span ratio, not to a measured pixel value.
- The value of a column count is how many equal divisions it permits; twelve is common
  because it divides by 2, 3, 4, and 6, but the criterion is the smallest number that
  satisfies the divisions the content requires.
- A grid is tested against the content's minimum width requirements; a width at which no
  division satisfies the criteria marks the point where the layout needs to change.
- Grid type is chosen based on which axis the content needs to align on; if vertical
  alignment is not needed, the cost of modular and baseline grids is not paid.

#### Next Step

The grid made the horizontal measure derivable: column boundaries are no longer chosen,
they are computed. The vertical measure, though, is still free — the gutter was chosen
as 24 pixels, the grid margin was chosen as 24 pixels, but where these numbers came from
was never said. The inventory pulled out in the Repetition and Consistency lesson swelled
in exactly this gap. The next lesson ties spacing values to a production rule: it
computes and compares linear, geometric, and hybrid scales, and measures how many steps
a scale needs to cover the catalog interface's 13 raw spacing values.

### Spacing Scale

Source: https://academia.sh/en/courses/interface-fundamentals/spacing-scale

Deriving spacing values instead of choosing them; comparing linear, geometric, and hybrid scales by step count, distinguishability, and grouping ratio.

The grid made the horizontal measure derivable: column boundaries are no longer chosen,
they are computed. The grid's own inputs, though, were still free. The grid margin was
chosen as 24 pixels, the gutter was chosen as 24 pixels. The same freedom continues on
the vertical axis: the space between records, the space above a section heading, a
button's padding.

The inventory pulled out in the Repetition and Consistency lesson swelled in exactly this
gap: 31 distinct values across four properties, 13 of them spacing. This lesson ties
those 13 values to a production rule and shows which criteria the rule is chosen by.

#### A Scale Is a Production Rule

A **spacing scale** is a finite, rule-governed set of spacing values that can be used in
the interface. What the scale provides is not the list itself but the list being
**closed**: a value that is not on the scale cannot be used.

This closure does two things. First, it lowers the number of decisions; the question for
a given space becomes not "how many pixels" but "which step," and the number of options
stays below ten. Second, it makes the decision auditable: a value outside the scale is
not a preference, it is a violation.

A scale has four criteria, and these criteria pull against each other:

- **Step count.** Few steps mean few decisions, but too few steps cannot provide the
  distinction that is needed.
- **Distinguishability.** The difference between two neighboring steps has to be above
  the just-noticeable-difference threshold; if it is not, the scale carries a redundant
  step.
- **Grouping ratio.** The criterion set in the Proximity and Grouping lesson required a
  group boundary to be at least twice the within-group spacing. The scale needs to
  include step pairs whose ratio is at least 2.
- **Proximity to raw values.** When the existing design is fitted to the scale, the
  shifts should stay small; large shifts make the scale transition expensive.

#### Computing Three Scale Families

The program below generates three scale families and evaluates them against the four
criteria. The raw values are the 13 spacing values pulled out in the Repetition and
Consistency lesson.

```js
// 07-scale.mjs — comparison of linear, geometric, and hybrid spacing scales

const BASE = 4;
const TOP = 96;
const DISTINGUISH_THRESHOLD = 2; // two steps below this difference cannot be distinguished

function linear(base, step, top) {
  const o = [];
  for (let v = base; v <= top; v += step) o.push(v);
  return o;
}

function geometric(base, ratio, top) {
  const o = [];
  let v = base;
  while (v <= top) {
    o.push(Math.round(v));
    v *= ratio;
  }
  return [...new Set(o)];
}

// Catalog interface's raw spacing values (from the Repetition and Consistency lesson).
const RAW = [4, 6, 8, 10, 12, 14, 16, 20, 24, 28, 32, 40, 48];

function nearest(value, scale) {
  return scale.reduce((a, b) => (Math.abs(b - value) < Math.abs(a - value) ? b : a));
}

function evaluate(name, scale) {
  const ratios = [];
  let indistinguishable = 0;
  for (let i = 1; i < scale.length; i++) {
    ratios.push(scale[i] / scale[i - 1]);
    if (scale[i] - scale[i - 1] <= DISTINGUISH_THRESHOLD) indistinguishable++;
  }
  const shifts = RAW.map((h) => Math.abs(nearest(h, scale) - h));
  const avgShift = shifts.reduce((t, k) => t + k, 0) / shifts.length;
  // For nested grouping, a consecutive step pair: ratio must be at least 2 (see Proximity and Grouping).
  const groupable = ratios.filter((o) => o >= 2).length;

  console.log(`${name}`);
  console.log(`  steps: ${scale.join(", ")}`);
  console.log(`  step count: ${scale.length}`);
  console.log(
    `  neighboring ratio range: ${Math.min(...ratios).toFixed(2)} - ${Math.max(...ratios).toFixed(2)}`
  );
  console.log(`  indistinguishable neighboring pairs (diff <= ${DISTINGUISH_THRESHOLD}px): ${indistinguishable}`);
  console.log(`  neighboring pairs with ratio 2 or greater: ${groupable}`);
  console.log(`  average shift of raw values: ${avgShift.toFixed(2)}px   largest: ${Math.max(...shifts)}px`);
  console.log("");
}

evaluate("linear, 4px steps", linear(BASE, 4, TOP));
evaluate("linear, 8px steps", linear(BASE, 8, TOP));
evaluate("geometric, ratio 2.0", geometric(BASE, 2, TOP));
evaluate("geometric, ratio 1.5", geometric(BASE, 1.5, TOP));

const HYBRID = [4, 8, 12, 16, 24, 32, 48, 64, 96];
evaluate("hybrid", HYBRID);

// Grouping levels do not have to be neighboring steps; they are chosen from within the scale.
const GROUPING = [4, 8, 24, 48];
console.log("are grouping levels chosen from within the hybrid scale");
for (const d of GROUPING) {
  console.log(`  ${String(d).padStart(3)}px  in scale: ${HYBRID.includes(d) ? "yes" : "NO"}  (step ${HYBRID.indexOf(d) + 1})`);
}
console.log("  consecutive grouping level ratios:");
for (let i = 1; i < GROUPING.length; i++) {
  const o = GROUPING[i] / GROUPING[i - 1];
  console.log(`    ${GROUPING[i - 1]} -> ${GROUPING[i]}  ratio ${o.toFixed(2)}  ${o >= 2 ? "separates" : "UNCLEAR"}`);
}
```

```
linear, 4px steps
  steps: 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 68, 72, 76, 80, 84, 88, 92, 96
  step count: 24
  neighboring ratio range: 1.04 - 2.00
  indistinguishable neighboring pairs (diff <= 2px): 0
  neighboring pairs with ratio 2 or greater: 1
  average shift of raw values: 0.46px   largest: 2px

linear, 8px steps
  steps: 4, 12, 20, 28, 36, 44, 52, 60, 68, 76, 84, 92
  step count: 12
  neighboring ratio range: 1.10 - 3.00
  indistinguishable neighboring pairs (diff <= 2px): 0
  neighboring pairs with ratio 2 or greater: 1
  average shift of raw values: 2.31px   largest: 4px

geometric, ratio 2.0
  steps: 4, 8, 16, 32, 64
  step count: 5
  neighboring ratio range: 2.00 - 2.00
  indistinguishable neighboring pairs (diff <= 2px): 0
  neighboring pairs with ratio 2 or greater: 4
  average shift of raw values: 3.85px   largest: 16px

geometric, ratio 1.5
  steps: 4, 6, 9, 14, 20, 30, 46, 68
  step count: 8
  neighboring ratio range: 1.43 - 1.56
  indistinguishable neighboring pairs (diff <= 2px): 1
  neighboring pairs with ratio 2 or greater: 0
  average shift of raw values: 1.69px   largest: 6px

hybrid
  steps: 4, 8, 12, 16, 24, 32, 48, 64, 96
  step count: 9
  neighboring ratio range: 1.33 - 2.00
  indistinguishable neighboring pairs (diff <= 2px): 0
  neighboring pairs with ratio 2 or greater: 1
  average shift of raw values: 1.69px   largest: 8px

are grouping levels chosen from within the hybrid scale
    4px  in scale: yes  (step 1)
    8px  in scale: yes  (step 2)
   24px  in scale: yes  (step 5)
   48px  in scale: yes  (step 7)
  consecutive grouping level ratios:
    4 -> 8  ratio 2.00  separates
    8 -> 24  ratio 3.00  separates
    24 -> 48  ratio 2.00  separates
```

#### Every Scale Loses One Criterion

None of the four candidates satisfies all four criteria at once, and the losses land in
different places.

**The linear, 4-pixel-step scale** stays closest to the raw values: average shift 0.46
pixels. But it has 24 steps. The inventory held 13 distinct spacing values; this scale
allows 24, meaning it imposes no constraint at all. A scale's job is not to offer options
but to narrow them; a scale that allows more options than the raw state does not earn the
name.

**The linear, 8-pixel-step scale** brings the step count down to 12, but because it
starts from a base of 4, it never includes common values like 8, 16, or 24: its steps
run 4, 12, 20, 28. Average shift rises to 2.31 pixels. Its loss is that the base does not
match the step size.

**The geometric scale at ratio 2.0** generates only five steps, and all the neighboring
ratios are exactly 2 — ideal for grouping. Its cost is the gap in the upper range: after
16 it jumps straight to 32, then to 64. The average shift of raw values is 3.85, the
largest shift 16 pixels. A 24-pixel spacing value has no counterpart on the scale.

**The geometric scale at ratio 1.5** gives fine adjustment, but it has two flaws. Rounding
produces unmemorable numbers like 9, 14, 30, and 46, and more importantly: **the number
of neighboring pairs with a ratio of 2 or more is zero.** With this scale, a group
boundary cannot be expressed with a neighboring step; grouping always requires skipping
two steps.

**The hybrid scale** satisfies all four criteria at a middling level: 9 steps, no
indistinguishable neighboring pairs, average shift 1.69 pixels. Its loss is that the
largest shift is 8 pixels — this comes from the raw value of 40 landing on 32, and it is
a visible change.

The selection criterion is this: **a scale should be finest in the most-used range and
coarsest in the least-used range.** In interfaces, most spacing falls between 4 and 32
pixels; between 64 and 96, fine adjustment is not needed. The hybrid scale does exactly
this: it advances in 4-pixel steps in the lower range and doubles in the upper range.

#### A Scale Does Two Jobs at Once

The last block of output confirms the scale's second job. In the Proximity and Grouping
lesson, the catalog screen's four grouping levels were set at 4, 8, 24, and 48 pixels.
All four of these values are inside the hybrid scale: steps 1, 2, 5, and 7. The ratios
between them are 2, 3, and 2 — all three clear the grouping threshold.

The real lesson here is that grouping levels **do not have to be neighboring steps.** A
scale does two different jobs: neighboring steps close together for fine adjustment,
steps far apart from each other for grouping. A scale generated with a single fixed
ratio can only do one of these jobs. A ratio of 2.0 gives grouping but not fine
adjustment; a ratio of 1.5 gives fine adjustment but not grouping. The hybrid scale gives
both, because grouping levels are **chosen** from among the steps.

This requires one more rule when using a scale: access to the scale is not left
unrestricted. A designer can choose 12 pixels for a group boundary; it exists on the
scale, but if the within-group spacing is 8 pixels, the ratio stays at 1.5. The scale
does not block the wrong value, it blocks the wrong **pair**. This is why the audit does
not look at scale membership but at the ratio of the pairs actually used.

#### Naming a Step by Its Role, Not Its Measure

The names given to scale steps are also a decision. If a step is named by its measure —
"twenty-four-pixel spacing" — every name becomes wrong the moment the scale changes. If a
step is named by its position — "the fifth step" — every number shifts the moment a step
is inserted in between.

The usable route is to tie the name to the **role**, not the measure: within-group
spacing, between-block spacing, between-section spacing. A role name defines, in one
place, which step of the scale it corresponds to; when the scale changes, only that
definition changes, and the rest of the interface stays the same. The system-level
counterpart of this approach is the subject of the Design Systems course; the only rule
needed here is that spacing decisions are referred to by role name.

#### Summary

- A spacing scale is a closed set of usable spacing values; its value comes not from the
  list but from the impossibility of stepping outside it.
- A scale is evaluated against four criteria: step count, the distinguishability of
  neighboring steps, the presence of ratios of 2 or more for grouping, and the shift
  incurred when fitting to raw values.
- The linear, 4-pixel-step scale imposes no constraint because it allows 24 values; a
  scale's job is not to offer options but to narrow them.
- A scale generated with a single fixed ratio gives only one of fine adjustment and
  grouping: a ratio of 2.0 gives grouping, a ratio of 1.5 gives fine adjustment.
- The hybrid scale satisfies both jobs by advancing finely in the lower range and coarsely
  in the upper range; grouping levels are chosen from within the scale, not from
  neighboring steps.
- Scale membership alone is not enough; the audit looks at the ratio of the spacing pairs
  used, and steps are referred to by role, not by measure.

#### Next Step

The scale said which values spacing can take, not which step gets used where. In the
catalog results list, whether a given record uses 8 pixels or 24 pixels directly
determines how many records appear on one screen. The next lesson computes that
trade-off: it derives the number of records per screen from row height, finds how many
screens are needed to scan 200 results, and shows in which direction the density
decision is made for which tasks.

### Density Decisions

Source: https://academia.sh/en/courses/interface-fundamentals/density-decisions

Deriving information density from the task; computing records per screen, scan cost, and spacing ratio from row height.

The spacing scale said which values spacing could take, not which step gets used where.
This question takes its most concrete form in the catalog results list: should a record
row's padding be 6 pixels or 16 pixels?

The question looks aesthetic, but its consequence is arithmetic. Padding determines row
height, row height determines how many records appear on a screen, and that determines
how many screens are needed to scan 200 results. This lesson computes that chain from end
to end.

#### Density Is Derived from the Task

**Information density** is the amount of meaningful information per unit of screen area.
High density shows more information at once; low density gives each piece of information
more room.

Neither is generally better. The correct density is derived from the screen's primary
task, and two task types pull in opposite directions:

- **Scanning task.** The user is searching for one item among many. The decision
  criterion is a single field — most often the title. In this task, every additional
  pixel of space raises the odds that the item being searched for falls off the screen.
- **Evaluation task.** The user is choosing by comparing among a small number of items.
  The decision criterion is multiple fields: author, year, edition, shelf availability.
  In this task, crowding causes the fields to blur into each other.

The catalog interface has both tasks, but not on the same screen. The results list is a
scanning screen; the record detail view is an evaluation screen. Applying the same
density decision to both breaks both of them.

#### Computing the Chain

The program below computes a record row's total height for three density levels, and
derives from it the number of records per screen, the number of fields shown on screen,
the number of screens needed to scan 200 results, and the number of screens to page
through to reach the record ranked 137th. The `row` column in the output is the record
row's total height; the `text` column is the portion of that height taken up by lines of
text.

```js
// 08-density.mjs — records per screen and scan cost from row height

const VIEWPORT_HEIGHT = 900;
const FIXED_AREA = 260; // header, search field, and filter strip
const LIST_AREA = VIEWPORT_HEIGHT - FIXED_AREA;

const RESULT_COUNT = 200;
const TARGET_RANK = 137; // the searched record's rank in the list

const TOUCH_THRESHOLD = 24; // WCAG 2.5.8 minimum target size (CSS px)

const levels = [
  { name: "compact", fontSize: 14, multiplier: 1.4, padding: 6, fieldCount: 1 },
  { name: "balanced", fontSize: 16, multiplier: 1.5, padding: 12, fieldCount: 2 },
  { name: "spacious", fontSize: 16, multiplier: 1.6, padding: 16, fieldCount: 3 },
];

console.log(`list area: ${LIST_AREA}px   result count: ${RESULT_COUNT}   target rank: ${TARGET_RANK}`);
console.log("");
console.log("level    row  text  space  records/screen  fields/screen  screens  to target  touch");
for (const d of levels) {
  const lineHeight = Math.round(d.fontSize * d.multiplier);
  const text = d.fieldCount * lineHeight;
  const row = text + 2 * d.padding + 1; // 1px divider line
  const spaceRatio = (100 * (row - text)) / row;
  const recordsPerScreen = Math.floor(LIST_AREA / row);
  const fieldsOnScreen = recordsPerScreen * d.fieldCount;
  const screenCount = Math.ceil(RESULT_COUNT / recordsPerScreen);
  const screensToTarget = Math.ceil(TARGET_RANK / recordsPerScreen);
  const touch = row >= TOUCH_THRESHOLD ? "passes" : "fails";

  console.log(
    `${d.name.padEnd(8)} ${String(row).padStart(4)}px ${String(text).padStart(6)} ` +
      `${spaceRatio.toFixed(1).padStart(6)}% ${String(recordsPerScreen).padStart(12)} ` +
      `${String(fieldsOnScreen).padStart(11)} ${String(screenCount).padStart(6)} ` +
      `${String(screensToTarget).padStart(7)} ${touch.padStart(8)}`
  );
}

// The compact level's padding is 6px; it is not in the hybrid spacing scale (4, 8, 12, 16, 24, ...).
// For the two neighboring steps that land on the scale, the same chain is recomputed.
console.log("");
console.log("fitting the compact level to the scale (font 14, multiplier 1.4, single field)");
console.log("padding  row  records/screen  screens for 200 results  to target");
for (const p of [4, 6, 8]) {
  const row = Math.round(14 * 1.4) + 2 * p + 1;
  const records = Math.floor(LIST_AREA / row);
  console.log(
    `${String(p).padStart(7)}px ${String(row).padStart(6)}px ${String(records).padStart(12)} ` +
      `${String(Math.ceil(RESULT_COUNT / records)).padStart(21)} ${String(Math.ceil(TARGET_RANK / records)).padStart(13)}`
  );
}
```

```
list area: 640px   result count: 200   target rank: 137

level    row  text  space  records/screen  fields/screen  screens  to target  touch
compact    33px     20   39.4%           19          19     11       8   passes
balanced   73px     48   34.2%            8          16     25      18   passes
spacious  111px     78   29.7%            5          15     40      28   passes

fitting the compact level to the scale (font 14, multiplier 1.4, single field)
padding  row  records/screen  screens for 200 results  to target
      4px     29px           22                    10             7
      6px     33px           19                    11             8
      8px     37px           17                    12             9
```

#### What Changes Is Not the Amount of Information, but Its Shape

The most instructive column in the output is `fields/screen`: 19, 16, 15. Across the
three density levels, the total number of fields visible on screen is nearly the same,
with only a 21 percent difference.

This breaks the common framing of the density debate. The claim "a compact layout shows
more information" is not true; a compact layout shows **more records**, a spacious layout
shows **more fields per record**. The total information a screen carries is roughly
constant. The decision is not about the amount of information but its distribution: the
titles of 19 records, or three fields each for 5 records?

With this framing, the task distinction turns directly into a rule. In the scanning task,
the user needs many titles; the compact layout shows 19 records, and record 137 is
reached in 8 screens. The spacious layout takes 28 screens to reach the same record —
three and a half times as many. In the evaluation task, by contrast, the spacious layout
gives all three fields of every record at once, and the user does not have to enter the
detail view to compare.

#### Spacing Ratio Misleads Intuition

The output's `space` column says the opposite of what is expected: the compact row's
space ratio is 39.4 percent, the spacious row's is 29.7 percent. The layout named
"spacious" allocates a **smaller** portion of its row height to space.

The contradiction is only apparent. In the spacious layout, the row is long because it
contains three lines of text; even though padding rises to 16 pixels, the text's share
grows faster. The ratio falls while absolute space rises: the compact row has 13 pixels
of space in total, the spacious row has 33.

Two conclusions follow. First, the words "spacious" and "compact" do not by themselves
give a measure; which quantity is being measured has to be stated. Second, there are two
distinct ways to change density, and their effects differ: **changing padding** affects
row height linearly, while **changing the field count** jumps row height by a full line
height. In the spacious layout, padding is 2.67 times the compact layout's, but row
height rises to 3.36 times; the source of the difference is the two extra fields.

A designer who wants to lower density first has to know which lever they are pressing.
Adding a field is expensive and hard to reverse; changing padding is cheap and moves
between scale steps.

#### Where Density Draws from the Scale

The calculation itself reveals an inconsistency. The compact level's padding is 6 pixels,
and this value is not on the hybrid spacing scale chosen in the previous lesson — 4, 8,
12, 16, 24, and onward. The density decision has stepped outside the scale.

The output's second block shows what fitting to the scale changes. If padding is pulled
down to 4 pixels, the row drops to 29 pixels, 22 records appear on screen, and record 137
is reached in 7 screens. If raised to 8 pixels, the row goes to 37 pixels, records per
screen to 17, and reaching the target to 9 screens. The off-scale 6 pixels sits between
these two and gains nothing: the 4-pixel step shows more records, the 8-pixel step
separates them more comfortably, and 6 pixels carries the rationale of neither.

This clarifies the relationship between the scale and density. The scale **discretizes**
the density decision: instead of infinitely many intermediate values, it leaves a
countable handful of options. The consequence of each option — records per screen, scan
cost — can be computed in advance. Density thereby stops being a dial and becomes a
choice; a choice can be justified, an intermediate value cannot.

#### Leaving Density to the User

There are cases where the task distinction cannot be resolved on a single screen: the
same results list might be used for both quick scanning and comparison. In that case,
density is turned into a setting, and the user chooses.

This solution's cost is real and often underestimated. Every density level is a separate
layout: separate row height, separate field set, separate overflow behavior. Two levels
mean twice the testing. The choice also needs to persist — if the user has to choose
again every session, the setting is a burden rather than a convenience.

The criterion is this: **a density setting is added only when both tasks can be shown to
be primary.** If one of the two tasks is clearly dominant, no setting is added; the
correct density is chosen for the dominant task, and the other task is moved to a
separate screen. In the catalog interface, the results list exists for scanning and the
record detail view for evaluation; no setting is needed.

#### The Lower Bound of Density

Density cannot be raised without limit. The output's last column shows whether the record
row meets the 24 CSS pixels required by the WCAG 2.5.8 criterion: all three levels pass.

But 2.5.5, the enhanced form of the same criterion, raises the target size to 44 CSS
pixels. The compact layout's 33-pixel row falls below this threshold; the balanced
layout's 73 pixels and the spacious layout's 111 pixels are above it. So if the entire
row is a clickable target, the compact layout meets only the lowest threshold.

This shows that the density decision cannot be made independently of input type. The
same 33-pixel row is comfortable in a pointer-driven environment and a missed target in a
touch-driven one. The exact measure of this dependency is computed in the topic's last
lesson.

#### Summary

- Information density is not a preference; it is a decision derived from the screen's
  primary task. The scanning task pulls toward higher density, the evaluation task
  toward lower.
- Record row height determines records per screen, which determines scan cost: record
  137 is reached in 8 screens at the compact level, 28 at the spacious level. The total
  number of fields visible on screen, by contrast, stays roughly constant (19, 16, 15);
  what changes is not the amount of information but its distribution across records.
- Spacing ratio misleads intuition: the spacious layout's space ratio is lower, because
  what grows row height is not padding but the added lines of text.
- The density decision draws from the spacing scale's steps; when 4 or 8 is chosen
  instead of the off-scale 6-pixel padding, records per screen become 22 or 17, and both
  choices can be justified, while the value between them cannot.
- A density setting is added only when both tasks can be shown to be primary; every
  setting brings a second layout and the burden of persisting state.
- Target size criteria draw density's lower bound; the 33-pixel row passes the lowest
  threshold and falls short of the enhanced one.

#### Next Step

This lesson's calculations assumed one fixed viewport height and one fixed container
width. The Grid Systems lesson found that at a 768-pixel container, no column division
met the content criteria. Both findings lead to the same question: what does a layout do
when width changes? The next lesson establishes the difference between the responsive and
adaptive approaches, computes how column count is derived from width, and shows that
breakpoints are derived from content criteria rather than device lists.

### Responsive and Adaptive Layout

Source: https://academia.sh/en/courses/interface-fundamentals/responsive-and-adaptive-layout

The difference between continuous and discrete layout approaches; deriving a breakpoint from content criteria and computing the widths that need testing in a continuous grid.

The calculations in the previous two lessons rested on a single container width. The
Grid Systems lesson already showed the limit of this assumption: at a 768-pixel
container, no column division met the content criteria. The Density Decisions lesson, in
turn, assumed a single fixed viewport height.

Both findings lead to the same question: what does a layout do if the container's width
is not a number the designer chooses but a variable determined at the moment of use?
This lesson distinguishes the two approaches that answer this question differently and
computes where transition points are derived from.

#### Continuous Layout and Discrete Layout

**Responsive layout** is the approach that treats container width as a continuous
variable. Measures are given as ratios with minimum and maximum bounds; the layout is
defined at every intermediate width.

**Adaptive layout** is the approach in which separate layouts are designed for a handful
of predetermined widths. Intermediate widths round to the nearest layout; the layout
exists in one of a finite number of states.

The difference is not an implementation detail, it is the design contract itself. In the
responsive approach, the designer designs **rules** and leaves the outcome of every
intermediate width to the rule. In the adaptive approach, the designer designs **states**
and directly controls the appearance of every state.

Two asymmetries follow from this. The responsive approach requires less work but gives
less control: at a width nobody has looked at, the layout is still defined, but whether
it is correct is not guaranteed. The adaptive approach requires more work but keeps
control complete: every state has been designed individually, and there is no undesigned
state.

#### The Breakpoint Is Derived from Content

In both approaches, the layout has to change shape at some point. This transition width
is called a **breakpoint**.

The common way breakpoints are chosen is a list of device widths. This method does not
survive five years, and it asks the wrong question to begin with: the issue is not how
wide the device is, but how much width the content needs. The catalog interface's two
criteria were set in the Grid Systems lesson: the filter panel needs at least 240 pixels,
the results list at least 480 pixels.

The program below searches for the smallest container width that satisfies these two
criteria, then derives a continuous card grid's column count from width, and finds the
exact widths at which column count changes.

```js
// 09-responsive.mjs — deriving the breakpoint from content and continuous column count

const MARGIN = 24;
const GUTTER = 24;
const COLUMN_COUNT = 12;

const FILTER_MIN = 240; // filter labels fit without wrapping
const LIST_MIN = 480; // record titles keep a readable length

function columnWidth(container) {
  return (container - 2 * MARGIN - (COLUMN_COUNT - 1) * GUTTER) / COLUMN_COUNT;
}
function span(n, column) {
  return n * column + (n - 1) * GUTTER;
}

// 1) The smallest container width at which a two-column layout can stand.
let smallest = null;
let smallestSplit = null;
for (let k = 600; k <= 1200; k++) {
  const s = columnWidth(k);
  for (let left = 2; left <= 6; left++) {
    if (span(left, s) >= FILTER_MIN && span(COLUMN_COUNT - left, s) >= LIST_MIN) {
      smallest = k;
      smallestSplit = [left, COLUMN_COUNT - left];
      break;
    }
  }
  if (smallest !== null) break;
}
console.log("1) the threshold for a two-column layout");
console.log(`   criteria: filter >= ${FILTER_MIN}px, list >= ${LIST_MIN}px`);
console.log(`   smallest container: ${smallest}px   split: ${smallestSplit.join("+")} columns`);
const s0 = columnWidth(smallest);
console.log(
  `   at this width: filter ${span(smallestSplit[0], s0).toFixed(1)}px, ` +
    `list ${span(smallestSplit[1], s0).toFixed(1)}px`
);
console.log(`   the commonly cited 768px from device lists is ${smallest - 768}px below this threshold`);

// 2) Continuous column count: card grid minimum width 220px, gap 24px.
const CARD_MIN = 220;
const GAP = 24;
function cardCount(container) {
  return Math.max(1, Math.floor((container + GAP) / (CARD_MIN + GAP)));
}
console.log("");
console.log("2) card grid: minimum card width 220px, gap 24px");
console.log("   container  card count  card width");
for (const k of [360, 480, 720, 960, 1200, 1440]) {
  const n = cardCount(k);
  const width = (k - (n - 1) * GAP) / n;
  console.log(
    `   ${String(k).padStart(9)}  ${String(n).padStart(11)}  ${width.toFixed(1).padStart(14)}px`
  );
}

// 3) Exact widths at which column count changes: W + gap = n * (min + gap)
console.log("");
console.log("3) widths at which column count changes (values to test)");
for (let n = 2; n <= 6; n++) {
  const threshold = n * (CARD_MIN + GAP) - GAP;
  console.log(`   transition to ${n} columns: ${threshold}px  (verification: ${cardCount(threshold - 1)} -> ${cardCount(threshold)})`);
}
console.log(`   integer widths between 320 and 1440: ${1440 - 320 + 1}`);
console.log("   widths that need testing: 5 transitions + 2 endpoints = 7");
```

```
1) the threshold for a two-column layout
   criteria: filter >= 240px, list >= 480px
   smallest container: 816px   split: 4+8 columns
   at this width: filter 240.0px, list 504.0px
   the commonly cited 768px from device lists is 48px below this threshold

2) card grid: minimum card width 220px, gap 24px
   container  card count  card width
         360            1           360.0px
         480            2           228.0px
         720            3           224.0px
         960            4           222.0px
        1200            5           220.8px
        1440            6           220.0px

3) widths at which column count changes (values to test)
   transition to 2 columns: 464px  (verification: 1 -> 2)
   transition to 3 columns: 708px  (verification: 2 -> 3)
   transition to 4 columns: 952px  (verification: 3 -> 4)
   transition to 5 columns: 1196px  (verification: 4 -> 5)
   transition to 6 columns: 1440px  (verification: 5 -> 6)
   integer widths between 320 and 1440: 1121
   widths that need testing: 5 transitions + 2 endpoints = 7
```

The first block gives the breakpoint: 816 pixels. This number was not chosen; it came out
of two content criteria and the grid arithmetic. The 768 pixels that recurs in device
lists is 48 pixels below this threshold; if a two-column layout were built at that width,
either the filter panel or the results list would drop below its own minimum width.

A derived breakpoint has one more advantage: when content changes, the breakpoint changes
with it. If filter labels grow longer, the minimum width grows, the calculation is
rerun, and a new threshold is found. A number taken from a device list silently becomes
wrong when content changes.

Once the breakpoint is found, the layout can be expressed with a single rule:

```css
/* Catalog results page: the two-column layout is built only above the threshold. */
.catalog {
  display: grid;
  gap: 24px;
  padding: 0 24px;
  grid-template-columns: 1fr;
}

@media (min-width: 816px) {
  .catalog {
    grid-template-columns: minmax(240px, 1fr) minmax(480px, 2fr);
  }
}
```

The 240 and 480 values in the declaration are exactly the criteria that produced the
breakpoint; the `1fr` and `2fr` ratio comes from the 4+8 column division. Below the
threshold, a single column is built and the filter panel drops above the list.

#### A Continuous Layout May Not Need a Breakpoint

The second block shows another route. In a card-shaped results list, column count can be
derived directly from width instead of through breakpoints. Under the rule, the maximum
number of cards that fit the container is computed, and the remaining space is
distributed among the cards.

In the output's second block, card widths are 360, 228, 224, 222, 220.8, and 220 pixels.
As width grows, card count grows, and card width approaches its minimum value. This is
the characteristic behavior of a continuous layout: it stays undefined at no width, and
it violates the criterion at no width.

The rule here depends on three numbers — minimum card width, gap, and container — and
the designer chooses only the first two. The breakpoint decision disappears. This is not
always possible: it works in layouts like a card grid, where **elements of the same kind
repeat**, but not in layouts like the filter panel and results list, where **elements of
different kinds stand side by side**, because the two have separate minimum widths and
neither can substitute for the other.

#### A Continuous Layout Does Not Require Infinite Testing

The common objection raised against continuous layout is testing cost: a layout defined
at every width appears to need testing at every width. There are 1121 integer widths
between 320 and 1440 pixels.

The third block shows this objection is wrong. Column count changes at only five widths:
464, 708, 952, 1196, and 1440 pixels. These values are exact by rule; the program
verifies each transition by computing column count one pixel below and exactly at it. At
widths between two transitions, the layout's structure stays the same, and only card
width changes continuously.

The set of widths that needs testing therefore has seven elements: five transition points
and two endpoint values. A continuous layout defines infinite states with a finite number
of transitions; testing is done at those transitions. In the adaptive approach, by
contrast, the number of states to test equals the number of designed layouts, and the
testing burden grows linearly as the number of layouts grows.

#### Which One, When

The two approaches are not competitors; the criterion is what changes when width changes.

If what changes is only the **arrangement** — the same elements, in a different order —
the responsive approach is enough, and it is cheaper. The card grid dropping from three
columns to two is like this; no element is added or removed.

If what changes is the **content**, the adaptive approach is needed. At a narrow
viewport, the filter panel does not drop above the list — it moves into a dropdown panel;
the borrow action is removed from the record row and left to the detail view. These
cannot be expressed with a ratio, because the element set is different.

In practice, the two are used together: two or three discrete states are defined, and
within each state, measures are computed continuously. The catalog interface takes this
form — a discrete transition at the 816-pixel threshold, with continuous behavior on both
sides of it.

#### Summary

- Responsive layout treats width as a continuous variable and designs rules; adaptive
  layout defines a finite number of states and designs each one directly.
- A breakpoint is derived from the content's minimum width requirements, not from device
  widths; the catalog interface's threshold computes to 816 pixels, and the common value
  of 768 is 48 pixels below this threshold.
- A derived breakpoint can be recomputed when content changes; a breakpoint taken from a
  list silently becomes wrong when content changes.
- In grids where elements of the same kind repeat, column count can be derived directly
  from width, and the breakpoint decision disappears.
- A continuous layout does not require infinite testing: structure changes only at the
  widths where column count changes, and in the measured example, 7 values are tested
  instead of 1121 widths.
- If only the arrangement changes when width changes, the responsive approach is enough;
  if the element set changes, the adaptive approach is needed, and the two are most often
  used together.

#### Next Step

This lesson treated width as the only variable. But a narrow viewport is not only
narrow: input type often changes with it too, touch takes the place of the pointer, and
target size gets redefined. The Density Decisions lesson found that a 33-pixel row
passed the lowest threshold but fell short of the enhanced one. The next lesson computes
these thresholds by criterion number, tests the icon buttons in the catalog toolbar
against target size and spacing exception criteria, and shows how pointer-dependent
interactions go unanswered under touch.

### Screen Size and Input Type

Source: https://academia.sh/en/courses/interface-fundamentals/screen-size-and-input-type

Input type as a variable independent of screen width; auditing target size criteria, the spacing exception, and hit area expansion by computation.

The previous lesson treated width as the only variable and computed how the layout
changes with width. But a narrow viewport is not only narrow. Input type often changes
with it too: the finger takes the place the pointer left, target size gets redefined,
and pointer-dependent interactions lose their counterpart.

The Density Decisions lesson found that the 33-pixel record row passed the lowest target
threshold but fell short of the enhanced one. This lesson computes those thresholds by
criterion number, tests the icon buttons in the catalog record row, and derives the cost
of the fix.

#### Input Type Is Independent of Screen Size

**Input modality** specifies the precision with which the user reaches the interface —
by touch, pointer, or keyboard. The common assumption is that a narrow screen means
touch and a wide screen means pointer. This assumption breaks in three places: wide
touch surfaces, pointer-driven sessions in a window shrunk to a narrow size, and devices
that carry both. Moreover, input type can change within a session; a user can work with a
finger for one minute and a pointer the next on the same screen.

Two design rules follow from this:

- **Sizing decisions are made for the coarsest input.** Every interactive area is a
  **touch target**, and its size is set to the value touch requires; a pointer-driven
  user is not harmed by a large target.
- **Capability decisions are made by feature detection, not by width.** Whether hover is
  supported cannot be inferred from screen width; it is tested with the relevant media
  feature.

#### Target Size Criteria

There are two criteria, and both are measured in CSS pixels. WCAG 2.5.8 requires a
target's smallest side to be **24 pixels**; 2.5.5 raises the same measure to **44
pixels**. The first has a spacing exception: even if a target is smaller than 24 pixels,
the criterion is considered satisfied as long as the 24-pixel-diameter circles drawn on
each target's bounding box do not intersect.

The program below generates the four icon buttons on the right side of the catalog
record row under three separate sizing decisions and audits both criteria.

```js
// 10-target.mjs — target size, spacing exception, and the cost of expanding the hit area

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

// The four icon buttons on the right side of the catalog record row.
// Icon 16px, padding on both sides, GAP of space between buttons.
function buttonRow(icon, padding, gap, count, startX) {
  const side = icon + 2 * padding;
  const buttons = [];
  for (let i = 0; i < count; i++) {
    buttons.push({
      name: `button-${i + 1}`,
      x: startX + i * (side + gap),
      y: 0,
      w: side,
      h: side,
    });
  }
  return buttons;
}

function center(d) {
  return { x: d.x + d.w / 2, y: d.y + d.h / 2 };
}
function centerDistance(a, b) {
  const ca = center(a);
  const cb = center(b);
  return Math.hypot(ca.x - cb.x, ca.y - cb.y);
}

function audit(name, buttons) {
  console.log(name);
  const smallestSide = Math.min(...buttons.map((d) => Math.min(d.w, d.h)));
  console.log(`  button size: ${buttons[0].w}x${buttons[0].h}px   button count: ${buttons.length}`);
  console.log(`  2.5.8 (>= ${MIN}px): ${smallestSide >= MIN ? "passes" : "fails"}`);
  console.log(`  2.5.5 (>= ${ENHANCED}px): ${smallestSide >= ENHANCED ? "passes" : "fails"}`);

  // Spacing exception: 24px-diameter circles drawn at each target's center must not
  // intersect, meaning the distance between two centers must be at least 24px.
  let closest = Infinity;
  for (let i = 0; i < buttons.length; i++) {
    for (let j = i + 1; j < buttons.length; j++) {
      closest = Math.min(closest, centerDistance(buttons[i], buttons[j]));
    }
  }
  console.log(
    `  closest two centers: ${closest}px   spacing exception: ${closest >= MIN ? "passes" : "fails"}`
  );
  console.log("");
}

audit("A) icon 16, padding 4, gap 4", buttonRow(16, 4, 4, 4, 0));
audit("B) icon 16, padding 2, gap 2", buttonRow(16, 2, 2, 4, 0));
audit("C) icon 16, padding 14, gap 4", buttonRow(16, 14, 4, 4, 0));

// Expanding the hit area to 44px without changing the visual size
const visual = 24;
const growth = (ENHANCED - visual) / 2;
console.log("expanding the hit area to 44px without changing the visual size");
console.log(`  to add on each side: ${growth}px`);
for (const gap of [4, 12, 20, 24]) {
  const remainingSpace = gap - 2 * growth;
  console.log(
    `  button gap ${String(gap).padStart(2)}px -> hit areas ` +
      `${remainingSpace >= 0 ? `${remainingSpace}px apart` : `${-remainingSpace}px overlap`}`
  );
}

// If the entire row is the target: the effect of a 44px row height on scan cost
console.log("");
const LIST_AREA = 640;
console.log("records per screen if the entire row is the target");
for (const row of [33, 44, 73]) {
  const records = Math.floor(LIST_AREA / row);
  console.log(
    `  row ${String(row).padStart(3)}px -> ${String(records).padStart(2)} records/screen   ` +
      `${Math.ceil(200 / records)} screens for 200 results`
  );
}
```

```
A) icon 16, padding 4, gap 4
  button size: 24x24px   button count: 4
  2.5.8 (>= 24px): passes
  2.5.5 (>= 44px): fails
  closest two centers: 28px   spacing exception: passes

B) icon 16, padding 2, gap 2
  button size: 20x20px   button count: 4
  2.5.8 (>= 24px): fails
  2.5.5 (>= 44px): fails
  closest two centers: 22px   spacing exception: fails

C) icon 16, padding 14, gap 4
  button size: 44x44px   button count: 4
  2.5.8 (>= 24px): passes
  2.5.5 (>= 44px): passes
  closest two centers: 48px   spacing exception: passes

expanding the hit area to 44px without changing the visual size
  to add on each side: 10px
  button gap  4px -> hit areas 16px overlap
  button gap 12px -> hit areas 8px overlap
  button gap 20px -> hit areas 0px apart
  button gap 24px -> hit areas 4px apart

records per screen if the entire row is the target
  row  33px -> 19 records/screen   11 screens for 200 results
  row  44px -> 14 records/screen   15 screens for 200 results
  row  73px ->  8 records/screen   25 screens for 200 results
```

All three sizing decisions carry the same 16-pixel icon; only the padding creates the
difference. Decision A stops at exactly 24 pixels with 4 pixels of padding and passes the
lowest threshold. Decision B drops to 20 pixels with 2 pixels of padding and fails both
criteria. Decision C rises to 44 pixels with 14 pixels of padding and passes both.

The only variable between them is a value that never appears on screen. Padding is the
transparent area around the icon; the user does not see it, and notices its existence
only when they touch it. Target size is therefore not a visual decision, it is an
**invisible** one, and it cannot be audited by eye.

#### What the Spacing Exception Saves

In decision B, button size dropped to 20 pixels, and the criterion failed. Could the
spacing exception have saved this case? The output says no: the closest two centers are
22 pixels apart, and 24 is required. The exception is not satisfied either.

The arithmetic is interesting. In decision B, the buttons are 20 pixels wide with 2
pixels between them; the distance between centers is 22 pixels. For the exception to
pass, either the button has to grow to at least 22 pixels or the gap between them has to
be at least 4 pixels. The second route is cheaper: at the same visual size, the criterion
is satisfied with just 2 more pixels of gap.

This shows what the spacing exception is for. The exception does not exist to legitimize
small targets; it exists to say that **the space between targets is part of the target
size.** Two small buttons standing side by side make each other risky; a small button
standing alone does not. The criterion rewards isolation.

#### The Hidden Cost of Expanding the Hit Area

A common solution is to expand the **hit area** without changing the visual size: the
button looks 24 pixels but is clickable across 44. The output's second block gives this
solution's hidden cost.

Going from 24 pixels to 44 pixels adds 10 pixels on every side. If the gap between
buttons is 4 pixels, two neighboring buttons' hit areas overlap by 16 pixels: which
button a touch in the overlapping region goes to is something the design cannot say. At
a 12-pixel gap, 8 pixels of overlap still remain. Separation is achieved only at a
20-pixel gap.

The conclusion is this: **the hit area cannot be expanded without also expanding the
gap.** The promise of gaining touch ease while keeping the visual size fixed goes
unanswered when neighboring targets exist. On a button standing alone — the "Borrow"
button in the record detail view, for instance — the promise holds, because there is no
neighbor to collide with.

#### If the Entire Row Is the Target

In the catalog results list, what is clickable is most often not the individual icons
but the entire record row. In that case, target size is directly the row height, and it
ties to the same number as the density decision.

The output's last block shows the link. The compact layout's 33-pixel row shows 19
records on screen, and 200 results are scanned in 11 screens. When the row is raised to
the enhanced threshold of 44 pixels, 14 records remain on screen and scanning rises to 15
screens — four more screens. In the balanced layout's 73-pixel row, the cost is 25
screens.

This confirms that the trade-off in the Density Decisions lesson depends on input type.
The same list can be scanned in 11 screens with a 33-pixel row in a pointer-driven
session; in a touch-driven session, holding the enhanced threshold raises it to 15
screens. The difference is 36 percent, and the design has to either accept it or resolve
it with a target other than the row — for instance, making only the record title the
target instead of the entire row, and raising the title's own height to the threshold.

#### Pointer-Dependent Interaction Has No Counterpart

Beyond size, there is also a difference in capability. A pointer reports a position, and
this position is known even without a click; touch does not produce such a position.
Anything that appears under **hover** either never appears in a touch-driven session, or
appears only after the target has been touched.

A constraint follows from this: **hover cannot be the only channel that carries
information.** If the icon buttons in a record row appear only on hover, a touch-driven
user cannot know they exist. In the same way, if an abbreviation's expansion or a shelf
code's meaning is given only in a tooltip, that information does not exist for one set of
users.

The legitimate use of hover is **reinforcement**: emphasizing the clickability of
something already visible, making a label already written more prominent. Whether the
capability exists is read not from screen width but from the relevant media feature; the
`hover` and `pointer` media features query this distinction directly, and a `pointer:
coarse` value reports a coarse-resolution input. Sizing decisions should not be tied to
this query: target size is set for the coarsest input under every condition, because
input type can change within a session.

#### Summary

- Input type cannot be derived from screen width; sizing decisions are made for the
  coarsest input, while capability decisions are made by querying a media feature.
- Target size is an invisible decision: the same 16-pixel icon produces a 20-, 24-, or
  44-pixel target depending on padding, and the difference is invisible on screen.
- The spacing exception does not legitimize small targets; it says that the space
  between targets is part of the target size, and it rewards an isolated target.
- Expanding the hit area without changing the visual size goes unanswered when
  neighboring targets exist: going from 24 to 44 pixels adds 10 pixels on every side,
  and at gaps narrower than 20 pixels, hit areas overlap.
- If the entire row is the target, the density decision and target size tie to the same
  number; a 33-pixel row is scanned in 11 screens, a 44-pixel row in 15.
- Hover cannot be the only channel that carries information; its legitimate use is
  reinforcing something already visible.

#### Next Step

The layout skeleton is complete with this lesson: columns, gutters, and grid margins are
derived from screen width, spacing comes from a closed scale, density is derived from the
task, and target size is set for the coarsest input. The text that will fill the
skeleton has not been chosen yet, though. Nearly all the weight the catalog interface
carries is text, and throughout this topic, text has always been treated as a rectangle
— a block made of characters 16 pixels tall and 8 pixels wide. The next topic opens up
this assumption and moves font selection from taste to criterion: how apparent size is
computed, which characters get confused with each other, how many hierarchy levels a
font family can carry.

## Typography and Color

Typeface selection, typographic scale, line length and spacing, color roles, semantic colors, contrast thresholds, and the dark theme transformation.

### Typeface Selection

Source: https://academia.sh/en/courses/interface-fundamentals/typeface-selection

The distinction between legibility and readability, what each typeface class promises, computing apparent size from x-height, and auditing a typeface candidate for alphabet coverage and character distinguishability.

The previous lesson completed the layout skeleton: columns, gutters, and grid margins
took their place according to screen width, the minimum size of touch targets was set,
and pointer and touch were shown to expect different things from the same interface. The
skeleton stands; the text that will fill it has not been chosen yet. Nearly all the
weight the catalog interface carries is text: record titles, author names, shelf codes,
ISBN numbers, dates, the label on the borrow button.

This lesson answers a single question: by what criteria, rather than taste, is a
typeface chosen? The criteria fall into four groups, and three of them are computable —
apparent size, alphabet coverage, and character distinguishability. The fourth, the
hierarchy load a family can carry, is a direct consequence of the two-channel rule from
the first lesson.

#### Legibility and Readability Are Separate Things

The two criteria are often called by a single word and confused with each other.

**Legibility** is the degree to which a single character can be distinguished from its
neighbors. Whether the letter `I` in a shelf code is distinguishable from the digit `1`
is a legibility question. It is measured at the character level and is a property of the
typeface itself.

**Readability** is the degree to which a block of text can be read fluently. Line length,
leading, alignment, and contrast ratio all factor into this criterion. It is measured at
the block level and is a property not of the typeface but of the design.

The practical consequence of the distinction is this: a legibility problem is solved by
changing the typeface, a readability problem by changing the layout. In the catalog
interface, a shelf code being misread falls into the first class; a search results list
that tires the eye falls into the second. Neither improves with the other's solution.

The catalog interface has two separate masses of text, and their criteria differ. The
results list and the metadata fields consist of short, scanned strings that resemble
each other; there, legibility is decisive. The summary paragraph in the record detail, by
contrast, is read continuously; there, readability is decisive.

#### Classes and What They Promise

Typefaces are grouped into classes by the formal features they carry. A class makes it
possible to talk about a typeface without naming a product, and more importantly, it
says what each class promises.

- **Serif.** Carries finishing strokes at the letter ends. These strokes produce a
  horizontal flow cue that helps in long continuous text. At small sizes the finishing
  strokes thin out and can disappear at low resolution.
- **Sans-serif.** Has no finishing strokes. It stays steadier at small sizes and in
  interface labels; because letterforms are simplified, the distinction between
  similar-looking characters can weaken.
- **Monospace.** Every character occupies the same advance width. Wherever column
  alignment is needed — shelf code, ISBN, date — it gives alignment for free. In
  continuous text it slows reading down because word shapes are distorted.
- **Variable font.** Carries a continuous weight axis in a single file; a separate file
  for each weight is not needed. What it provides is not a new form but a cheaper weight
  channel.

On the CSS side, these classes correspond to generic family keywords: `serif`,
`sans-serif`, `monospace`. These were introduced in the Visual Presentation with CSS
course as the last item in a family list; on the design side, they are a direct class
decision.

The catalog interface's decision follows from this distinction: a single sans-serif
family for body text and headings, a monospace family for the shelf code and ISBN
fields. Two families, because there are two different jobs; a third family cannot be
justified, because no third job can be named.

#### Nominal Size Is Not Apparent Size

Two typefaces set at the same size do not look the same size. Nominal size is the
measure of the em box; what the eye perceives as size is the body height of the
lowercase letters, that is, the **x-height** value. A font file declares this measure in
its own metadata in em units, so it can be computed.

```js
// xheight.mjs — computing apparent size from a font's declared metrics

// Every font file declares its metrics in em units (here em = 1000 units).
// Below are the metrics declared by three candidate families.
const candidates = [
  { name: "candidate-1", xHeight: 480, capHeight: 700, ascent: 750, descent: 250 },
  { name: "candidate-2", xHeight: 520, capHeight: 715, ascent: 800, descent: 230 },
  { name: "candidate-3", xHeight: 548, capHeight: 730, ascent: 776, descent: 275 },
];

const EM = 1000;
const BASE_PX = 16; // nominal size chosen for body text

console.log("candidate     x/em    x-height  cap height  x/cap height");
for (const c of candidates) {
  const xRatio = c.xHeight / EM;
  const xPx = xRatio * BASE_PX;
  const capHeightPx = (c.capHeight / EM) * BASE_PX;
  console.log(
    `${c.name.padEnd(13)} ${xRatio.toFixed(3)}   ${xPx.toFixed(2).padStart(9)} px ${capHeightPx.toFixed(2).padStart(8)} px  ${(c.xHeight / c.capHeight).toFixed(3)}`
  );
}

// Nominal size required to hit the same apparent size.
// The target is the x-height candidate-3 produces at 16 pixels.
const targetX = (548 / EM) * BASE_PX;
console.log(`\ntarget x-height: ${targetX.toFixed(2)} px (candidate-3, 16px)`);
console.log("candidate     equivalent nominal size   diff");
for (const c of candidates) {
  const required = targetX / (c.xHeight / EM);
  const diff = required - BASE_PX;
  console.log(
    `${c.name.padEnd(13)} ${required.toFixed(2).padStart(18)} px ${(diff >= 0 ? "+" : "") + diff.toFixed(2)} px`
  );
}

// Vertical measure carried by the line box: ascent + descent.
console.log("\ncandidate     ascent+descent (em)   at equivalent size (px)");
for (const c of candidates) {
  const total = (c.ascent + c.descent) / EM;
  const required = targetX / (c.xHeight / EM);
  console.log(
    `${c.name.padEnd(13)} ${total.toFixed(3).padStart(16)} ${(total * required).toFixed(2).padStart(21)}`
  );
}
```

```
candidate     x/em    x-height  cap height  x/cap height
candidate-1   0.480        7.68 px    11.20 px  0.686
candidate-2   0.520        8.32 px    11.44 px  0.727
candidate-3   0.548        8.77 px    11.68 px  0.751

target x-height: 8.77 px (candidate-3, 16px)
candidate     equivalent nominal size   diff
candidate-1                18.27 px +2.27 px
candidate-2                16.86 px +0.86 px
candidate-3                16.00 px +0.00 px

candidate     ascent+descent (em)   at equivalent size (px)
candidate-1              1.000                 18.27
candidate-2              1.030                 17.37
candidate-3              1.051                 16.82
```

The output's second block shows that "16 pixels" says nothing by itself. To hit the same
apparent size, candidate-1 has to go up to 18.27 pixels; the difference is 2.27 pixels,
that is, fourteen percent of the base size. This is why swapping a typeface requires the
whole size scale to be readjusted. The scale is the subject of the next lesson, and its
input is this computation.

The third block gives a second result. The sum of ascent and descent sets the base
height of the line box. Because candidate-3 operates at a smaller nominal size for the
same apparent size, its line box comes out shorter too: 16.82 pixels against 18.27. Of
two text blocks set at the same leading multiplier, one will sit tighter even though
both are at the same apparent size.

The x-height ratioed to cap height (the x/cap height column) gives a second signal. As
this ratio rises, lowercase letters approach the size of capitals; the leading bump of
words that start with a capital weakens. Where word shape speeds up scanning — record
titles in search results — this is the cost of too high a ratio.

#### Distinguishing Characters From Each Other

Legibility has two auditable faces: whether the needed characters exist in the
typeface, and whether the ones that exist are not confused with each other. Both are
tested against the text that actually appears in the interface, not against a sample
sentence.

```js
// coverage.mjs — auditing catalog text for typeface coverage and distinguishability

// Fields that actually appear in the catalog interface.
const records = [
  { title: "Işık ve Gölge: Bir Optik Tarihi", author: "Öztürk, Şükran", year: 1998, shelfCode: "QC353.I8", isbn: "9789750718533" },
  { title: "Çağdaş Türk Şiirinde İmge", author: "Ilgaz, İbrahim", year: 2004, shelfCode: "PL248.C1", isbn: "9789944882101" },
  { title: "Ağ Protokolleri El Kitabı", author: "Gündoğdu, Elif", year: 2011, shelfCode: "TK5105.5", isbn: "9786055829476" },
];

// 1) Coverage check: the code-point ranges a candidate font declares.
const ranges = {
  "latin-basic-only": [[0x0020, 0x007e]],
  "latin-1-supplement": [[0x0020, 0x007e], [0x00a0, 0x00ff]],
  "latin-extended-a": [[0x0020, 0x007e], [0x00a0, 0x00ff], [0x0100, 0x017f], [0x2010, 0x2027]],
};

function covers(ranges, code) {
  return ranges.some(([low, high]) => code >= low && code <= high);
}

const text = records.map((r) => `${r.title} ${r.author} ${r.year} ${r.shelfCode} ${r.isbn}`).join(" ");
const used = [...new Set([...text])].sort((a, b) => a.codePointAt(0) - b.codePointAt(0));

console.log(`distinct characters across catalog text: ${used.length}`);
for (const [name, r] of Object.entries(ranges)) {
  const missing = used.filter((c) => !covers(r, c.codePointAt(0)));
  const written = missing
    .map((c) => `${c}(U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`)
    .join(" ");
  console.log(`${name.padEnd(24)} missing ${String(missing.length).padStart(2)}: ${written || "-"}`);
}

// 2) Distinguishability: character sets that can be confused with each other.
const confusable = [
  ["I", "l", "1", "|"],
  ["0", "O", "o"],
  ["5", "S", "s"],
  ["8", "B"],
  ["I", "İ", "i", "ı"],
];

console.log("\nfield      confusable set members present together");
const fields = [
  ["shelfCode", records.map((r) => r.shelfCode)],
  ["isbn", records.map((r) => String(r.isbn))],
  ["author", records.map((r) => r.author)],
];
for (const [fieldName, values] of fields) {
  const letters = new Set(values.join("").split(""));
  const overlapping = confusable
    .map((set) => set.filter((c) => letters.has(c)))
    .filter((v) => v.length >= 2)
    .map((v) => v.join("/"));
  console.log(`${fieldName.padEnd(10)} ${overlapping.length ? overlapping.join("  ") : "-"}`);
}
```

```
distinct characters across catalog text: 54
latin-basic-only         missing  9: Ç(U+00C7) Ö(U+00D6) ö(U+00F6) ü(U+00FC) ğ(U+011F) İ(U+0130) ı(U+0131) Ş(U+015E) ş(U+015F)
latin-1-supplement       missing  5: ğ(U+011F) İ(U+0130) ı(U+0131) Ş(U+015E) ş(U+015F)
latin-extended-a         missing  0: -

field      confusable set members present together
shelfCode  I/1
isbn       -
author     I/l  I/İ/i
```

The first block shows why a coverage problem cannot be caught by eye. Latin-1 Supplement
covers most of Turkish's letters; the five code points still missing are exactly the
dotted and dotless `i` pair, `ğ`, and `ş` in the Latin Extended-A block. A typeface looks
flawless for as long as these five letters do not appear in the sample text. Characters
that fall back to a substitute family are drawn with a different x-height, and two
separate typefaces appear within the same word. Code points were defined in the How
Computers Work course; the audit here is a direct application of them.

The second block tells which field carries which confusion. In the shelf code field, `I`
and `1` occur together; this field is used by being read aloud or copied by hand in a
library, so the cost of confusion is concrete. The ISBN field carries only digits, so it
does not collide with any set in the list. The author field, however, has a confusion
specific to Turkish: capital `I`, lowercase `l`, and dotted `İ` occur in the same field.

This finding has two separate solutions, and they touch different places. Giving the
shelf code field a monospace family separates letters and digits into two distinct
drawing traditions — this is a typeface decision. The `I`/`İ` confusion in the author
field is not solved by the typeface, because both are genuinely distinct letters that
are needed; what is needed there is to check which form — serifed base or hooked — the
candidate draws its capital `I` with.

#### The Hierarchy Load a Family Can Carry

The Visual Hierarchy lesson established as a rule that critical distinctions are
expressed in at least two channels. One of the channels was weight. The number of
weights a typeface family offers directly limits how many places this rule can be
applied.

The catalog interface needs at least three weight levels: bold for the record title,
regular for body text, and regular again but at lower contrast for suppressed metadata.
A family that offers two weights covers this. A family that offers a single weight does
not; in that case the entire load of the hierarchy falls on the size and contrast
channels, producing the one-legged distinction case computed in the first lesson.

Figure style is a second constraint. A family's digits can be drawn as **proportional**
or **tabular** figures. In proportional figures, the `1` sits narrower than the others;
in publication years stacked one under another, the column drifts. If the catalog
results list aligns years in a column, tabular figures are required. A monospace family
already provides this; a sans-serif family needs it declared as a separate feature.

The third constraint is whether a genuine italic cut exists. If the family has no
italic cut, the browser produces an imitation by slanting the roman letters; this
imitation distorts letter ends and reduces legibility at small sizes. In the catalog
interface, italics are needed in only one place — the citation in the record detail —
and even that single place constrains the family choice.

When these three constraints come together, the selection criterion simplifies: does the
family actually offer the channels the interface needs? If it does not, the missing
channel's load transfers to other channels, and those channels are already full.

#### Summary

- Legibility is measured at the character level, readability at the block level; the
  first is solved by a typeface change, the second by a layout change.
- Typeface classes make it possible to decide without naming a product; each class makes
  one job cheaper and another more expensive.
- Nominal size does not give apparent size; apparent size is computed from the x-height
  ratio, and hitting the same measure requires a different nominal size in different
  families.
- Alphabet coverage is audited against the code points that actually occur in the
  interface; missing coverage is invisible in the sample text but shows itself through
  characters that fall back to a substitute family.
- Character confusion is audited field by field; which confusable set actually
  co-occurs in which field is a computable question.
- The number of weights a family offers, its digit width, and whether it has a genuine
  italic cut directly limit how many channels the hierarchy can be built on.

#### Next Step

This lesson fixed a single measure — the base size of body text — and showed that this
measure changes across families. But the interface does not have a single size: page
title, record title, author name, metadata, and button text sit at separate steps. If
these steps are each chosen individually, the crowd of numbers grows, and the
distinction thresholds measured in the first lesson are hit by chance or missed
entirely. The next lesson derives the steps from a base and a ratio, rounds the
resulting scale to the pixel grid, and computes whether neighboring steps actually
clear the distinction threshold.

### Typographic Scale

Source: https://academia.sh/en/courses/interface-fundamentals/typographic-scale

Deriving size steps from a base and a ratio, how rounding distorts the neighboring-step ratio, computing the step budget, and placing interface levels onto scale steps.

The previous lesson tied the base size of body text to a typeface decision and computed
that the same apparent size requires different nominal sizes across families. But the
interface does not have a single size. The catalog interface carries at least five text
levels: page title, record title, the borrow button's text, author name, and metadata.
What happens if these five numbers are each chosen individually?

What happens is this: the numbers are chosen independently, the ratios between them turn
out random, and the 1.15 size-ratio threshold set in the Visual Hierarchy lesson is hit
by chance in some pairs and missed in others. A scale is the structure that removes this
chance: sizes are not chosen one by one, they are **derived from a base and a ratio.**

#### The Scale Is an Exponential Sequence

A **typographic scale** is a sequence of size steps that starts from a base size and is
multiplied or divided by a fixed ratio. The size of step `n` is written as:

$$
b_n = T \cdot r^{\,n}
$$

Here $T$ is the base size, $r$ is the scale ratio, and $n$ is the step number. The base
step is taken as zero; positive steps grow, negative steps shrink. The guarantee the
scale gives is this: **the ratio of any two neighboring steps is constant and equal to
$r$.** Auditing the distinction comes down to looking at a single number.

This structure was introduced under the name modular scale in the Visual Presentation
with CSS course; there, the problem was one of writing a declaration. Here, the problem
is one of decision: which ratio, how many steps, and which level goes on which step.

#### Rounding Distorts the Fixed Ratio

The formula produces a continuous sequence; the screen, however, renders text more
stably at integer pixel values. The difference looks negligible but directly distorts
the neighboring-step ratio.

```js
// scale.mjs — ratio-based typographic scale generation and rounding's effect on distinction

const BASE = 16;          // body text size (px)
const STEPS = [-2, -1, 0, 1, 2, 3, 4];
const RATIOS = [1.125, 1.200, 1.250, 1.333];
const THRESHOLD = 1.15;   // size-ratio threshold set in the Visual Hierarchy lesson

function scale(ratio) {
  return STEPS.map((n) => {
    const raw = BASE * Math.pow(ratio, n);
    return { n, raw, rounded: Math.round(raw) };
  });
}

for (const ratio of RATIOS) {
  const s = scale(ratio);
  console.log(`\n--- ratio ${ratio.toFixed(3)} ---`);
  console.log("step   raw (px)  rounded  neighbor ratio (raw)  neighbor ratio (rnd)  threshold");
  for (let i = 0; i < s.length; i++) {
    const a = s[i];
    if (i === 0) {
      console.log(
        `${String(a.n).padStart(4)} ${a.raw.toFixed(3).padStart(9)} ${String(a.rounded).padStart(8)}` +
          `${"-".padStart(23)}${"-".padStart(23)}${"-".padStart(11)}`
      );
      continue;
    }
    const previous = s[i - 1];
    const rawRatio = a.raw / previous.raw;
    const roundedRatio = a.rounded / previous.rounded;
    console.log(
      `${String(a.n).padStart(4)} ${a.raw.toFixed(3).padStart(9)} ${String(a.rounded).padStart(8)}` +
        `${rawRatio.toFixed(4).padStart(23)}${roundedRatio.toFixed(4).padStart(23)}` +
        `${(roundedRatio >= THRESHOLD ? "passed" : "FAILED").padStart(11)}`
    );
  }
}
```

```
--- ratio 1.125 ---
step   raw (px)  rounded  neighbor ratio (raw)  neighbor ratio (rnd)  threshold
  -2    12.642       13                      -                      -          -
  -1    14.222       14                 1.1250                 1.0769     FAILED
   0    16.000       16                 1.1250                 1.1429     FAILED
   1    18.000       18                 1.1250                 1.1250     FAILED
   2    20.250       20                 1.1250                 1.1111     FAILED
   3    22.781       23                 1.1250                 1.1500     passed
   4    25.629       26                 1.1250                 1.1304     FAILED

--- ratio 1.200 ---
step   raw (px)  rounded  neighbor ratio (raw)  neighbor ratio (rnd)  threshold
  -2    11.111       11                      -                      -          -
  -1    13.333       13                 1.2000                 1.1818     passed
   0    16.000       16                 1.2000                 1.2308     passed
   1    19.200       19                 1.2000                 1.1875     passed
   2    23.040       23                 1.2000                 1.2105     passed
   3    27.648       28                 1.2000                 1.2174     passed
   4    33.178       33                 1.2000                 1.1786     passed

--- ratio 1.250 ---
step   raw (px)  rounded  neighbor ratio (raw)  neighbor ratio (rnd)  threshold
  -2    10.240       10                      -                      -          -
  -1    12.800       13                 1.2500                 1.3000     passed
   0    16.000       16                 1.2500                 1.2308     passed
   1    20.000       20                 1.2500                 1.2500     passed
   2    25.000       25                 1.2500                 1.2500     passed
   3    31.250       31                 1.2500                 1.2400     passed
   4    39.063       39                 1.2500                 1.2581     passed

--- ratio 1.333 ---
step   raw (px)  rounded  neighbor ratio (raw)  neighbor ratio (rnd)  threshold
  -2     9.005        9                      -                      -          -
  -1    12.003       12                 1.3330                 1.3333     passed
   0    16.000       16                 1.3330                 1.3333     passed
   1    21.328       21                 1.3330                 1.3125     passed
   2    28.430       28                 1.3330                 1.3333     passed
   3    37.897       38                 1.3330                 1.3571     passed
   4    50.517       51                 1.3330                 1.3421     passed
```

The first scale fails. Choosing a ratio of 1.125 means choosing a distinction below the
threshold (1.15); rounding neither fixes this nor makes it worse, it only makes it
irregular. The realized ratios oscillate between 1.0769 and 1.1500: one step pair grows
by 7.7 percent, another by 15 percent — exactly double. The scale's one guarantee — that
the neighboring ratio stays constant — disappears along with the rounding.

The growth of the oscillation at small sizes is not a coincidence. The rounding error is
at most half a pixel; this error's effect on the ratio is inversely proportional to
size. Half a pixel at 13 pixels is a 3.8 percent deviation; at 39 pixels it is a 1.3
percent deviation. The scale's low end is three times as fragile as its high end.

In the other three scales, every rounded ratio clears the threshold. As the ratio grows,
the oscillation's effect shrinks proportionally: in the 1.333-ratio scale, the realized
ratios fall between 1.3125 and 1.3571, meaning the widest ratio is 1.034 times the
narrowest.

The rule that follows can be written this way: **the scale ratio is chosen distinctly
larger than the distinction threshold.** A ratio equal or close to the threshold falls
below it in some step pairs, depending on which direction the rounding happens to work.

#### The Step Budget

Growing the ratio has a cost. The size range the interface can use is bounded:
readability sets the low end, the space it takes up on screen sets the high end. As the
ratio grows, fewer steps fit into this range.

```js
// steps.mjs — the scale's step budget and placing levels onto steps

const BASE = 16;
const SMALLEST = 12;   // smallest allowed size for non-body text
const LARGEST = 40;    // largest heading size the catalog interface needs

console.log("ratio   step count (12-40 px range)  first step  last step");
for (const ratio of [1.125, 1.2, 1.25, 1.333, 1.5]) {
  const lowStep = Math.ceil(Math.log(SMALLEST / BASE) / Math.log(ratio));
  const highStep = Math.floor(Math.log(LARGEST / BASE) / Math.log(ratio));
  const count = highStep - lowStep + 1;
  const first = Math.round(BASE * Math.pow(ratio, lowStep));
  const last = Math.round(BASE * Math.pow(ratio, highStep));
  console.log(
    `${ratio.toFixed(3)} ${String(count).padStart(28)} ${String(first).padStart(12)} ${String(last).padStart(11)}`
  );
}

// The catalog interface's levels, placed onto the steps of a 1.25-ratio scale.
const RATIO = 1.25;
const size = (n) => Math.round(BASE * Math.pow(RATIO, n));

const levels = [
  { name: "page title",   step: 3, weight: 700 },
  { name: "record title", step: 1, weight: 600 },
  { name: "button text",  step: 0, weight: 600 },
  { name: "author",       step: 0, weight: 400 },
  { name: "metadata",     step: -1, weight: 400 },
];

const THRESHOLD = { sizeRatio: 1.15, weightDiff: 200 };

console.log("\nlevel           step  size(px)  weight");
for (const l of levels) {
  console.log(
    `${l.name.padEnd(15)} ${String(l.step).padStart(4)} ${String(size(l.step)).padStart(9)} ${String(l.weight).padStart(7)}`
  );
}

console.log("\nneighboring level pair           size ratio  weight diff  passing channel");
for (let i = 0; i < levels.length - 1; i++) {
  const upper = levels[i];
  const lower = levels[i + 1];
  const sizeRatio = size(upper.step) / size(lower.step);
  const weightDiff = upper.weight - lower.weight;
  const passing = [];
  if (sizeRatio >= THRESHOLD.sizeRatio) passing.push("size");
  if (weightDiff >= THRESHOLD.weightDiff) passing.push("weight");
  const label = `${upper.name} > ${lower.name}`;
  console.log(
    `${label.padEnd(32)} ${sizeRatio.toFixed(3).padStart(10)} ${String(weightDiff).padStart(13)}  ${passing.length ? passing.join("+") : "WEAK"}`
  );
}
```

```
ratio   step count (12-40 px range)  first step  last step
1.125                           10           13          36
1.200                            7           13          40
1.250                            6           13          39
1.333                            5           12          38
1.500                            3           16          36

level           step  size(px)  weight
page title         3        31     700
record title       1        20     600
button text        0        16     600
author             0        16     400
metadata          -1        13     400

neighboring level pair           size ratio  weight diff  passing channel
page title > record title             1.550           100  size
record title > button text            1.250             0  size
button text > author                  1.000           200  weight
author > metadata                     1.231             0  size
```

The first table shows the real trade-off in choosing a scale ratio. The same 12–40
pixel range fits ten steps at a ratio of 1.125, three steps at 1.5. The previous
computation showed that a 1.125 ratio cannot clear the threshold; this table shows that
a 1.5 ratio leaves room for only three levels. The catalog interface needs five levels,
so a 1.5 ratio is eliminated outright. The remaining candidates are 1.2, 1.25, and
1.333; all clear the threshold and all fit five levels.

The decision was made in favor of a 1.25 ratio. The rationale is the slack in the second
table: six steps leave a one-step margin for five levels. A scale that is completely
full means that adding a level later requires recomputing the whole scale.

#### Not Every Level Needs a New Step

Two points stand out in the third table's level placement.

First, button text and author name sit at the same step: both step zero, 16 pixels. The
size ratio is 1.000; the size channel produces no distinction. The distinction is built
in weight, and the 200-unit difference clears the threshold. The two-channel rule from
the Visual Hierarchy lesson is met here by a third channel: button text sits on a filled
surface, author name on a plain background. Surface is a channel that does not appear in
this table but is genuinely at work, and it is the subject of the Color System lesson.

Second, the comparison in the third row is not actually a hierarchical pair. Button text and author name
do not compete in the same visual area; one is in the action zone, the other inside the record row. The
neighbor audit treats the levels as a sorted list, but hierarchy is meaningful only among elements seen at
the same time. Because the table produced no warning here, this distinction caused no problem, but whether
a row marked WEAK is a real problem is always checked with this same question.

The last row closes out a finding from the first lesson. There, the distinction between
author and metadata stood only in the contrast channel and was counted as one-legged.
The scale, by lowering metadata to 13 pixels instead of 14, raised the size ratio to
1.231; the distinction now stands in both size and contrast. The step decision solved,
through the scale's own structure, a problem that choosing sizes one by one could not.

#### The Scale's Written Form

If the scale remains a computation, it gets redone and drifts at every use. Its written
form is the conversion of steps into named values:

```css
:root {
  --size--1: 13px;
  --size-0: 16px;
  --size-1: 20px;
  --size-2: 25px;
  --size-3: 31px;
}
```

Names carrying the step number are more durable than names carrying meaning like
"heading" or "small." Meaning-bearing names start lying when the interface changes: a
value named "heading" one day gets used somewhere that is not a heading. A step number,
by contrast, only states its position in the scale, and that position does not change.

This list has no intermediate value, and it should not. When the feeling arises that a
level needs 18 pixels, the answer is not to add a new variable but to decide which step
the level belongs to. Every number that steps outside the scale cancels the scale's
guarantee at that point.

#### Summary

- A typographic scale derives sizes exponentially from a base and a ratio; the
  guarantee it provides is that the neighboring-step ratio stays constant.
- Rounding to integer pixels breaks this constancy; the deviation's effect on the ratio
  is larger at small sizes, so the scale's low end is its most fragile point.
- The scale ratio is chosen not equal to the distinction threshold but distinctly
  larger than it; a ratio close to the threshold drops below it in some step pairs
  through rounding.
- As the ratio grows, fewer steps fit into a given size range; choosing a ratio is a
  trade-off between distinguishing power and step budget.
- Not every level needs a new step; two levels at the same step can be separated in the
  weight or surface channel.
- The scale is written as named step values; adding an intermediate value cancels the
  scale's guarantee at that point.

#### Next Step

The scale said how large the text would be, not how wide. When the summary paragraph in
a record detail spreads across the full column, lines grow long, and the eye starts
missing its target as it returns from the end of one line to the start of the next. The
next lesson computes line length in characters, ties the measure to column width, and
shows why leading has to be adjusted together with line length.

### Line Length and Spacing

Source: https://academia.sh/en/courses/interface-fundamentals/line-length-and-spacing

Computing line length in characters from column width, tying leading to line length, the return-sweep-angle criterion, and the lower bound on paragraph spacing.

The scale gave the size of the text. The summary paragraph on the record detail screen
takes a number from the scale — 16 pixels, step zero — and spreads across the full
column the layout gives it. On a wide screen, that column opens up to 880 pixels. Is
this paragraph readable?

The question falls on the second side of the distinction defined in the previous
lesson. The typeface decision is made, the size decision is made; both were on the
legibility side. Readability, however, is measured at the block level and depends on
two quantities: how long a line is and how far apart the lines sit from each other.
These two quantities are not independent; when one changes, the other has to change
too.

#### Line Length Is a Character Measure, Not a Pixel Measure

**Line length** is the number of characters that fit on a line. The reason it is
measured in characters rather than pixels is that the distance the eye covers returning
from the end of a line to the start of the next relates to word count; the same pixel
width carries a different number of words at different type sizes.

Character count cannot be measured directly, because characters have different widths.
A typeface does, however, declare each character's **advance width**; it can be
computed by averaging over real text.

```js
// linelength.mjs — computing line length in characters from column width

// Advance widths the candidate family declares (em = 1000 units).
const G = {
  " ": 260, a: 556, b: 574, c: 500, d: 574, e: 545, f: 340, g: 574,
  h: 560, i: 244, j: 244, k: 520, l: 244, m: 856, n: 560,
  o: 570, p: 574, q: 574, r: 366, s: 480, t: 358, u: 560,
  v: 508, w: 800, x: 500, y: 508, z: 470, A: 660, B: 660, C: 686,
  D: 720, E: 610, F: 610, G: 740, H: 730, I: 280, J: 280, K: 660,
  L: 610, M: 890, N: 730, O: 760, P: 610, R: 660, S: 620,
  T: 620, U: 720, V: 660, W: 890, X: 660, Y: 620, Z: 600,
  ",": 260, ".": 260, ":": 260, "-": 340, "1": 560, "9": 560,
  "0": 560, "8": 560,
};
const DEFAULT = 560;

const summary =
  "This volume traces the development of optics together with the changing " +
  "instruments of observation. The author first frames light and shadow as a " +
  "problem of measurement, then shows how theories of vision follow from these " +
  "measurements. The bibliography is extensive, the index detailed, and the " +
  "plates are taken from original prints.";

const total = [...summary].reduce((s, c) => s + (G[c] ?? DEFAULT), 0);
const average = total / [...summary].length;

const SIZE = 16; // px
const averagePx = (average / 1000) * SIZE;

console.log(`summary text character count: ${[...summary].length}`);
console.log(`average advance width: ${average.toFixed(1)} / 1000 em`);
console.log(`average character width at 16 px: ${averagePx.toFixed(3)} px\n`);

const TARGET = [45, 75];
console.log("column (px)  line length (characters)  target 45-75");
for (const column of [320, 480, 560, 640, 720, 880, 1040]) {
  const characters = column / averagePx;
  const status = characters < TARGET[0] ? "SHORT" : characters > TARGET[1] ? "LONG" : "fits";
  console.log(`${String(column).padStart(11)} ${characters.toFixed(1).padStart(26)}  ${status}`);
}

console.log("\ntarget character count -> required column width");
for (const k of [45, 66, 75]) {
  console.log(`${String(k).padStart(3)} characters -> ${(k * averagePx).toFixed(1)} px`);
}

// If size changes at a fixed column width, line length changes too.
console.log("\ncolumn fixed at 640 px  ->  size  line length (characters)");
for (const b of [13, 16, 20, 25]) {
  const width = (average / 1000) * b;
  console.log(`${String(b).padStart(24)} px ${(640 / width).toFixed(1).padStart(24)}`);
}
```

```
summary text character count: 333
average advance width: 455.9 / 1000 em
average character width at 16 px: 7.295 px

column (px)  line length (characters)  target 45-75
        320                       43.9  SHORT
        480                       65.8  fits
        560                       76.8  LONG
        640                       87.7  LONG
        720                       98.7  LONG
        880                      120.6  LONG
       1040                      142.6  LONG

target character count -> required column width
 45 characters -> 328.3 px
 66 characters -> 481.4 px
 75 characters -> 547.1 px

column fixed at 640 px  ->  size  line length (characters)
                      13 px                    108.0
                      16 px                     87.7
                      20 px                     70.2
                      25 px                     56.2
```

The 45–75 character range is the criterion this course adopts and quantifies so it can
be audited; the lower bound keeps lines from breaking too often, the upper bound keeps
the return sweep from becoming difficult.

The output's second block is the real finding. An 880-pixel column fits 120.6
characters, roughly 1.6 times the upper bound. When the summary paragraph spreads across
the full column, it misses the readability criterion not at a single point but across
the entire span of a wide screen. Every column width from 560 pixels up falls outside
the criterion.

The third block gives the measure of the solution. Whatever the column's pixel width,
the summary text's width should be at most 547 pixels. This is a constraint independent
of the layout's column width: the text block carries its own maximum width, and if the
column is wider than that, the text does not fill the column.

The fourth block exposes a trap. Holding the column fixed and changing the size still
changes line length: in a 640-pixel column, 108.0 characters at 13 pixels, 56.2 at 25
pixels. Although line length looks like a layout decision, it is a direct consequence
of the step chosen from the scale. A heading's line length and body text's line length
differ even in the same column, and the upper bound is audited separately for each
level.

The constraint's written form is a maximum width given to the text block, not the
column:

```css
.summary {
  font-size: 16px;
  max-width: 547px;
}
```

This value also has a unit that can be written in characters: the `ch` unit uses the
advance width of the digit zero in the current typeface. Because zero's width is not
equal to the average character width, a number given in `ch` does not equal the
computed character count; this lesson computes the measure through the average and
writes the result in pixels.

#### Leading Depends on Line Length

**Leading** is the distance between the baselines of two consecutive lines. In the
Visual Presentation with CSS course, this measure was given through the `line-height`
property and was called **line height** there; written unitless, it works as a
multiplier of the font size. On the design side, the question asked is what that
multiplier should be.

The multiplier has two separate lower bounds, and both are computable.

The first lower bound comes from the typeface. The sum of ascent and descent computed
in the previous lesson is the vertical measure the letters actually occupy. When the
multiplier drops below this value, the descenders of one line overlap the ascenders of
the next.

The second lower bound comes from eye movement. At the end of a line, the eye makes a
**return sweep** to the start of the next line. This movement's horizontal component is
line length, its vertical component is leading. Their ratio gives the angle at which
the sweep happens; as the angle shrinks, it becomes harder for the target line to stand
apart from its neighbors, and the eye reads the same line twice or skips a line.

```js
// leading.mjs — line box, inter-line space, return-sweep angle, and paragraph spacing

const SIZE = 16;             // px
const ASCENT_DESCENT = 1.051; // candidate-3's declared ascent+descent (em)
const ANGLE_THRESHOLD = 2.0;  // smallest return-sweep angle this course adopts (degrees)

const degrees = (rad) => (rad * 180) / Math.PI;

console.log("multiplier  line box   inter-line space   paragraph spacing >=");
for (const m of [1.2, 1.3, 1.4, 1.5, 1.6, 1.75]) {
  const box = m * SIZE;
  const space = box - ASCENT_DESCENT * SIZE;
  console.log(
    `${m.toFixed(2).padStart(10)} ${box.toFixed(2).padStart(14)} px ${space.toFixed(2).padStart(17)} px ${(space * 2).toFixed(2).padStart(17)} px`
  );
}

console.log("\nline length (px) -> return-sweep angle (degrees), by multiplier");
const multipliers = [1.2, 1.4, 1.5, 1.6, 1.75];
console.log("length   " + multipliers.map((m) => m.toFixed(2).padStart(7)).join(""));
for (const length of [328, 481, 547, 640, 880]) {
  const row = multipliers
    .map((m) => degrees(Math.atan((m * SIZE) / length)).toFixed(2).padStart(7))
    .join("");
  console.log(`${String(length).padStart(7)}  ${row}`);
}

console.log("\nline length -> smallest multiplier meeting the threshold (2.0 degrees)");
for (const length of [328, 481, 547, 640, 880]) {
  const fromAngle = (length * Math.tan((ANGLE_THRESHOLD * Math.PI) / 180)) / SIZE;
  // The multiplier cannot go below the ascent+descent value, or lines start to overlap.
  const lower = Math.max(fromAngle, ASCENT_DESCENT);
  const chosen = Math.ceil(lower * 20) / 20; // round to 0.05 steps
  console.log(
    `${String(length).padStart(4)} px -> from angle ${fromAngle.toFixed(3)}  lower bound ${lower.toFixed(3)}  chosen ${chosen.toFixed(2)}`
  );
}
```

```
multiplier  line box   inter-line space   paragraph spacing >=
      1.20          19.20 px              2.38 px              4.77 px
      1.30          20.80 px              3.98 px              7.97 px
      1.40          22.40 px              5.58 px             11.17 px
      1.50          24.00 px              7.18 px             14.37 px
      1.60          25.60 px              8.78 px             17.57 px
      1.75          28.00 px             11.18 px             22.37 px

line length (px) -> return-sweep angle (degrees), by multiplier
length      1.20   1.40   1.50   1.60   1.75
    328     3.35   3.91   4.18   4.46   4.88
    481     2.29   2.67   2.86   3.05   3.33
    547     2.01   2.34   2.51   2.68   2.93
    640     1.72   2.00   2.15   2.29   2.51
    880     1.25   1.46   1.56   1.67   1.82

line length -> smallest multiplier meeting the threshold (2.0 degrees)
 328 px -> from angle 0.716  lower bound 1.051  chosen 1.10
 481 px -> from angle 1.050  lower bound 1.051  chosen 1.10
 547 px -> from angle 1.194  lower bound 1.194  chosen 1.20
 640 px -> from angle 1.397  lower bound 1.397  chosen 1.40
 880 px -> from angle 1.921  lower bound 1.921  chosen 1.95
```

The two-degree threshold is likewise a criterion this course adopts; what matters is not
the number itself but that the decision is tied to a number.

The last block shows how the two lower bounds trade places. At 328 and 481 pixels, the
angle criterion is met by multipliers of 0.716 and 1.050; both sit below the 1.051 bound
the typeface imposes, so the typeface is decisive. From 547 pixels on, the angle
criterion takes over, demanding a multiplier of 1.921 at 880 pixels.

This confirms the previous section's finding by a second route. An 880-pixel line looks
"rescuable" by raising the leading multiplier to 1.95. But at 16-pixel text, a 1.95
multiplier means a 31.2-pixel line box; the gap between lines approaches the letters'
own height, and the block stops being a set of lines and turns into a series of separate
lines. The real fix for a long line is not to increase the leading, it is to shorten the
line.

The second table carries a warning in the opposite direction. At the short, 328-pixel
line, a 1.75 multiplier produces a return-sweep angle of 4.88 degrees. A large angle is
not itself a problem; but the inter-line space climbing to 11.18 pixels causes the lines
in a short-line block to come apart from each other. A short line wants little space, a
long line wants a lot of space; these are the two ends of a single rule.

#### Paragraph Spacing Derives From Inter-Line Space

The last column of the first table gives a side effect of leading. Where one paragraph
ends and the next begins is legible only if the space between paragraphs is distinctly
larger than the inter-line space. Taken as double for a criterion, text set at a 1.5
multiplier needs a paragraph spacing of at least 14.37 pixels.

This is the typographic application of the relative-spacing principle established in
the Proximity and Grouping lesson: the ratio between within-group spacing and
between-group spacing determines the grouping. Here the group is the paragraph, and the
within-group spacing is the inter-line space.

The same rationale explains why paragraph indentation and paragraph spacing are not
used together. Both do the same job — announcing the paragraph boundary — and using
both at once marks the boundary twice. The two-channel rule does not apply here,
because when the paragraph boundary is lost, the cost the reader pays is not a loss of
hierarchy but merely a pause.

#### Alignment and Breaking

Once line length is fixed, what remains is how line endings get broken.

In left-aligned text, the right edge is ragged and word spacing stays fixed. In
justified text, the right edge straightens, but word spacing stretches line by line. In
narrow columns this stretching grows and produces visibly empty channels within the
line. Because the catalog interface's summary paragraph can drop close to 45
characters, it is set left-aligned.

Centered text is unsuited to continuous reading, because every line starts at a
different point and the return sweep's target changes line by line. Centering can be
used for single-line or at most two-line text — an empty-state message, button text —
and these are treated in the Component States topic.

Word breaking reduces stretching in justified typesetting; in left-aligned
typesetting, it straightens the right edge but breaks word integrity. In the catalog
interface, author names and work titles are proper names and must not be broken;
breaking is therefore enabled only in the summary paragraph and stays off in the title
and metadata fields.

#### Summary

- Line length is measured in characters; even if the pixel width stays the same, the
  character count changes when the type size changes.
- Character count is computed by averaging the typeface's declared advance widths over
  real text, and it is turned into a maximum-width constraint.
- The long-line problem is solved by a width limit given to the text block, not the
  column; the limit is computed separately for each text level.
- The leading multiplier has two lower bounds: the typeface's ascent-plus-descent sum
  and the return-sweep-angle criterion. The first is decisive on short lines, the
  second on long lines.
- Increasing the spacing does not rescue a long line; as the required multiplier grows,
  the block stops being a set of lines.
- Paragraph spacing must be distinctly larger than inter-line space; the paragraph
  boundary is announced by indentation or spacing, not both.

#### Next Step

These three lessons fixed the form of the text: which family, which size, which width,
and which spacing. The color of text and ground, however, is still nothing more than
the dark gray and white from the first lesson. The interface's need for color is not a
decorative need: separating the primary action from the secondary action, announcing
that a record is selected, and showing shelf-availability status all require separate
surfaces. The next lesson defines colors not one by one but as roles, sets the roles
onto a scale along the lightness axis, and determines by computation which ground each
role will be matched with.

### Color System

Source: https://academia.sh/en/courses/interface-fundamentals/color-system

Defining color as role, generating a ramp from a single hue along the lightness axis, binding roles to steps, and auditing matches through relative luminance.

The previous three lessons fixed the form of the text. On the color side, only two
values remain from the first lesson: dark gray text and a white ground. The catalog
interface cannot run on this. That the borrow button is the primary action, that a
selected record is selected, that a record is not on the shelf — all of these need
separate surfaces and separate text colors.

The real risk here is not choosing colors, it is choosing colors one by one. If a color
is added every time a need arises, the interface soon carries dozens of values close to
each other, and a list forms that nobody knows where to use which of. Just as the scale
solved this problem in typography, the color system solves it the same way: values are
not chosen one by one, they are **derived**; and where they are used is named not by
value but by **role**.

#### Color Carries a Role, Not a Value

`#275ea5` is a value. "The primary action's ground" is a role. The difference is that
the second one stays true even when the interface changes.

In a system named by value — "blue," "light blue," "dark blue" — three problems arise. First, the same
value gets used in unrelated places, and it cannot later be separated out when only one of them needs to
change. Second, when the hue changes, every name starts lying; a constant named "blue" gets set to green.
Third, in a dark theme the values invert, and a name like "light blue" points to a dark color. Dark theme
is the subject of this topic's seventh lesson, and that is where role naming pays back its cost.

Role naming is built from three parts: the color of what (surface, text, border,
action), at which level (primary, secondary), and if needed, on top of what (an `-on`
suffix). The role of text that sits on a given role is named separately, because that
text is audited together with its ground.

#### A Ramp Is Derived From a Single Hue

The steps that make up a family are not chosen one by one. Hue and saturation are held
constant, and only lightness changes. This approach was introduced in the Visual
Presentation with CSS course; here, the **relative luminance** value of every step in
the generated ramp is also computed, because that is the criterion for matching roles to
each other.

```js
// ramp.mjs — a color ramp along the lightness axis from a single hue, and the ramp's relative luminance

// HSL -> RGB (the reverse direction of the CSS color notation lesson's conversion)
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)].map((v) => Math.round(v * 255));
}
const hex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");

// WCAG relative luminance and contrast ratio
function channel(v) {
  const s = v / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

const STEP = [
  { name: "000", l: 100 }, { name: "050", l: 97 }, { name: "100", l: 92 },
  { name: "200", l: 84 }, { name: "300", l: 74 }, { name: "400", l: 62 },
  { name: "500", l: 50 }, { name: "600", l: 40 }, { name: "700", l: 31 },
  { name: "800", l: 22 }, { name: "900", l: 14 },
];

const FAMILY = {
  neutral:   { hue: 214, saturation: 8 },
  primary:   { hue: 214, saturation: 62 },
  secondary: { hue: 268, saturation: 46 },
};

const ramp = {};
for (const [name, { hue, saturation }] of Object.entries(FAMILY)) {
  ramp[name] = {};
  for (const s of STEP) {
    ramp[name][s.name] = hslRgb(hue, saturation, s.l);
  }
}

const WHITE = [255, 255, 255];
for (const name of Object.keys(FAMILY)) {
  console.log(`\n--- ${name} (hue ${FAMILY[name].hue}, saturation ${FAMILY[name].saturation}%) ---`);
  console.log("step     lightness  hex        relative luminance  against white");
  for (const s of STEP) {
    const rgb = ramp[name][s.name];
    console.log(
      `${s.name.padStart(7)} ${String(s.l).padStart(7)}%  ${hex(rgb)}  ` +
        `${luminance(rgb).toFixed(4).padStart(15)}  ${contrast(rgb, WHITE).toFixed(2).padStart(11)}:1`
    );
  }
}
```

```
--- neutral (hue 214, saturation 8%) ---
step     lightness  hex        relative luminance  against white
    000     100%  #ffffff           1.0000         1.00:1
    050      97%  #f7f7f8           0.9307         1.07:1
    100      92%  #e9eaec           0.8223         1.20:1
    200      84%  #d3d6d9           0.6695         1.46:1
    300      74%  #b7bcc2           0.4993         1.91:1
    400      62%  #969da6           0.3335         2.74:1
    500      50%  #757e8a           0.2054         4.11:1
    600      40%  #5e656e           0.1281         5.89:1
    700      31%  #494e55           0.0752         8.39:1
    800      22%  #34383d           0.0390        11.80:1
    900      14%  #212327           0.0167        15.74:1

--- primary (hue 214, saturation 62%) ---
step     lightness  hex        relative luminance  against white
    000     100%  #ffffff           1.0000         1.00:1
    050      97%  #f3f7fc           0.9260         1.08:1
    100      92%  #dee9f7           0.8052         1.23:1
    200      84%  #bdd3ef           0.6364         1.53:1
    300      74%  #94b7e6           0.4588         2.06:1
    400      62%  #6296da           0.2947         3.05:1
    500      50%  #3075cf           0.1786         4.59:1
    600      40%  #275ea5           0.1115         6.50:1
    700      31%  #1e4980           0.0660         9.05:1
    800      22%  #15335b           0.0328        12.68:1
    900      14%  #0e213a           0.0149        16.19:1

--- secondary (hue 268, saturation 46%) ---
step     lightness  hex        relative luminance  against white
    000     100%  #ffffff           1.0000         1.00:1
    050      97%  #f7f4fb           0.9144         1.09:1
    100      92%  #eae1f4           0.7787         1.27:1
    200      84%  #d5c3e9           0.5906         1.64:1
    300      74%  #bb9edb           0.4013         2.33:1
    400      62%  #9b72cb           0.2331         3.71:1
    500      50%  #7c45ba           0.1209         6.15:1
    600      40%  #633795           0.0755         8.36:1
    700      31%  #4d2b73           0.0454        11.00:1
    800      22%  #361e52           0.0232        14.34:1
    900      14%  #231334           0.0107        17.30:1
```

The three families carry the same step names and were generated from the same lightness
values. Despite this, the relative luminances of the colors at the same step are not
equal. At step 500, neutral carries a luminance of 0.2054, primary 0.1786, secondary
0.1209. The difference is not small: the secondary family's step 500 is about forty
percent darker than the neutral family's same step.

The reason is that the lightness axis in HSL notation is not perceptual. Relative
luminance does not weight the three channels equally; it gives green a coefficient of
0.7152, red 0.2126, blue 0.0722. A blue-weighted hue produces a darker luminance at the
same lightness value.

The practical consequence of this is that **equal step numbers do not mean equal
contrast.** Binding a role to a step is not enough; whether the match actually works has
to be computed separately.

#### Roles Are Bound to Steps and Matches Are Audited

A role table enumerates the jobs the interface needs and gives each one a step. Then it
is listed which role falls on which ground, and every match is audited.

```js
// roles.mjs — binding color roles to ramp steps and auditing the matches

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)].map((v) => Math.round(v * 255));
}
const hex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
function channel(v) {
  const s = v / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

const LIGHTNESS = { "000": 100, "050": 97, 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22, 900: 14 };
const FAMILY = { neutral: [214, 8], primary: [214, 62], secondary: [268, 46] };
const color = (family, step) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[step]);

// Roles: each role names a job, not a color.
const ROLE = {
  "surface":            ["neutral", "000"],
  "surface-secondary":  ["neutral", "050"],
  "border":             ["neutral", "300"],
  "text-primary":       ["neutral", "900"],
  "text-secondary":     ["neutral", "600"],
  "action-primary":     ["primary", "600"],
  "action-primary-on":  ["neutral", "000"],
  "accent-pale":        ["primary", "100"],
  "accent-pale-on":     ["primary", "800"],
  "action-secondary":   ["secondary", "600"],
};

console.log("role                 family    step  hex");
for (const [name, [family, step]] of Object.entries(ROLE)) {
  console.log(`${name.padEnd(20)} ${family.padEnd(9)} ${step.padStart(4)}  ${hex(color(family, step))}`);
}

// Matches: which role falls on which ground, and which threshold applies.
const MATCH = [
  ["text-primary", "surface", 4.5],
  ["text-secondary", "surface", 4.5],
  ["text-secondary", "surface-secondary", 4.5],
  ["border", "surface", 3.0],
  ["action-primary-on", "action-primary", 4.5],
  ["action-primary", "surface", 3.0],
  ["accent-pale-on", "accent-pale", 4.5],
  ["action-secondary", "surface", 3.0],
];

console.log("\nforeground           ground             contrast  threshold  result");
for (const [fg, ground, threshold] of MATCH) {
  const k = contrast(color(...ROLE[fg]), color(...ROLE[ground]));
  console.log(
    `${fg.padEnd(20)} ${ground.padEnd(18)} ${k.toFixed(2).padStart(7)}:1 ${threshold.toFixed(1).padStart(9)}  ${k >= threshold ? "passed" : "FAILED"}`
  );
}

// If two action colors are separated only by hue, they blend together in grayscale.
const a = color(...ROLE["action-primary"]);
const b = color(...ROLE["action-secondary"]);
console.log(
  `\ncontrast between action-primary (${hex(a)}) and action-secondary (${hex(b)}): ${contrast(a, b).toFixed(3)}:1`
);
console.log(`relative luminances: ${luminance(a).toFixed(4)} and ${luminance(b).toFixed(4)}`);

// How many steps are needed to build the same distinction on the lightness axis?
console.log("\naction-secondary step  contrast (against action-primary)");
for (const step of ["200", "300", "400", "500", "600", "700", "800"]) {
  const k = contrast(a, color("secondary", step));
  console.log(`${step.padStart(22)}  ${k.toFixed(3).padStart(7)}:1  ${k >= 3.0 ? "separates" : "-"}`);
}

// If the border role does not clear the threshold: which neutral step first provides 3.0?
console.log("\nneutral step  contrast against surface  3.0 threshold");
for (const step of ["200", "300", "400", "500", "600"]) {
  const k = contrast(color("neutral", step), color(...ROLE["surface"]));
  console.log(`${step.padStart(12)}  ${k.toFixed(2).padStart(23)}:1  ${k >= 3.0 ? "passed" : "FAILED"}`);
}
```

```
role                 family    step  hex
surface              neutral    000  #ffffff
surface-secondary    neutral    050  #f7f7f8
border               neutral    300  #b7bcc2
text-primary         neutral    900  #212327
text-secondary       neutral    600  #5e656e
action-primary       primary    600  #275ea5
action-primary-on    neutral    000  #ffffff
accent-pale          primary    100  #dee9f7
accent-pale-on       primary    800  #15335b
action-secondary     secondary  600  #633795

foreground           ground             contrast  threshold  result
text-primary         surface              15.74:1       4.5  passed
text-secondary       surface               5.89:1       4.5  passed
text-secondary       surface-secondary     5.51:1       4.5  passed
border               surface               1.91:1       3.0  FAILED
action-primary-on    action-primary        6.50:1       4.5  passed
action-primary       surface               6.50:1       3.0  passed
accent-pale-on       accent-pale          10.33:1       4.5  passed
action-secondary     surface               8.36:1       3.0  passed

contrast between action-primary (#275ea5) and action-secondary (#633795): 1.287:1
relative luminances: 0.1115 and 0.0755

action-secondary step  contrast (against action-primary)
                   200    3.966:1  separates
                   300    2.794:1  -
                   400    1.753:1  -
                   500    1.058:1  -
                   600    1.287:1  -
                   700    1.693:1  -
                   800    2.206:1  -

neutral step  contrast against surface  3.0 threshold
         200                     1.46:1  FAILED
         300                     1.91:1  FAILED
         400                     2.74:1  FAILED
         500                     4.11:1  passed
         600                     5.89:1  passed
```

The audit produced two findings. The rationale for the thresholds and which criterion
they come from is the subject of the sixth lesson; what matters here is that the audit
is done while the system is being built.

#### First Finding: The Border Role Does Not Clear the Threshold

The border role was bound to neutral step 300 and produces a 1.91:1 contrast ratio
against the surface. Since the threshold is 3.0, this border does not reliably show the
line the interface uses to announce a component boundary.

The last table gives the measure of the fix: the first step in the neutral family to
clear the threshold is 500, at a ratio of 4.11:1. Step 400 still falls short at 2.74.
The border role is moved to neutral 500.

This fix has a side effect that needs to be noticed: to the eye, neutral 500 is a
distinctly gray line, far from the subtle distinction step 300 gave. If the border's
only job is to show a component boundary, and that boundary is already built by a
surface difference or by spacing, the right decision is not to darken the border but to
remove it. The rule from the Proximity and Grouping lesson returns here: a distinction
built with a line is often built more cheaply with space. When an audit fails, there are
two options — move the role to the threshold, or make the role unnecessary.

#### Second Finding: A Second Hue Is Not a Hierarchy Channel

The primary action color and the secondary action color sit at the same step, and the
contrast ratio between them is 1.287:1. This means the two colors sit at almost the
same point on the relative luminance axis. For a reader with limited color distinction
or a grayscale output, these two buttons are the same button.

The third table shows why the fix is not on the hue axis. Separating the secondary
family from the primary action by a contrast of 3.0 requires dropping to step 200, that
is, a light purple; but that step cannot carry white text, because it itself sits at
only a 1.64:1 contrast against the surface. None of the steps in between produce the
distinction: at step 500 the ratio is 1.058, meaning the two colors are practically
identical in luminance.

The rule that follows is the color side's counterpart to the two-channel rule from the
first lesson: **the primary and secondary actions are not separated by hue
difference.** The distinction is built in the surface channel — the primary action
carries a filled surface, the secondary action is drawn only as a border or plain text.
A hue difference can be added on top of this distinction; it cannot carry the
distinction alone.

A second hue does have a place in the system, but that place is not action hierarchy:
marking categories that are independent of each other, distinguishing one type of
information from another, or forming a ground in accent areas. In the catalog
interface, the secondary family is used in labels that show collection type; the
distinction there is not hierarchical but categorical, and the text on the label
already carries the distinction.

#### The System's Written Form

The ramp and the roles are written as two separate layers. The first layer is the raw
steps, the second layer is the roles, and roles only reference steps:

```css
:root {
  /* steps */
  --neutral-000: #ffffff;
  --neutral-050: #f7f7f8;
  --neutral-500: #757e8a;
  --neutral-600: #5e656e;
  --neutral-900: #212327;
  --primary-100: #dee9f7;
  --primary-600: #275ea5;
  --primary-800: #15335b;

  /* roles */
  --surface: var(--neutral-000);
  --surface-secondary: var(--neutral-050);
  --border: var(--neutral-500);
  --text-primary: var(--neutral-900);
  --text-secondary: var(--neutral-600);
  --action-primary: var(--primary-600);
  --action-primary-on: var(--neutral-000);
}
```

Keeping the two layers separate lets components use only role names. The moment a
component writes `--neutral-600`, the system is punctured: that component is no longer
affected by a hue change, a theme change, or a role reassignment. A component that
sticks to role names, by contrast, inherits all of these changes for free.

#### Summary

- Color decisions are named by role, not value; a role is a name that stays true when
  the interface changes, and it is the structure that makes theme switching possible.
- A color family is derived from a single hue along the lightness axis; step names are
  shared across families.
- The same step number does not give the same contrast; because relative luminance does
  not weight its channels equally, the same lightness produces different luminance
  across hues.
- Role matches are audited while the system is built; when an audit fails, the role is
  either moved to the threshold or questioned for whether it is needed at all.
- The primary and secondary actions are not separated by hue difference; two hues at
  the same step carry nearly identical luminance and collapse into a single color in
  grayscale.
- The system is written as two layers: raw steps and role names. Components reference
  only role names.

#### Next Step

This lesson tied color to a visual task: surface, text, border, action. There is a
second area where the interface uses color, and there color carries not a visual task
but a direct meaning. That the borrow transaction completed successfully, that a
record is not on the shelf, that a due date has passed — the interface reports this
information with color. The next lesson takes up which colors these meanings get bound
to, why binding alone is not enough, and how every semantic color has to carry its own
ground-and-text pair.

### Semantic Colors

Source: https://academia.sh/en/courses/interface-fundamentals/functional-colors

Binding success, warning, error, and info meanings to color families, why the same step produces different contrast across families, auditing the notification triad against its own ground, and why color cannot be a single channel.

The previous lesson tied color to a visual task: surface, text, border, action. These
roles determine how the interface looks, not what it says. In the catalog interface,
color does a second job. When a borrow transaction completes, a notification appears;
if a record is not on the shelf, a warning; when an invalid value is entered in the
ISBN field, an error; a line of information saying a due date is approaching.

These four states are four separate things the interface tells the user, and they are
marked with color. The question to ask is this: how are these colors chosen, how are
they audited once chosen, and can color do this job alone?

#### Four Meanings, Four Families

**Functional colors** report status, not appearance. Four base meanings are
distinguished:

- **Success.** The desired outcome occurred. In the catalog interface, completion of a
  borrow transaction.
- **Warning.** The operation can continue, but a condition needs attention. A record
  being available at another branch instead of on the shelf.
- **Error.** The operation cannot continue; a correction is expected from the user. An
  invalid ISBN entry.
- **Info.** A neutral notification about status; no action is needed. Reporting a due
  date.

The match between these four meanings and colors is not a natural relationship, it is a
**learned convention.** The convention is not universal either; the same color reads
differently across cultures. The consequence that follows from this directly shapes the
system's design: color can be used as a **cue**, it cannot be used alone as a
**carrier**. The last two sections of this lesson account for this.

Each meaning gets not a color but a family. The reason is that a meaning does not
appear in a single form in the interface: the same error state appears in a
notification box's pale ground, in a form field's border, and in a line of text. All
three are different steps of the same family.

#### The Same Step Does a Different Job Across Families

The finding from the previous lesson becomes decisive here: the same lightness value
produces different relative luminance across different hues. For the four functional
families, this difference directly changes which step can carry text.

```js
// functional.mjs — auditing that semantic color families do not do the same job at the same step

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)].map((v) => Math.round(v * 255));
}
const hex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
function channel(v) {
  const s = v / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

const LIGHTNESS = { 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22 };
const FAMILY = {
  success: [145, 54],
  warning: [42, 92],
  error:   [8, 68],
  info:    [214, 62],
};
const WHITE = [255, 255, 255];
const color = (family, step) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[step]);

console.log("family  " + Object.keys(LIGHTNESS).map((b) => b.padStart(8)).join(""));
for (const family of Object.keys(FAMILY)) {
  const row = Object.keys(LIGHTNESS)
    .map((b) => contrast(color(family, b), WHITE).toFixed(2).padStart(8))
    .join("");
  console.log(`${family.padEnd(7)} ${row}`);
}

console.log("\nfamily  text step (>=4.5:1)      fill step (>=3.0:1)");
for (const family of Object.keys(FAMILY)) {
  const steps = Object.keys(LIGHTNESS);
  const text = steps.find((b) => contrast(color(family, b), WHITE) >= 4.5);
  const fill = steps.find((b) => contrast(color(family, b), WHITE) >= 3.0);
  console.log(
    `${family.padEnd(7)} ${(text + " (" + hex(color(family, text)) + ")").padStart(24)}` +
      ` ${(fill + " (" + hex(color(family, fill)) + ")").padStart(24)}`
  );
}
```

```
family       100     200     300     400     500     600     700     800
success     1.13    1.30    1.52    1.86    2.25    3.44    5.31    8.67
warning     1.11    1.24    1.41    1.65    1.92    2.96    4.68    7.78
error       1.26    1.59    2.17    3.20    4.54    6.51    9.07   12.72
info        1.23    1.53    2.06    3.05    4.59    6.50    9.05   12.68

family  text step (>=4.5:1)      fill step (>=3.0:1)
success            700 (#247a48)            600 (#2f9d5d)
warning            700 (#986c06)            700 (#986c06)
error              500 (#d64029)            400 (#e06e5c)
info               500 (#3075cf)            400 (#6296da)
```

The table measures the most common setup mistake in semantic colors. If a rule is set
as "let step 500 be the text color in every family," the threshold is met in the error
and info families but not in success and warning: success produces a contrast of 2.25
at step 500, warning 1.92. The difference is more than double.

Yellow-leaning hues are the extreme case. In the warning family, the 3.0 threshold
required for a fill is not met at any middle step; the first step to clear it is 700 —
the same as the text step. In this family, creating a step gap between fill and text
requires dropping to the family's darker end.

The reason is the channel coefficients in the relative luminance formula. The green
channel's coefficient is 0.7152, red's is 0.2126, blue's is 0.0722. Because a yellow
hue keeps both the red and green channels high, its luminance rises fast; a
blue-weighted hue, by contrast, stays much darker at the same lightness value. **A step
number makes no promise about contrast; every family finds its own threshold through
its own computation.**

#### The Notification Triad Is Audited Against Its Own Ground

A notification box carries three colors: a pale ground, a border, and the text that
falls on the ground. The text steps found in the previous section were computed
against white; the notification box's ground, however, is not white, it is the
family's step 100.

```js
// notification.mjs — the functional-color notification triad and separability without the hue channel

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)].map((v) => Math.round(v * 255));
}
const hex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
function channel(v) {
  const s = v / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

const LIGHTNESS = { 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22, 900: 14 };
const FAMILY = { success: [145, 54], warning: [42, 92], error: [8, 68], info: [214, 62] };
const color = (family, b) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[b]);
const SURFACE = [255, 255, 255];

// Notification triad: pale ground (100), border (?), and text (family-specific step)
const TEXT_STEP = { success: 700, warning: 700, error: 500, info: 500 };

console.log("family  ground     text       text/ground  thr 4.5  border    border/surface  thr 3.0");
for (const family of Object.keys(FAMILY)) {
  const ground = color(family, 100);
  const text = color(family, TEXT_STEP[family]);
  const border = color(family, 200);
  const textContrast = contrast(text, ground);
  const borderContrast = contrast(border, SURFACE);
  console.log(
    `${family.padEnd(7)} ${hex(ground)}  ${hex(text)}  ${textContrast.toFixed(2).padStart(9)}:1  ` +
      `${(textContrast >= 4.5 ? "passed" : "FAILED").padStart(7)}  ${hex(border)}  ${borderContrast.toFixed(2).padStart(12)}:1  ${borderContrast >= 3.0 ? "passed" : "FAILED"}`
  );
}

// With the hue channel set aside, can the four meanings still be told apart?
console.log("\npair                  contrast  without hue channel");
const names = Object.keys(FAMILY);
for (let i = 0; i < names.length; i++) {
  for (let j = i + 1; j < names.length; j++) {
    const a = color(names[i], TEXT_STEP[names[i]]);
    const b = color(names[j], TEXT_STEP[names[j]]);
    const k = contrast(a, b);
    console.log(
      `${(names[i] + " - " + names[j]).padEnd(21)} ${k.toFixed(3).padStart(7)}:1  ${k >= 1.5 ? "separates" : "DOES NOT SEPARATE"}`
    );
  }
}

// The info role and the primary action color were built from the same hue; do they blend together?
const actionPrimary = hslRgb(214, 62, 40);
const infoText = color("info", 500);
console.log(
  `\ncontrast between action-primary (${hex(actionPrimary)}) and info text (${hex(infoText)}): ${contrast(actionPrimary, infoText).toFixed(3)}:1`
);

// Fix: text and border steps are chosen against their real grounds.
console.log("\nfamily  text step (against pale ground >=4.5)  border step (against surface >=3.0)");
for (const family of Object.keys(FAMILY)) {
  const ground = color(family, 100);
  const steps = Object.keys(LIGHTNESS).map(Number);
  const t = steps.find((b) => contrast(color(family, b), ground) >= 4.5);
  const bd = steps.find((b) => contrast(color(family, b), SURFACE) >= 3.0);
  console.log(
    `${family.padEnd(7)} ${(t + " (" + hex(color(family, t)) + ", " + contrast(color(family, t), ground).toFixed(2) + ":1)").padStart(42)}` +
      ` ${(bd + " (" + hex(color(family, bd)) + ", " + contrast(color(family, bd), SURFACE).toFixed(2) + ":1)").padStart(37)}`
  );
}
```

```
family  ground     text       text/ground  thr 4.5  border    border/surface  thr 3.0
success #e0f6e9  #247a48       4.69:1   passed  #c0ecd3          1.30:1  FAILED
warning #fdf2d8  #986c06       4.21:1   FAILED  #fce5b1          1.24:1  FAILED
error   #f8e0dd  #d64029       3.61:1   FAILED  #f2c2ba          1.59:1  FAILED
info    #dee9f7  #3075cf       3.74:1   FAILED  #bdd3ef          1.53:1  FAILED

pair                  contrast  without hue channel
success - warning       1.134:1  DOES NOT SEPARATE
success - error         1.170:1  DOES NOT SEPARATE
success - info          1.157:1  DOES NOT SEPARATE
warning - error         1.032:1  DOES NOT SEPARATE
warning - info          1.020:1  DOES NOT SEPARATE
error - info            1.012:1  DOES NOT SEPARATE

contrast between action-primary (#275ea5) and info text (#3075cf): 1.415:1

family  text step (against pale ground >=4.5)  border step (against surface >=3.0)
success                      700 (#247a48, 4.69:1)                 600 (#2f9d5d, 3.44:1)
warning                      800 (#6c4d04, 6.99:1)                 700 (#986c06, 4.68:1)
error                        600 (#ab3321, 5.17:1)                 400 (#e06e5c, 3.20:1)
info                         600 (#275ea5, 5.29:1)                 400 (#6296da, 3.05:1)
```

The first table misses the text threshold in three of the four notifications. The
cause is not a computation error, it is computing against the wrong ground: the steps
were chosen against white, but the text falls not on white but on the family's pale
ground. Because the pale ground is slightly darker than white, the ratio between text
and ground narrows.

The border column gives a sharper result. All four families' step 200 produces a
contrast between 1.24 and 1.59 against the surface; none comes close to the 3.0
threshold. A pale border does not do the job of announcing the notification box's
boundary.

The last table gives the fix. When text steps are reselected against their own
grounds, warning moves to 800, error and info to 600; success stays at 700 because it
already passed. Border steps, meanwhile, spread between 400 and 700 across families —
giving a single step number for all four families is, again, not possible.

Written as a rule: **every contrast computation is done against the ground the color
will actually fall on.** A value computed against white is not valid on a ground that
is not white.

#### Color Cannot Be a Single Channel

The second table turns the convention warning made at the start of this lesson into a
number. The pairwise contrast ratios of the four functional text colors fall between
1.012 and 1.170. All are very close to 1, meaning the four colors sit at almost the
same point on the relative luminance axis.

This means that when the hue channel is disabled, the four meanings collapse into a
single color. The hue channel being disabled is not an exceptional case — a reader with
limited color distinction, a grayscale output, a low-saturation screen in sunlight, and
high-contrast mode are all instances of it.

The same result is written into the WCAG 1.4.1 criterion: color cannot be the only
visual means of conveying information. The criterion is not a design recommendation, it
is a conformance requirement.

In the catalog interface, this means every functional state carries at least one
channel in addition to color:

- **Text.** The notification states the status in words. The difference between
  "Borrow completed" and "This record is not on the shelf" is not read from color, it
  is read from the text.
- **Icon.** Each state is assigned a separate shape, and the shape is distinguishable
  independent of color. Under what conditions an icon carries meaning is the question
  of the Icon Usage lesson in the Component States topic.
- **Position.** A field-level error message sits directly below the field; the space
  between it and its field is smaller than the space between it and other fields.

Having at least one of these three channels present alongside color both meets the
criterion and continues the two-channel rule set in the first lesson.

#### The Info Role Collides With the Action Color

The output's third block catches a collision that was overlooked while the system was
being built. The info family and the primary family established in the previous lesson
were generated from the same hue. The contrast between the info text and the primary
action color is 1.415:1; the two colors are too close to be told apart.

The fix table sharpens the situation further: when info text is moved to its correct
step, its value becomes `#275ea5` — the exact same as the primary action color. A
clickable action and an unclickable info line appear in the same color in the
interface.

This is the cost of two roles sharing the same hue in the color system. There are three
solutions, and the choice is made according to the interface's priorities.

The first is to move the info family to a different hue; but the convention for the
info meaning has settled on bluish hues, and leaving that hue weakens the meaning.

The second is to move the primary action color instead. If the action color carries no
convention — and in the catalog interface it does not — it is the one that can be
moved.

The third is to move the distinction to a channel other than color. The primary action
carries white text on a filled surface; the info line is colored text on a plain
ground. The surface channel already separates the two, and the hue collision never
turns into a visible problem.

In the catalog interface, the third path is chosen, because the distinction is already
in place and it is consistent with the previous lesson's finding: action hierarchy is
built on surface difference, not hue difference. This choice, however, comes with a
condition, and the condition has to be written down: the info color cannot be used as a
filled surface anywhere. The moment it is, the collision becomes visible.

#### Summary

- Functional colors report status, not appearance; success, warning, error, and info
  are the four base meanings, and each gets not a single value but a family.
- The relationship between meaning and color is a learned convention; this is why
  color can be a cue but cannot be the sole carrier.
- A step number makes no promise about contrast; yellow-leaning hues produce much
  higher luminance at the same lightness value and meet the threshold only at the dark
  end.
- Every contrast computation is done against the ground the color will actually fall
  on; a text color computed against white can miss the threshold on a pale ground.
- The four semantic colors sit very close to each other on the relative luminance
  axis; when the hue channel is disabled, all of them collapse into the same color,
  which is why at least one of the text, icon, or position channels accompanies color.
- Two roles generated from the same hue produce a collision; the collision is resolved
  either by moving a hue or by handing the distinction to the surface channel, and if
  the second option is chosen, the condition is written down.

#### Next Step

Throughout the last two lessons, the numbers 4.5 and 3.0 appeared in every
computation, but where they come from was not said. These thresholds are not numbers
the course chose arbitrarily; they are criteria with a defined measurement method,
scope, and exceptions. The next lesson takes up which criterion these thresholds come
from, why large text is subject to a separate threshold, which rule non-text elements
fall under, and how a whole interface can be audited against these thresholds in bulk.

### Contrast and Accessibility

Source: https://academia.sh/en/courses/interface-fundamentals/contrast-and-accessibility

The definition and limits of the contrast ratio formula, working backward from threshold to color, the discontinuity of the large-text boundary, and a bulk audit of the whole catalog interface.

In the last two lessons, the numbers 4.5 and 3.0 appeared in every computation. These
numbers are not criteria the course chose arbitrarily; they are thresholds with a
defined measurement method, defined scope, and defined exemptions. This lesson takes up
where the thresholds come from, at what point they constrain design, and how the whole
catalog interface is audited against them.

The two words in the lesson's name point to two separate things. **Contrast** is the perceptual principle
introduced in the Contrast and Emphasis lesson: the difference that separates an element from its
surroundings. **Contrast ratio** is the measured quantity of that difference. Contrast is a design tool
and can be built in size, weight, shape, and position too; contrast ratio only gives the relationship
between two colors' relative luminances.

#### The Formula and Its Limits

**Contrast ratio** is computed from two colors' relative luminances:

$$
K = \frac{L_1 + 0.05}{L_2 + 0.05}
$$

Here $L_1$ is the relative luminance of the lighter color, $L_2$ that of the darker
one. The 0.05 constant in the denominator represents ambient reflection off the screen
surface; without this constant, the ratio would go to infinity because black's
luminance is zero.

```js
// threshold.mjs — the contrast ratio formula's limits, working backward from threshold to color, and the large-text boundary

function channel(v) {
  const s = v / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}
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)].map((v) => Math.round(v * 255));
}
const hex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");

const BLACK = [0, 0, 0];
const WHITE = [255, 255, 255];
console.log(`luminance(black) = ${luminance(BLACK).toFixed(4)}   luminance(white) = ${luminance(WHITE).toFixed(4)}`);
console.log(`contrast(black, white) = ${contrast(BLACK, WHITE).toFixed(2)}:1   (formula's upper bound)`);
console.log(`contrast(white, white) = ${contrast(WHITE, WHITE).toFixed(2)}:1   (lower bound)`);

// Working backward: the highest relative luminance meeting a threshold on a white ground
console.log("\nthreshold  highest allowed relative luminance  equivalent gray (0-255)");
function fromGray(l) {
  let low = 0, high = 255;
  for (let i = 0; i < 40; i++) {
    const mid = (low + high) / 2;
    if (luminance([mid, mid, mid]) < l) low = mid; else high = mid;
  }
  return (low + high) / 2;
}
for (const threshold of [3.0, 4.5, 7.0]) {
  const highest = 1.05 / threshold - 0.05;
  console.log(`${threshold.toFixed(1).padStart(9)}  ${highest.toFixed(4).padStart(31)}  ${fromGray(highest).toFixed(1).padStart(24)}`);
}

// The formula sees only luminance: colors at different hues that give the same ratio
console.log("\nhue  lightness  hex        relative luminance  against white");
const TARGET = 1.05 / 4.5 - 0.05;
for (const hue of [8, 42, 145, 214, 268]) {
  let low = 0, high = 100;
  for (let i = 0; i < 40; i++) {
    const mid = (low + high) / 2;
    if (luminance(hslRgb(hue, 60, mid)) < TARGET) low = mid; else high = mid;
  }
  const l = (low + high) / 2;
  const rgb = hslRgb(hue, 60, l);
  console.log(
    `${String(hue).padStart(3)} ${l.toFixed(2).padStart(8)}%  ${hex(rgb)}  ${luminance(rgb).toFixed(4).padStart(15)}  ${contrast(rgb, WHITE).toFixed(2).padStart(11)}:1`
  );
}

// WCAG "large text" boundary: 18pt, or bold 14pt
const PT_PX = 96 / 72;
console.log(`\n18pt = ${(18 * PT_PX).toFixed(2)} px, 14pt = ${(14 * PT_PX).toFixed(2)} px`);
const isLarge = (px, weight) => px >= 18 * PT_PX || (weight >= 700 && px >= 14 * PT_PX);
console.log("px  weight  large text  applicable threshold");
for (const [px, w] of [[16, 400], [18, 700], [19, 700], [20, 400], [23, 400], [24, 400], [31, 700]]) {
  const b = isLarge(px, w);
  console.log(`${String(px).padStart(2)} ${String(w).padStart(6)}  ${(b ? "yes" : "no").padStart(11)}  ${(b ? "3.0" : "4.5").padStart(20)}`);
}
```

```
luminance(black) = 0.0000   luminance(white) = 1.0000
contrast(black, white) = 21.00:1   (formula's upper bound)
contrast(white, white) = 1.00:1   (lower bound)

threshold  highest allowed relative luminance  equivalent gray (0-255)
      3.0                           0.3000                     148.9
      4.5                           0.1833                     118.7
      7.0                           0.1000                      89.0

hue  lightness  hex        relative luminance  against white
  8    51.12%  #cd4c38           0.1843         4.48:1
 42    35.91%  #927225           0.1828         4.51:1
145    33.21%  #22884c           0.1847         4.47:1
214    50.49%  #3577cd           0.1836         4.50:1
268    59.44%  #935ad6           0.1837         4.49:1

18pt = 24.00 px, 14pt = 18.67 px
px  weight  large text  applicable threshold
16    400           no                   4.5
18    700           no                   4.5
19    700          yes                   3.0
20    400           no                   4.5
23    400           no                   4.5
24    400          yes                   3.0
31    700          yes                   3.0
```

The first block gives the formula's range: lowest 1:1, highest 21:1. This range is a
ratio measure, not a percentage; the gap between 10:1 and 20:1 is the same factor as
the gap between 2:1 and 4:1, but it does not look the same size to the eye. The ratio
itself is not a linear measure of perception.

The second block gives the reverse computation, and it is used directly on the design
side. A text color that meets the 4.5 threshold on a white ground can have a relative
luminance of at most 0.1833; its gray equivalent is roughly 118.7 out of 255. This is
the numeric answer to the question "how light can the text color be," and it serves to
generate a color directly instead of testing one.

The third block shows the formula's most important limit. Five different hues all
produce roughly 4.5 contrast against white; but the HSL lightness values that get them
there spread between 33.21 percent and 59.44 percent. **The formula does not see hue,
it only sees relative luminance.** This has two consequences: hitting the same
threshold at different hues requires different lightness, and two colors clearing the
threshold does not show that they are distinguishable from each other — this is the
general form of the previous lesson's finding.

The fourth block shows another limit, a discontinuity. The criterion splits text into
two buckets: large and not large. The boundary is 18 points, or 14 points bold; their
pixel equivalents are 24 and 18.67. The consequence of this is that 18-pixel bold text
is subject to the 4.5 threshold while 19-pixel bold text is subject to the 3.0
threshold. A one-pixel size change lowers the required contrast by a third. The
criterion is not a continuous function, it is a stepped rule.

#### Where Each Threshold Comes From

The thresholds are defined in the WCAG criteria. Four criteria directly bind this
course's decisions:

- **1.4.1 Use of Color.** Color cannot be the only visual means of conveying information, indicating an
  action, prompting a response, or distinguishing a visual element. The text, icon, and position channels
  from the previous lesson are the counterpart of this criterion. This criterion has no threshold; it is a
  structural requirement.
- **1.4.3 Contrast (Minimum).** At least 4.5:1 for text and images of text; 3:1 for
  large text.
- **1.4.6 Contrast (Enhanced).** 7:1 for the same distinction, 4.5:1 for large text.
  This is the higher level; the catalog interface's body text already meets it at
  15.74:1 on neutral step 900, but secondary text does not meet it.
- **1.4.11 Non-text Contrast.** At least 3:1 between the visual information needed to
  identify a component or its state and the adjacent colors. Border, focus ring, and
  icon strokes fall under this criterion.

There are three exemptions, and all are decision points. Inactive components — that is, disabled controls
— are out of scope for 1.4.3. Text that is purely decorative and carries no information is also out of
scope. Text that is part of a logo or brand name is not subject to the criterion either. An exemption is
not a permit: if text cannot be read, the interface is failing to give that information even if the
criterion exempts it.

#### Auditing the Whole Interface

Looking at matches one by one is not enough, because a missed match goes unnoticed. The
audit is done by listing every foreground–ground pair that actually occurs in the
interface.

```js
// audit.mjs — bulk audit of the whole catalog interface against the thresholds

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)].map((v) => Math.round(v * 255));
}
function channel(v) {
  const s = v / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

const LIGHTNESS = { "000": 100, "050": 97, 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22, 900: 14 };
const FAMILY = { neutral: [214, 8], primary: [214, 62], error: [8, 68] };
const color = (family, b) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[b]);

const ROLE = {
  "surface":            ["neutral", "000"],
  "surface-secondary":  ["neutral", "050"],
  "border":             ["neutral", "500"],
  "text-primary":       ["neutral", "900"],
  "text-secondary":     ["neutral", "600"],
  "text-disabled":      ["neutral", "400"],
  "action-primary":     ["primary", "600"],
  "action-primary-on":  ["neutral", "000"],
  "focus-ring":         ["primary", "500"],
  "selected-ground":    ["primary", "100"],
  "error-text":         ["error", "600"],
};

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

// type: "text" | "non-text"
const AUDIT = [
  ["page title",             "text-primary",      "surface",           "text", 31, 700],
  ["record title",           "text-primary",      "surface",           "text", 20, 600],
  ["author name",            "text-secondary",    "surface",           "text", 16, 400],
  ["metadata (shelf code)",  "text-secondary",    "surface-secondary", "text", 13, 400],
  ["borrow button",          "action-primary-on", "action-primary",    "text", 16, 600],
  ["search placeholder",     "text-disabled",     "surface",           "text", 16, 400],
  ["disabled button",        "text-disabled",     "surface-secondary", "text", 16, 600],
  ["error message",          "error-text",        "surface",           "text", 13, 400],
  ["field border",           "border",            "surface",           "non-text", 0, 0],
  ["focus ring",             "focus-ring",        "surface",           "non-text", 0, 0],
  ["selected row ground",    "selected-ground",   "surface",           "non-text", 0, 0],
  ["author in selected row", "text-secondary",    "selected-ground",   "text", 16, 400],
];

console.log("item                    foreground        ground              type      threshold  measured  result");
let remaining = 0;
for (const [item, fg, ground, type, px, weight] of AUDIT) {
  const threshold = type === "non-text" ? 3.0 : isLarge(px, weight) ? 3.0 : 4.5;
  const k = contrast(color(...ROLE[fg]), color(...ROLE[ground]));
  const passed = k >= threshold;
  if (!passed) remaining++;
  console.log(
    `${item.padEnd(23)} ${fg.padEnd(17)} ${ground.padEnd(19)} ${type.padEnd(9)} ${threshold.toFixed(1).padStart(9)} ${k.toFixed(2).padStart(9)}  ${passed ? "passed" : "FAILED"}`
  );
}
console.log(`\ntotal ${AUDIT.length} matches, ${remaining} do not clear the threshold`);

// For the remaining ones: the first step in the same family that clears the threshold
console.log("\nremaining match          current step  lightest step required");
for (const [item, fg, ground, type, px, weight] of AUDIT) {
  const threshold = type === "non-text" ? 3.0 : isLarge(px, weight) ? 3.0 : 4.5;
  if (contrast(color(...ROLE[fg]), color(...ROLE[ground])) >= threshold) continue;
  const [family] = ROLE[fg];
  const groundRgb = color(...ROLE[ground]);
  const fit = Object.keys(LIGHTNESS).find((b) => contrast(color(family, b), groundRgb) >= threshold);
  console.log(
    `${item.padEnd(25)} ${(ROLE[fg][0] + "-" + ROLE[fg][1]).padEnd(16)} ${fit ? family + "-" + fit + " (" + contrast(color(family, fit), groundRgb).toFixed(2) + ":1)" : "not in family"}`
  );
}

// If the selected row's ground is darkened, what happens to the text on it?
console.log("\nselected ground  ground/surface  text on top (neutral-600)  text threshold 4.5");
for (const step of ["100", "200", "300", "400"]) {
  const ground = color("primary", step);
  const groundContrast = contrast(ground, color(...ROLE["surface"]));
  const textContrast = contrast(color("neutral", "600"), ground);
  console.log(
    `${("primary-" + step).padEnd(16)} ${groundContrast.toFixed(2).padStart(9)}:1 ${textContrast.toFixed(2).padStart(28)}:1  ${textContrast >= 4.5 ? "passed" : "FAILED"}`
  );
}
```

```
item                    foreground        ground              type      threshold  measured  result
page title              text-primary      surface             text            3.0     15.74  passed
record title            text-primary      surface             text            4.5     15.74  passed
author name             text-secondary    surface             text            4.5      5.89  passed
metadata (shelf code)   text-secondary    surface-secondary   text            4.5      5.51  passed
borrow button           action-primary-on action-primary      text            4.5      6.50  passed
search placeholder      text-disabled     surface             text            4.5      2.74  FAILED
disabled button         text-disabled     surface-secondary   text            4.5      2.56  FAILED
error message           error-text        surface             text            4.5      6.51  passed
field border            border            surface             non-text        3.0      4.11  passed
focus ring              focus-ring        surface             non-text        3.0      4.59  passed
selected row ground     selected-ground   surface             non-text        3.0      1.23  FAILED
author in selected row  text-secondary    selected-ground     text            4.5      4.80  passed

total 12 matches, 3 do not clear the threshold

remaining match          current step  lightest step required
search placeholder        neutral-400      neutral-600 (5.89:1)
disabled button           neutral-400      neutral-600 (5.51:1)
selected row ground       primary-100      primary-400 (3.05:1)

selected ground  ground/surface  text on top (neutral-600)  text threshold 4.5
primary-100           1.23:1                         4.80:1  passed
primary-200           1.53:1                         3.85:1  FAILED
primary-300           2.06:1                         2.86:1  FAILED
primary-400           3.05:1                         1.94:1  FAILED
```

Three of the twelve matches remain, and the fix for each of the three is different. The
audit's value is not finding failures, it is showing what **kind** of failure each one
is.

#### Three Failures, Three Different Decisions

**The search field's placeholder text** misses the threshold at a 2.74 contrast ratio.
The table gives the fix: neutral step 600 produces 5.89. But this fix creates a new
problem — at that step, the placeholder text becomes indistinguishable from real input
text, and the user assumes the field is filled. The right decision is not to darken the
color, it is to stop using the placeholder as a label. The field's name stays above the
field as a persistent label; the placeholder is either removed or shows only a format
example. The Label-Field Relationship lesson in the Web Fundamentals and HTML course
reached the same conclusion from the accessibility side; here the same conclusion is
reached from the contrast computation.

**The disabled button** carries a 2.56 contrast ratio, and criterion 1.4.3 exempts
inactive components. So this row is not a conformance violation. It is, however, still
a decision point: if the text on a disabled button cannot be read, the user cannot know
what the button does or why it cannot be used. In the catalog interface, the "Borrow"
button stays disabled while a record is not on the shelf; what the user needs to read
is exactly that button's text and the explanation next to it. An exemption does not
require low contrast; it only permits it. The decision is to keep the button active and
say why it does not work, rather than disabling it.

**The selected row's ground** falls short at a ratio of 1.23. The last table shows why
fixing it by darkening does not work: when the ground moves to primary step 400, it
clears the threshold at 3.05, but the secondary text on top of it drops to 1.94,
producing a far more severe violation. Darkening the ground trades one problem for
another.

The fix is hidden in the wording of criterion 1.4.11 itself: the threshold is required
for the visual information **needed** to identify a state. If the selected state is
announced only by the ground color, the ground carries that information and is subject
to the threshold. If a second indicator is added to the state — a vertical bar at the
start of the row that clears a 3:1 threshold — the ground stops being the sole carrier
and can stay pale. The rule from the previous lesson becomes, here, the way to meet the
threshold: a second channel produces not only resilience but also conformance.

#### Where the Audit Belongs

Doing this audit while the system is being built is cheaper than doing it after
components are written. When the role table and the match list are kept together, the
audit reduces to a data-processing task: the input is role names, the output is which
matches pass and which remain.

Writing the match list by hand is a weakness — if a new match is added to the interface
and not added to the list, the audit will not see it. What has to happen on the design
side is that the list exists, and every new role match gets written into it.

#### Summary

- Contrast ratio is the 0.05-offset ratio of two relative luminances; its range runs
  from 1:1 to 21:1, and it is not a linear measure of perception.
- A reverse computation from threshold to color is possible: a 4.5 threshold on a
  white ground allows a text color with a relative luminance of at most 0.1833.
- The formula does not see hue; colors that give the same ratio sit at very different
  lightness values, and clearing the threshold does not mean they are distinguishable
  from each other.
- The large-text boundary is a stepped rule; the required contrast drops from 4.5 to
  3.0 between 18 and 19 pixels.
- The audit is not done one match at a time but over every foreground–ground pair that
  actually occurs in the interface; a missed match goes unnoticed.
- Not every failure is fixed by darkening a color: in some cases an element is
  removed, in others a second indicator is added so color stops being the sole
  carrier.

#### Next Step

This lesson audited every match against a single ground family: light surfaces and dark
text. When the same interface runs in a dark theme, this entire relationship inverts.
Inverting is not as mechanical as it looks; swapping the ramp's steps one-for-one does
not preserve contrast ratios, and semantic colors miss the threshold on a dark
ground. The next lesson builds the dark theme not as a second color list but as a
**transformation**, defines the transformation's preservation condition, and runs the
transformation through the same audit.

### Dark Theme

Source: https://academia.sh/en/courses/interface-fundamentals/dark-theme

Why mirroring steps distorts the hierarchy ratio, deriving the dark theme through a contrast-preservation condition, why saturation is free while lightness is dependent, and how elevation consumes the contrast budget.

The previous lesson audited every color match in the catalog interface. The entire
audit was built on a single assumption: light surfaces, dark text. When the same
interface runs in a dark theme, this relationship inverts, and the entire audit has to
be redone.

A dark theme is not a second list in the color system; it is a **transformation**
derived from the first. This lesson's question is the transformation's rule: which
quantity is preserved, which is set free, and how is the transformation checked for
correctness?

#### What Mirroring Preserves, and What It Distorts

The most direct transformation is to swap the ramp's steps one-for-one: 000 trades
places with 900, 050 with 800, 100 with 700. Role names do not change, only which step
they point to changes. This transformation's outcome needs to be measured.

```js
// mirroring.mjs — auditing a dark theme built by swapping ramp steps one-for-one

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)].map((v) => Math.round(v * 255));
}
const hex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
function channel(v) {
  const s = v / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

const LIGHTNESS = { "000": 100, "050": 97, 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22, 900: 14 };
const FAMILY = { neutral: [214, 8], primary: [214, 62], error: [8, 68] };
const color = (family, b) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[b]);

const MIRROR = { "000": "900", "050": "800", 100: "700", 200: "600", 300: "500", 400: "400", 500: "300", 600: "200", 700: "100", 800: "050", 900: "000" };

const LIGHT_ROLE = {
  "surface":           ["neutral", "000"],
  "surface-secondary": ["neutral", "050"],
  "border":            ["neutral", "500"],
  "text-primary":      ["neutral", "900"],
  "text-secondary":    ["neutral", "600"],
  "action-primary":    ["primary", "600"],
  "action-primary-on": ["neutral", "000"],
  "selected-ground":   ["primary", "100"],
  "error-text":        ["error", "600"],
};
const DARK_ROLE = Object.fromEntries(
  Object.entries(LIGHT_ROLE).map(([name, [family, b]]) => [name, [family, MIRROR[b]]])
);

console.log("role              light theme      dark theme (mirrored)");
for (const name of Object.keys(LIGHT_ROLE)) {
  const [lf, ls] = LIGHT_ROLE[name];
  const [df, ds] = DARK_ROLE[name];
  console.log(
    `${name.padEnd(17)} ${(lf + "-" + ls).padEnd(10)} ${hex(color(lf, ls))}  ${(df + "-" + ds).padEnd(10)} ${hex(color(df, ds))}`
  );
}

const MATCH = [
  ["text-primary", "surface", 4.5],
  ["text-secondary", "surface", 4.5],
  ["text-secondary", "surface-secondary", 4.5],
  ["border", "surface", 3.0],
  ["action-primary-on", "action-primary", 4.5],
  ["action-primary", "surface", 3.0],
  ["error-text", "surface", 4.5],
  ["text-secondary", "selected-ground", 4.5],
];

console.log("\nforeground / ground                  threshold  light theme  dark theme  dark result");
for (const [fg, ground, threshold] of MATCH) {
  const lightK = contrast(color(...LIGHT_ROLE[fg]), color(...LIGHT_ROLE[ground]));
  const darkK = contrast(color(...DARK_ROLE[fg]), color(...DARK_ROLE[ground]));
  console.log(
    `${(fg + " / " + ground).padEnd(36)} ${threshold.toFixed(1).padStart(4)} ${lightK.toFixed(2).padStart(11)} ${darkK.toFixed(2).padStart(10)}  ${darkK >= threshold ? "passed" : "FAILED"}`
  );
}
```

```
role              light theme      dark theme (mirrored)
surface           neutral-000 #ffffff  neutral-900 #212327
surface-secondary neutral-050 #f7f7f8  neutral-800 #34383d
border            neutral-500 #757e8a  neutral-300 #b7bcc2
text-primary      neutral-900 #212327  neutral-000 #ffffff
text-secondary    neutral-600 #5e656e  neutral-200 #d3d6d9
action-primary    primary-600 #275ea5  primary-200 #bdd3ef
action-primary-on neutral-000 #ffffff  neutral-900 #212327
selected-ground   primary-100 #dee9f7  primary-700 #1e4980
error-text        error-600  #ab3321  error-200  #f2c2ba

foreground / ground                  threshold  light theme  dark theme  dark result
text-primary / surface                4.5       15.74      15.74  passed
text-secondary / surface              4.5        5.89      10.78  passed
text-secondary / surface-secondary    4.5        5.51       8.09  passed
border / surface                      3.0        4.11       8.23  passed
action-primary-on / action-primary    4.5        6.50      10.29  passed
action-primary / surface              3.0        6.50      10.29  passed
error-text / surface                  4.5        6.51       9.89  passed
text-secondary / selected-ground      4.5        4.80       6.20  passed
```

All eight matches clear the threshold. Mirroring produced no conformance problem; in
fact, it raised the contrast ratio in most matches. From this, it cannot be concluded
that mirroring is the right transformation, because the audit is asking the wrong
question.

The threshold audit looks at each match one at a time and asks about the lower bound.
Hierarchy, however, depends on the relationship **between** matches. In the light
theme, primary text carries a contrast of 15.74, secondary text 5.89; the ratio between
them is 2.67. In the mirrored dark theme, primary is 15.74, secondary 10.78; the ratio
is 1.46. The second level sits much closer to the first.

This means the contrast channel measured in the Visual Hierarchy lesson weakens in the
dark theme. The channel clears the threshold but loses its distinguishing power. What
mirroring preserves is threshold conformance; what it distorts is the **ratio
structure**.

The reason is that the relative luminance function is not linear. Equal steps in HSL
lightness do not produce equal steps in luminance; the scale's dark end is compressed
on the luminance axis, its light end is sparse. Swapping the steps moves the compressed
end to the sparse end, and every distance in between changes.

#### The Quantity to Preserve Is the Contrast Ratio

The correct transformation carries not step numbers but **contrast ratios**. For each
role, the ratio measured in the light theme is taken as the target, and the lightness
value that produces the same ratio on the dark surface is searched for.

This requires a decision first: how dark will the dark surface be? As the surface
darkens, the contrast budget that can fit above it grows, but the budget is not
infinite.

```js
// preservation.mjs — deriving a dark theme that preserves the light theme's contrast ratios

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)].map((v) => Math.round(v * 255));
}
const hex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
function channel(v) {
  const s = v / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

const LIGHTNESS = { "000": 100, "050": 97, 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22, 900: 14 };
const FAMILY = { neutral: [214, 8], primary: [214, 62], error: [8, 68] };
const color = (family, b) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[b]);

// 1) Choosing the dark surface: how dark a surface, and how much contrast range does it leave above it?
console.log("dark surface candidate  hex        relative luminance  highest contrast on top");
for (const [name, rgb] of [
  ["pure black", [0, 0, 0]],
  ["neutral-900", color("neutral", "900")],
  ["neutral-800", color("neutral", "800")],
  ["neutral-700", color("neutral", "700")],
]) {
  console.log(
    `${name.padEnd(17)} ${hex(rgb)}  ${luminance(rgb).toFixed(4).padStart(15)}  ${(1.05 / (luminance(rgb) + 0.05)).toFixed(2).padStart(27)}:1`
  );
}

// 2) Preservation: reproduce each role's light-theme contrast ratio on the dark surface.
const LIGHT_SURFACE = color("neutral", "000");
const DARK_SURFACE = color("neutral", "900");

const ROLES = [
  { name: "text-primary",   family: "neutral", step: "900", saturation: 8 },
  { name: "text-secondary", family: "neutral", step: "600", saturation: 8 },
  { name: "border",         family: "neutral", step: "500", saturation: 8 },
  { name: "action-primary", family: "primary", step: "600", saturation: 45 },
  { name: "error-text",     family: "error",   step: "600", saturation: 55 },
];

function findLightness(hue, saturation, ground, target) {
  let low = 0, high = 100;
  for (let i = 0; i < 50; i++) {
    const mid = (low + high) / 2;
    if (contrast(hslRgb(hue, saturation, mid), ground) < target) low = mid;
    else high = mid;
  }
  return (low + high) / 2;
}

console.log("\nrole             light target  dark lightness  saturation  dark color  measured");
const darkPalette = {};
for (const r of ROLES) {
  const target = contrast(color(r.family, r.step), LIGHT_SURFACE);
  const hue = FAMILY[r.family][0];
  const l = findLightness(hue, r.saturation, DARK_SURFACE, target);
  const rgb = hslRgb(hue, r.saturation, l);
  darkPalette[r.name] = rgb;
  console.log(
    `${r.name.padEnd(16)} ${target.toFixed(2).padStart(8)}:1 ${l.toFixed(2).padStart(12)}% ${String(r.saturation).padStart(9)}%  ${hex(rgb)}  ${contrast(rgb, DARK_SURFACE).toFixed(2).padStart(6)}:1`
  );
}

// 3) Was the hierarchy ratio preserved? The gap between primary and secondary text.
const lightPrimary = contrast(color("neutral", "900"), LIGHT_SURFACE);
const lightSecondary = contrast(color("neutral", "600"), LIGHT_SURFACE);
const darkPrimary = contrast(darkPalette["text-primary"], DARK_SURFACE);
const darkSecondary = contrast(darkPalette["text-secondary"], DARK_SURFACE);
console.log(`\nlight theme: primary ${lightPrimary.toFixed(2)}:1, secondary ${lightSecondary.toFixed(2)}:1, ratio ${(lightPrimary / lightSecondary).toFixed(3)}`);
console.log(`dark theme: primary ${darkPrimary.toFixed(2)}:1, secondary ${darkSecondary.toFixed(2)}:1, ratio ${(darkPrimary / darkSecondary).toFixed(3)}`);

// 4) What was the same ratio in the mirrored version?
const mirroredPrimary = contrast(color("neutral", "000"), DARK_SURFACE);
const mirroredSecondary = contrast(color("neutral", "200"), DARK_SURFACE);
console.log(`mirroring  : primary ${mirroredPrimary.toFixed(2)}:1, secondary ${mirroredSecondary.toFixed(2)}:1, ratio ${(mirroredPrimary / mirroredSecondary).toFixed(3)}`);

// 5) Saturation is free, lightness is dependent: hitting the same target at different saturations
console.log("\nfor action-primary, target 6.50:1 -> saturation choice and resolved lightness");
for (const s of [62, 45, 30, 15]) {
  const l = findLightness(FAMILY.primary[0], s, DARK_SURFACE, 6.5);
  const rgb = hslRgb(FAMILY.primary[0], s, l);
  console.log(
    `saturation ${String(s).padStart(2)}%  ->  lightness ${l.toFixed(2).padStart(6)}%  ${hex(rgb)}  ${contrast(rgb, DARK_SURFACE).toFixed(2)}:1`
  );
}

// 6) Elevation in a dark theme: the surface on top gets lighter.
console.log("\nelevation      lightness  hex        against surface  highest contrast on top");
for (const [name, targetSeparation] of [["base", 1.0], ["card", 1.3], ["popover layer", 1.7]]) {
  const l = targetSeparation === 1.0 ? LIGHTNESS["900"] : findLightness(FAMILY.neutral[0], FAMILY.neutral[1], DARK_SURFACE, targetSeparation);
  const rgb = hslRgb(FAMILY.neutral[0], FAMILY.neutral[1], l);
  console.log(
    `${name.padEnd(14)} ${l.toFixed(2).padStart(7)}%  ${hex(rgb)}  ${contrast(rgb, DARK_SURFACE).toFixed(3).padStart(16)}  ${(1.05 / (luminance(rgb) + 0.05)).toFixed(2).padStart(27)}:1`
  );
}
```

```
dark surface candidate  hex        relative luminance  highest contrast on top
pure black        #000000           0.0000                        21.00:1
neutral-900       #212327           0.0167                        15.74:1
neutral-800       #34383d           0.0390                        11.80:1
neutral-700       #494e55           0.0752                         8.39:1

role             light target  dark lightness  saturation  dark color  measured
text-primary        15.74:1        99.82%         8%  #ffffff   15.74:1
text-secondary       5.89:1        62.78%         8%  #989fa8    5.89:1
border               4.11:1        52.08%         8%  #7b848f    4.15:1
action-primary       6.50:1        68.22%        45%  #89a9d2    6.50:1
error-text           6.51:1        70.24%        55%  #dd9589    6.55:1

light theme: primary 15.74:1, secondary 5.89:1, ratio 2.670
dark theme: primary 15.74:1, secondary 5.89:1, ratio 2.672
mirroring  : primary 15.74:1, secondary 10.78:1, ratio 1.459

for action-primary, target 6.50:1 -> saturation choice and resolved lightness
saturation 62%  ->  lightness  69.03%  #7fa9e1  6.49:1
saturation 45%  ->  lightness  68.22%  #89a9d2  6.50:1
saturation 30%  ->  lightness  67.38%  #93a9c5  6.54:1
saturation 15%  ->  lightness  66.36%  #9ca7b6  6.46:1

elevation      lightness  hex        against surface  highest contrast on top
base             14.00%  #212327             1.000                        15.74:1
card             21.60%  #33363b             1.298                        12.13:1
popover layer    28.34%  #42484e             1.700                         9.26:1
```

#### Surface Choice Sets the Contrast Budget

The first table ties the dark theme's most basic decision to a number. Above a pure
black surface, the highest contrast can reach up to 21:1; at neutral step 900 it is
15.74, at neutral 700 only 8.39.

Pure black gives the widest budget, but it has a cost. White text on pure black
produces 21:1; this is distinctly above the light theme's 15.74, and it violates the
transformation's preservation condition. If preservation is wanted, the surface has to
be chosen so that the light theme's highest ratio can also be produced in the dark
theme, but is not necessarily exceeded. Neutral 900 sits exactly at this point: the
highest contrast above it is 15.74, exactly the same as the light theme's primary text
ratio.

The second table confirms this. The lightness resolved for the primary text role is
99.82 percent, that is, practically pure white. This is not a coincidence, it is a
direct consequence of the surface choice: on a neutral 900 surface, only white can
produce a ratio of 15.74.

The third block shows that preservation is achieved. In the derived dark theme, the ratio between primary
and secondary text is 2.672; it matches the light theme's 2.670 value to two decimal places. In the
mirrored version, this ratio was 1.459. Hierarchy is preserved when the transformation's criterion is the
contrast ratio, and it is not preserved when the criterion is the step number.

#### Saturation Is Free, Lightness Is Dependent

The fourth table takes up the transformation's second parameter. For the primary
action color, the target is 6.50:1; this target can be hit at four different
saturations, only the required lightness changes: between 66.36 percent and 69.03
percent.

This means saturation is a **free parameter**. Contrast preservation does not
constrain saturation; whatever saturation is chosen, lightness is solved for
accordingly and the target ratio is hit. The saturation decision therefore needs a
separate rationale.

In the catalog interface, saturation for the dark theme was chosen lower than in the
light theme: 45 instead of 62 in the primary family, 55 instead of 68 in the error
family. The rationale is the relationship a high-saturation, high-lightness color forms
with the dark area around it on a dark ground; the same color's perceptual footprint on
a light ground is not the same as on a dark ground. This is a preference decision and
does not affect the rest of the transformation — the table shows this: even with
saturation lowered, the ratio is preserved.

The point to notice here is that saturation changes in the ramp definition, not in the
role table. The dark theme never changes role names; it only changes which step values
the roles point to. Components keep writing `--action-primary` and are never aware of
the theme switch. The cost of role naming from the fourth lesson is paid back here.

#### Elevation Consumes the Contrast Budget

The fifth table measures a constraint specific to the dark theme. In the light theme, a
card is separated from the base surface with a shadow. On a dark ground, a shadow is
invisible, because a shadow is already dark; the distinction is built instead by
**lightening** the surface on top.

This cost can be computed. The base surface sits at neutral 900 and has a contrast
budget of 15.74 above it. A card surface separated from the base by a ratio of 1.3
rises to 21.60 percent lightness, and the budget above it drops to 12.13. In a popover
layer separated by a ratio of 1.7, the budget falls to 9.26.

This means that in a dark theme, every elevation layer takes a cut from the contrast
range available to the text that sits on it. In a three-layer interface, the text on
the topmost layer cannot hit the same ratio as the text on the base layer. This is a
constraint with no counterpart in the light theme: in the light theme, elevation moves
toward white, and white is already the upper bound, so the budget does not change.

The practical consequence is that the number of elevation layers in a dark theme is
kept lower than in the light theme. Two layers are enough in the catalog interface: the
base surface and the layer the record detail opens on. If a third layer is added, the
secondary text on that layer cannot hit the 5.89 ratio, and the hierarchy breaks down
there.

#### The Theme Switch's Written Form

The transformation is written by holding role names fixed and changing the step
assignments:

```css
:root {
  --surface: #ffffff;
  --text-primary: #212327;
  --text-secondary: #5e656e;
  --border: #757e8a;
  --action-primary: #275ea5;
}

@media (prefers-color-scheme: dark) {
  :root {
    --surface: #212327;
    --text-primary: #ffffff;
    --text-secondary: #989fa8;
    --border: #7b848f;
    --action-primary: #89a9d2;
  }
}
```

Because the values are generated by computation, this block is not a hand-written
list, it is an output. When a new role is added to the system, its dark counterpart is
not guessed; the same preservation computation is run again.

Theme choice is read from the user's operating system preference. If the interface
also needs to offer a theme toggle, that toggle's choice overrides the system
preference and the choice is persisted; this is a state-management decision, not a
design decision.

#### Summary

- A dark theme is not a second color list, it is a transformation derived from the
  light theme.
- Swapping steps one-for-one preserves threshold conformance but distorts the ratio
  structure; the distinction between primary and secondary text drops from a 2.67
  multiple to a 1.46 multiple.
- The correct transformation's preserved quantity is the contrast ratio; the dark
  palette is derived by searching, for every role, for the lightness value that
  produces the target ratio.
- How dark the dark surface is sets the highest contrast that fits above it; the
  surface is chosen so that it exactly meets the light theme's highest ratio.
- Saturation is a free parameter; whatever saturation is chosen, lightness is solved
  for accordingly and the target ratio is hit.
- In a dark theme, elevation is built by lightening the surface on top, and every
  layer takes a cut from the contrast budget; this is why the layer count is kept
  lower than in the light theme.

#### Next Step

With the typography and color decisions finished, the catalog interface's static
appearance is complete: which text at which size, which color in which role, audited
in both themes. But the interface is not static. The "Borrow" button looks different
when hovered, focused by keyboard, held down, and disabled. Each of these appearances
is a separate design decision, and none of them has been defined so far. The next
topic takes up component states; its first lesson defines five interaction states,
determines which channel each one is separated in, and shows with a criterion why the
focus indicator cannot be removed.

## Component States

Interaction states, loading and empty states, error presentation, icon usage, and interface text as part of the design.

### Interaction States

Source: https://academia.sh/en/courses/interface-fundamentals/interaction-states

Definition of the five interaction states, tying state change to a measurable threshold, the channel conflict between hover and selected, and the area and contrast calculation of the focus indicator.

The previous topic completed the static appearance of the catalog interface: the text
takes a step from the scale, a value from the color role table, and both themes have
been checked. But the interface is not static. The Borrow button looks different when
the pointer hovers over it, when it receives keyboard focus, when it is held down, and
when it is unavailable because the record is not on the shelf.

Each of these appearances is a separate design decision, and none of them has been
defined yet. Left undefined, the decisions get made one at a time as each component is
coded; two buttons in the same interface end up responding differently, and the user
never learns what any given response means.

#### Five States

An interactive component carries the following states:

- **Default.** The appearance of the component under no interaction at all. Every
  other state is defined relative to it.
- **Hover.** The state in which the pointer sits over the component. The information
  it communicates is: this is a clickable spot. It exists only with pointer input.
- **Focus.** The state in which the component is the element that will receive
  keyboard input. The information it communicates is: this is what a keypress will
  affect.
- **Active.** The state in which the component is currently being pressed. The
  information it communicates is that input has been received.
- **Disabled.** The state in which the component cannot currently be used.

Two of the five states are frequently confused. Hover and focus come from different
input types and communicate different things; using one in place of the other means a
keyboard user navigating the interface cannot see where they are. As shown in the
Screen Size and Input Type lesson, hover never occurs with touch input; focus occurs
with every input type. For this reason, hover can only carry **supplementary**
information, never load-bearing information.

States can combine. A button can be both focused and active at the same time; it can
be both focused and hovered. States are therefore not a list of mutually exclusive
options but layers that stack on top of one another, and each one has to use a
**separate channel**.

#### State Change Must Be Measurable

A state that cannot be distinguished from the default does not exist. The measure of
the distinction is the contrast ratio used in earlier lessons; the threshold is the
value of 1.2 this course has adopted.

```js
// states.mjs — separability of interaction states from the default and text contrast

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)].map((v) => Math.round(v * 255));
}
const toHex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
function channel(v) {
  const s = v / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

const LIGHTNESS = { "000": 100, "050": 97, 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22, 900: 14 };
const TONE = { neutral: [214, 8], primary: [214, 62] };
const color = (family, b) => hslRgb(TONE[family][0], TONE[family][1], LIGHTNESS[b]);

const SEPARATION_THRESHOLD = 1.2;   // the smallest state change ratio this course adopts
const TEXT_THRESHOLD = 4.5;

const FILLED_BUTTON = [
  { state: "default",  surface: ["primary", "600"], text: ["neutral", "000"] },
  { state: "hover",    surface: ["primary", "700"], text: ["neutral", "000"] },
  { state: "active",   surface: ["primary", "800"], text: ["neutral", "000"] },
  { state: "disabled", surface: ["neutral", "200"], text: ["neutral", "400"] },
];

const OUTLINE_BUTTON = [
  { state: "default",  surface: ["neutral", "000"], text: ["primary", "600"] },
  { state: "hover",    surface: ["primary", "100"], text: ["primary", "700"] },
  { state: "active",   surface: ["primary", "200"], text: ["primary", "800"] },
  { state: "disabled", surface: ["neutral", "000"], text: ["neutral", "400"] },
];

function report(title, list) {
  console.log(`\n--- ${title} ---`);
  console.log("state           surface  text     text/surface  surface diff  text diff     carrying channel");
  const t = list[0];
  for (const d of list) {
    const y = color(...d.surface);
    const m = color(...d.text);
    const km = contrast(m, y);
    const ay = contrast(y, color(...t.surface));
    const am = contrast(m, color(...t.text));
    const channels = [];
    if (ay >= SEPARATION_THRESHOLD) channels.push("surface");
    if (am >= SEPARATION_THRESHOLD) channels.push("text");
    console.log(
      `${d.state.padEnd(15)} ${toHex(y)}  ${toHex(m)}  ${km.toFixed(2).padStart(9)}:1  ` +
        `${ay.toFixed(3).padStart(12)}  ${am.toFixed(3).padStart(12)}  ` +
        (d === t ? "-" : channels.length ? channels.join("+") : "NOT SEPARATED") +
        (km < TEXT_THRESHOLD ? "  [below text threshold]" : "")
    );
  }
}

report("primary button (filled)", FILLED_BUTTON);
report("secondary button (unfilled)", OUTLINE_BUTTON);

// Result list row: hover and selected share the same channel
console.log("\n--- result list row ---");
console.log("state           surface  vs default         vs hover");
const ROW = [
  ["default", ["neutral", "000"]],
  ["hover", ["neutral", "050"]],
  ["selected", ["primary", "100"]],
];
for (const [name, base] of ROW) {
  const y = color(...base);
  console.log(
    `${name.padEnd(15)} ${toHex(y)}  ${contrast(y, color("neutral", "000")).toFixed(3).padStart(17)}  ${contrast(y, color("neutral", "050")).toFixed(3).padStart(21)}`
  );
}

// For the row, at which step does the hover surface clear the threshold?
console.log("\nhover candidate      vs default         vs selected");
for (const b of ["050", "100", "200"]) {
  const y = color("neutral", b);
  console.log(
    `${("neutral-" + b).padEnd(20)} ${contrast(y, color("neutral", "000")).toFixed(3).padStart(17)}  ${contrast(y, color("primary", "100")).toFixed(3).padStart(20)}`
  );
}
```

```
--- primary button (filled) ---
state           surface  text     text/surface  surface diff  text diff     carrying channel
default         #275ea5  #ffffff       6.50:1         1.000         1.000  -
hover           #1e4980  #ffffff       9.05:1         1.393         1.000  surface
active          #15335b  #ffffff      12.68:1         1.950         1.000  surface
disabled        #d3d6d9  #969da6       1.88:1         4.454         2.738  surface+text  [below text threshold]

--- secondary button (unfilled) ---
state           surface  text     text/surface  surface diff  text diff     carrying channel
default         #ffffff  #275ea5       6.50:1         1.000         1.000  -
hover           #dee9f7  #1e4980       7.37:1         1.228         1.393  surface+text
active          #bdd3ef  #15335b       8.29:1         1.530         1.950  surface+text
disabled        #ffffff  #969da6       2.74:1         1.000         2.374  text  [below text threshold]

--- result list row ---
state           surface  vs default         vs hover
default         #ffffff              1.000                  1.071
hover           #f7f7f8              1.071                  1.000
selected        #dee9f7              1.228                  1.147

hover candidate      vs default         vs selected
neutral-050                      1.071                 1.147
neutral-100                      1.204                 1.020
neutral-200                      1.459                 1.189
```

In the filled button, both the hover and active states clear the threshold (1.393 and
1.950), and both sit on a single channel: the surface. This is acceptable, because both
are transient states and carry no information.

Text contrast is preserved in every state, and even rises: 6.50, 9.05, 12.68. This is
the result of the direction chosen; as the fill darkens, the contrast of the white text
increases. Had the opposite direction been chosen, the active state could have missed
the text threshold.

In the outline button, both states separate on two channels at once. The reason is
that the surface separation stays weak in the unfilled button: 1.228, just above the
threshold. The text channel accompanying it compensates for this weakness.

The disabled state stays below the text threshold in both buttons. As seen in the
Contrast and Accessibility lesson, inactive components fall outside the scope of 1.4.3,
so this is not a violation. But the decision made there applies here too: if the user
needs to read what the button does, the button is not disabled.

#### Hover and Selected Cannot Share the Same Channel

The third table shows two separate problems at once in the result list row.

First, the hover surface sits at neutral 050 and produces only 1.071 contrast against
the default. The threshold is 1.2, so this state does not produce a measurable change.

Second, and more serious, the selected state carries only 1.147 contrast against the
hover state. The two states do not separate from each other: the user cannot tell
whether a row is selected or under the pointer.

The fourth table shows the problem cannot be solved within the surface channel.
Neutral 100 clears the threshold against the default (1.204) but drops to 1.020
against the selected state — the two states merge completely. Neutral 200 clears the
default comfortably (1.459) but stays below the threshold against the selected state
at 1.189. None of the three candidates satisfies both conditions.

The reason is structural: both states are coded on the same axis, surface lightness,
and the two values must separate from the default on one side and from each other on
the other. The axis cannot carry that much load.

The solution is to distribute the two states across separate channels. Being selected
is a persistent, information-carrying state; it is marked with a vertical bar placed at
the start of the row that clears the 3.0 threshold against the surface. Hover is
transient; it stays on the surface channel and clears the threshold by moving to the
neutral 100 step. Because the two states sit on different axes, they remain
distinguishable even when they occur at the same time. The selected-row finding in the
Contrast and Accessibility lesson called for this same second channel.

#### The Focus Indicator Cannot Be Removed

The focus state differs from the other four because it alone is bound to a conformance
requirement. Success Criterion 2.4.7 Focus Visible requires every keyboard-operable
element to carry a visible focus indicator. The indicator cannot be removed; it can
only be changed.

What the indicator looks like is bound to two measures. The contrast measure comes
from 1.4.11: the indicator must separate from its neighboring colors by at least 3:1.
The area measure comes from Success Criterion 2.4.13 Focus Appearance: the area the
indicator covers must equal the area of a 2-pixel-thick perimeter around the component.

```js
// focus.mjs — checking the focus indicator against the area and contrast criteria

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)].map((v) => Math.round(v * 255));
}
const toHex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
function channel(v) {
  const s = v / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

// Area criterion: the indicator area must equal the component's 2 px perimeter.
const required = (w, h) => (w + 4) * (h + 4) - w * h;

// Ring area: thickness k, offset o
function ringArea(w, h, k, o) {
  const outW = w + 2 * (o + k), outH = h + 2 * (o + k);
  const inW = w + 2 * o, inH = h + 2 * o;
  return outW * outH - inW * inH;
}

const COMPONENTS = [
  { name: "borrow button", w: 132, h: 44 },
  { name: "icon button", w: 24, h: 24 },
];
const CANDIDATES = [
  { name: "1px, no gap", k: 1, o: 0 },
  { name: "2px, no gap", k: 2, o: 0 },
  { name: "2px, 2px gap", k: 2, o: 2 },
  { name: "3px, 1px gap", k: 3, o: 1 },
];

for (const b of COMPONENTS) {
  const g = required(b.w, b.h);
  console.log(`\n--- ${b.name} (${b.w}x${b.h} px) --- required minimum area: ${g} px2`);
  console.log("indicator          area (px2)  ratio to required  result");
  for (const a of CANDIDATES) {
    const area = ringArea(b.w, b.h, a.k, a.o);
    console.log(
      `${a.name.padEnd(17)} ${String(area).padStart(10)} ${(area / g).toFixed(3).padStart(20)}  ${area >= g ? "passed" : "FAILED"}`
    );
  }
}

// Contrast criterion: the ring must separate from both the page surface and the component fill.
const LIGHTNESS = { "000": 100, 100: 92, 500: 50, 600: 40, 700: 31, 900: 14 };
const TONE = { neutral: [214, 8], primary: [214, 62] };
const color = (family, b) => hslRgb(TONE[family][0], TONE[family][1], LIGHTNESS[b]);

const SURFACE = color("neutral", "000");
const FILL = color("primary", "600");
console.log("\nring color    vs surface    vs fill    both >=3.0");
for (const [family, base] of [["primary", "500"], ["primary", "700"], ["neutral", "900"], ["neutral", "500"]]) {
  const h = color(family, base);
  const ky = contrast(h, SURFACE);
  const kd = contrast(h, FILL);
  console.log(
    `${(family + "-" + base).padEnd(13)} ${ky.toFixed(2).padStart(12)}  ${kd.toFixed(2).padStart(13)}  ${ky >= 3 && kd >= 3 ? "passed" : "FAILED"}`
  );
}

// Two-layer ring: inner layer in the surface color, outer layer dark.
console.log("\ntwo-layer ring (inner: surface, outer: neutral-900)");
const inner = SURFACE, outer = color("neutral", "900");
console.log(`inner layer / fill   : ${contrast(inner, FILL).toFixed(2)}:1`);
console.log(`outer layer / surface: ${contrast(outer, SURFACE).toFixed(2)}:1`);
console.log(`outer layer / inner  : ${contrast(outer, inner).toFixed(2)}:1`);
console.log(`area (132x44, inner 2px + outer 2px): ${ringArea(132, 44, 4, 0)} px2, required ${required(132, 44)} px2`);
```

```
--- borrow button (132x44 px) --- required minimum area: 720 px2
indicator          area (px2)  ratio to required  result
1px, no gap              356                0.494  FAILED
2px, no gap              720                1.000  passed
2px, 2px gap             752                1.044  passed
3px, 1px gap            1116                1.550  passed

--- icon button (24x24 px) --- required minimum area: 208 px2
indicator          area (px2)  ratio to required  result
1px, no gap              100                0.481  FAILED
2px, no gap              208                1.000  passed
2px, 2px gap             240                1.154  passed
3px, 1px gap             348                1.673  passed

ring color    vs surface    vs fill    both >=3.0
primary-500           4.59           1.41  FAILED
primary-700           9.05           1.39  FAILED
neutral-900          15.74           2.42  FAILED
neutral-500           4.11           1.58  FAILED

two-layer ring (inner: surface, outer: neutral-900)
inner layer / fill   : 6.50:1
outer layer / surface: 15.74:1
outer layer / inner  : 15.74:1
area (132x44, inner 2px + outer 2px): 1472 px2, required 720 px2
```

The area table gives a one-line result: a one-pixel ring covers about half the
criterion. The ratio is roughly the same for both components — 0.494 and 0.481 —
because the criterion is already proportional to the component's perimeter. A
two-pixel ring meets the criterion exactly; adding a gap makes the area slightly
larger.

The contrast table gives a harsher result. **None** of the four ring-color candidates
satisfy both conditions. The reason is that the ring has two different neighbors: the
light page surface outside, the dark button fill inside. Separating from the surface
requires being dark; separating from the fill requires being light — a single color
cannot do both.

The final block gives the solution. The ring is drawn in two layers: the inner layer in
the page surface color, the outer layer dark. The inner layer carries 6.50 contrast
against the button fill, the outer layer 15.74 against the page surface; both
conditions are satisfied. At a total thickness of 4 pixels, the area is 1472 square
pixels, more than twice the 720 required.

This solution has a side effect: whatever the background, one of the two layers is
always guaranteed to separate from its neighbor. This property is what makes it
possible to write the focus indicator as a single rule instead of adjusting it
component by component.

#### The State Table Is the Component's Contract

A component's design is completed not by a single appearance but by its state table.
Each row of the table is a state, each column a channel; a cell left blank says that
state does not change on that channel.

A complete table means a complete design. The most frequently skipped rows are focus
and disabled; both require an interaction, so neither is visible on screen when the
design is reviewed. Writing the table down prevents this omission.

A second conclusion follows from the table: states are defined for a **role, not a
component**. The same state definition applies to the search button, the apply-filter
button, and the extend-due-date button; once it is defined at the role level, the
decision does not need to be made again for a new button.

#### Summary

- Interactive components carry five states, and each communicates different
  information; hover exists only with pointer input, so it can never carry
  load-bearing information.
- State change must be measurable; the distinction from the default is tied to a
  threshold, and the channel it occurs on is written down.
- State transitions built by darkening the fill raise text contrast; choosing the
  opposite direction can miss the text threshold.
- Hover and selected cannot be coded on the same axis; because surface lightness
  cannot carry two separations at once, the persistent state moves to a second
  channel.
- The focus indicator cannot be removed; the area criterion requires an area equal to
  the component's 2-pixel perimeter, and the contrast criterion requires 3:1
  separation from its neighbors.
- A single-color ring cannot satisfy two different neighbors at once; a two-layer ring
  works against any background and can be written as a single rule.

#### Next Step

All five states assumed the component was **ready**. But the catalog interface may be
waiting for data to arrive, a search may have returned no results, or the user may not
have searched for anything yet. In these three cases there is no component to show on
screen, and what needs designing is waiting and emptiness themselves. The next lesson
covers which duration thresholds change the loading indicator, when a skeleton view
becomes misleading, and why an empty state is not an error message.

### Loading and Empty States

Source: https://academia.sh/en/courses/interface-fundamentals/loading-and-empty-states

Splitting waiting time into feedback classes, the effect of indicator delay on flash rate, justifying the skeleton view with the layout shift score, and the three types of empty state.

The previous lesson defined a component's five interaction states. All five rested on
a single assumption: the component exists on screen and is ready for interaction. In
the catalog interface this assumption is frequently false. The user pressed the search
button and the results have not arrived yet; the search returned no records; or the
user just opened the page and has not searched for anything at all.

In all three cases there is no result list to show on screen. What needs designing is
not the list itself but its **absence**. This lesson separates two forms of absence:
absence because the data is on its way, and absence because there is no data. The two
communicate different information and call for different solutions.

#### Waiting Is a Range of Durations

The loading state is not a single design; it requires something different depending on
how long it lasts. The duration itself is measured, and the decision is grounded in
that measurement.

```js
// waiting.mjs — distributing measured response times across feedback classes

// Reproducible sample generation: linear congruential generator + log-normal transform
function generator(seed) {
  let s = seed >>> 0;
  return () => ((s = (1664525 * s + 1013904223) >>> 0) / 4294967296);
}
const random = generator(20260727);
function normal() {
  const u1 = Math.max(random(), 1e-12), u2 = random();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

// Response time of the catalog search (ms): median 220 ms, right-tailed
const samples = Array.from({ length: 200 }, () =>
  Math.round(220 * Math.exp(0.9 * normal()))
).sort((a, b) => a - b);

const percentile = (p) => samples[Math.min(samples.length - 1, Math.floor((p / 100) * samples.length))];
console.log(`sample count: ${samples.length}`);
console.log(`min ${samples[0]} ms   median ${percentile(50)} ms   p90 ${percentile(90)} ms   p99 ${percentile(99)} ms   max ${samples[samples.length - 1]} ms`);

// Feedback classes: each class's ceiling and the display it requires
const CLASSES = [
  { name: "instant",     ceiling: 100,      display: "no feedback needed" },
  { name: "seamless",    ceiling: 1000,     display: "pointer/button state is enough" },
  { name: "noticeable",  ceiling: 10000,    display: "loading indicator" },
  { name: "long",        ceiling: Infinity, display: "progress + cancel" },
];

console.log("\nclass         ceiling(ms)  sample count  share    required display");
for (const c of CLASSES) {
  const floor = CLASSES[CLASSES.indexOf(c) - 1]?.ceiling ?? 0;
  const n = samples.filter((v) => v > floor && v <= c.ceiling).length;
  console.log(
    `${c.name.padEnd(13)} ${(c.ceiling === Infinity ? "-" : String(c.ceiling)).padStart(12)} ${String(n).padStart(13)}  ${(n / samples.length * 100).toFixed(1).padStart(5)}%   ${c.display}`
  );
}

// Indicator delay: how many requests trigger the indicator, and how many of those flash?
// Flash criterion: the indicator stays visible for less than 200 ms.
console.log("\ndelay(ms)  indicator opened  flashing  flash rate (all requests)");
for (const delay of [0, 150, 300, 500]) {
  const opened = samples.filter((v) => v > delay);
  const flashing = opened.filter((v) => v - delay < 200).length;
  console.log(
    `${String(delay).padStart(9)} ${String(opened.length).padStart(17)} ${String(flashing).padStart(9)} ${(flashing / samples.length * 100).toFixed(1).padStart(28)}%`
  );
}

// Minimum visible duration rule: once the indicator opens, it stays for at least 300 ms.
// This adds artificial delay to some requests; its total cost is computed here.
const DELAY = 300, MIN_VISIBLE = 300;
let artificial = 0, affected = 0;
for (const v of samples) {
  if (v <= DELAY) continue;
  const visible = v - DELAY;
  if (visible < MIN_VISIBLE) { artificial += MIN_VISIBLE - visible; affected++; }
}
console.log(`\n300 ms delay + 300 ms minimum visible duration:`);
console.log(`  requests with artificial delay added: ${affected} / ${samples.length}`);
console.log(`  total added duration: ${artificial} ms, average per request: ${(artificial / samples.length).toFixed(1)} ms`);
```

```
sample count: 200
min 23 ms   median 228 ms   p90 722 ms   p99 2100 ms   max 2362 ms

class         ceiling(ms)  sample count  share    required display
instant                100            45   22.5%   no feedback needed
seamless              1000           147   73.5%   pointer/button state is enough
noticeable           10000             8    4.0%   loading indicator
long                     -             0    0.0%   progress + cancel

delay(ms)  indicator opened  flashing  flash rate (all requests)
        0               200        89                         44.5%
      150               126        59                         29.5%
      300                75        34                         17.0%
      500                41        20                         10.0%

300 ms delay + 300 ms minimum visible duration:
  requests with artificial delay added: 47 / 200
  total added duration: 7573 ms, average per request: 37.9 ms
```

The class boundaries are established thresholds tied to human perception. A response
under one hundred milliseconds is perceived as instant; a response of up to one second
does not break flow but is noticeable; a wait of up to ten seconds causes attention to
drift; beyond that is the range in which the user moves on to something else.

The measurement hands over a design decision directly: 96 percent of requests complete
in under one second. That means the catalog search **mostly** does not need a loading
indicator. If the indicator opens on every request, the indicator itself becomes a
source of noise.

The second table turns this into a number. When the indicator opens with no delay,
44.5 percent of requests make it appear and vanish in under 200 milliseconds; an
element that flashes onto the screen and disappears immediately draws attention
instead of giving information. Pulling the delay to 300 milliseconds brings that rate
down to 17 percent, and to 500 milliseconds brings it down to 10 percent.

Delay alone is not enough, because the indicator can still close right after it opens.
The second rule is that once the indicator has opened, it stays visible for at least
some minimum duration. The third block gives its cost: the 300-millisecond
minimum-visible-duration rule adds artificial delay to 47 of 200 requests and produces
an average cost of 37.9 milliseconds per request. This cost is paid deliberately: the
price of eliminating flash is that some requests appear to take slightly longer.

The "progress + cancel" class in the fourth row has no samples at all. This means the
catalog search never falls into that class; but if another operation in the interface —
a bulk export, for instance — falls into that class, a progress indicator and a cancel
button become mandatory. An indeterminate indicator does not tell the user, in an
operation lasting more than ten seconds, whether it is progressing at all.

#### The Skeleton View Reserves Space

The loading indicator's second job is to hold the place of the content that is coming.
A small spinner does not do this job: when the content arrives, everything below it
gets pushed down, and the point the user was looking at shifts.

This shift is measurable. The **layout shift score** is the product of the ratio
between the area affected by the shifting elements and the viewport area, and the
ratio between the distance the elements travel and the largest dimension of the
viewport.

```js
// skeleton.mjs — skeleton view row count and layout shift score

const VIEWPORT = { width: 1280, height: 800 };
const HEADER_Y = 96;      // top header strip
const ROW_Y = 72;         // result row height
const FOOTER_Y = 80;      // footer strip

// 1) How many skeleton rows fit the visible area?
const area = VIEWPORT.height - HEADER_Y;
console.log(`visible content area: ${area} px`);
console.log(`row height: ${ROW_Y} px`);
console.log(`fully fitting rows: ${Math.floor(area / ROW_Y)}`);
console.log(`partially visible row: ${(area % ROW_Y) > 0 ? "yes (" + (area % ROW_Y) + " px)" : "no"}`);

// 2) Layout shift score: impact fraction x distance fraction
// Unstable element: the footer strip. It shifts when going from the loading view to the loaded view.
function shiftScore(prevTop, nextTop) {
  const g = VIEWPORT.height;
  const visibleRange = (top) => {
    const bottom = Math.min(g, top + FOOTER_Y);
    return top >= g ? [0, 0] : [Math.max(0, top), bottom];
  };
  const [p1, p2] = visibleRange(prevTop);
  const [n1, n2] = visibleRange(nextTop);
  const unionTop = Math.min(p1 || Infinity, n1 || Infinity);
  const unionBottom = Math.max(p2, n2);
  const impactFraction = (unionBottom - unionTop) / g;
  const distance = Math.abs(nextTop - prevTop);
  const distanceFraction = distance / Math.max(VIEWPORT.width, VIEWPORT.height);
  return { impactFraction, distanceFraction, score: impactFraction * distanceFraction, distance };
}

const ROW_COUNT = 9; // visible row count in the loaded list
const loadedFooter = HEADER_Y + ROW_COUNT * ROW_Y;

const SCENARIOS = [
  { name: "spinner (120 px)", height: 120 },
  { name: "skeleton 5 rows", height: 5 * ROW_Y },
  { name: "skeleton 8 rows", height: 8 * ROW_Y },
  { name: "skeleton 9 rows", height: ROW_COUNT * ROW_Y },
];

console.log("\nloading view              footer before  footer after  distance  impact frac  distance frac  shift score");
for (const s of SCENARIOS) {
  const before = HEADER_Y + s.height;
  const k = shiftScore(before, loadedFooter);
  console.log(
    `${s.name.padEnd(25)} ${String(before).padStart(11)} ${String(loadedFooter).padStart(13)} ${String(k.distance).padStart(8)} ` +
      `${k.impactFraction.toFixed(4).padStart(11)} ${k.distanceFraction.toFixed(4).padStart(14)} ${k.score.toFixed(4).padStart(12)}`
  );
}
console.log("\naccepted good upper limit: 0.100");
```

```
visible content area: 704 px
row height: 72 px
fully fitting rows: 9
partially visible row: yes (56 px)

loading view              footer before  footer after  distance  impact frac  distance frac  shift score
spinner (120 px)                  216           744      528      0.7300         0.4125       0.3011
skeleton 5 rows                   456           744      288      0.4300         0.2250       0.0968
skeleton 8 rows                   672           744       72      0.1600         0.0563       0.0090
skeleton 9 rows                   744           744        0      0.0700         0.0000       0.0000

accepted good upper limit: 0.100
```

The spinner produces a score of 0.3011, three times the accepted good upper limit. The
reason is that the indicator is much shorter than the actual content, so the footer
gets pushed down 528 pixels when the content arrives.

The skeleton view's row count determines this score directly. Five rows stay just
under the limit at 0.0968, eight rows drop to 0.0090, and at nine rows the shift
disappears completely. Since exactly nine rows fit the visible area, that is the
correct number.

The rule that follows is: **the skeleton mimics not the count of the incoming content
but the row count the visible area holds.** Even if the search will return thirty
results, drawing thirty skeleton rows is unnecessary; the nine rows in the visible area
are enough to eliminate the shift.

The skeleton view has a limit that gets overlooked: the skeleton commits to the
**structure** of the incoming content. Drawing a nine-row skeleton and then having
three results arrive makes the user believe, for a moment, that there were nine
results. For this reason the skeleton is used only when it is certain results are
coming; in a search where the result count has a high probability of being zero, an
empty placeholder area is more appropriate than a skeleton.

#### An Empty State Is Not an Error

The state in which loading has finished but there is no content arises from three
separate causes, and each one says something different.

**First-use emptiness.** The user just opened the catalog page and has not searched
yet. What is missing here is not data but the user's action. The right content is an
explanation and a starting path: what the search covers and which fields it can search
on.

**No-results emptiness.** The user searched, the system worked, and there is no
matching record. What is missing here is data. The right content is to restate what
the search was and to offer a way to loosen the narrowing: a list of the applied
filters and the option to remove them one at a time. Restating the search text is not a
detail but a requirement; it is the user's only way to see that they typed something
wrong.

**Cleared-list emptiness.** The user returned every book they had borrowed, and the "my
borrowed items" list emptied out. Here the emptiness is a success. The right content is
neither an explanation nor a suggestion; a single line stating the situation is enough.

The common mistake across all three states is presenting the emptiness as an error.
Error color, a warning icon, and the phrase "not found" tell the user that something
broke; yet in all three states the system worked correctly. The distinction from the
Functional Colors lesson applies here: an empty state falls into the informational
class, not the error class.

The second common mistake with empty states is leaving the screen entirely blank. In
that case the user cannot tell whether loading is still in progress. An empty state is
a design too, and it has to carry text; which text and how to write it is the question
of this topic's final lesson.

#### Summary

- Waiting is not a single state but a range that needs different display depending on
  duration; the class boundaries are compared against measured durations.
- If the measurement shows most requests complete under the indicator threshold, the
  indicator opens with a delay; the delay directly lowers the flash rate.
- Once the indicator opens, it stays visible for at least some minimum duration; the
  cost is artificial delay added to some requests, and that cost can be computed.
- The skeleton view reserves space and zeroes out layout shift; the correct row count
  is not the number of incoming results but the number of rows the visible area holds.
- The skeleton commits to the structure of the incoming content; when the probability
  of no results is high, the skeleton is misleading.
- An empty state arises from three separate causes, none of them an error; the
  emptiness is presented in the informational class and always carries text.

#### Next Step

An empty state was a result of the system working correctly. The states in which the
system does not work correctly require a separate design: an invalid value was entered
in the ISBN field, the borrow request could not reach the server, the user's borrowing
limit is full. These three cannot be presented in the same box, because each has a
different path to recovery. The next lesson classifies errors by source and
recoverability, determines where a message is placed by its distance from the error
field, and discusses which errors should never be shown at all.

### Error and Warning States

Source: https://academia.sh/en/courses/interface-fundamentals/error-and-warning-states

Classifying errors by source and recoverability, computing the number of messages produced by the moment of validation, filtering out preventable errors, and placing the message with the proximity rule.

The previous lesson showed that emptiness is not an error: the system worked
correctly, and the result had not yet arrived. The states in which the system does not
work correctly, or in which the input the user gave cannot be accepted, require a
separate design.

The catalog interface has three separate examples: an invalid value was entered in the
ISBN field, the borrow request could not reach the server, the user's borrowing limit
is full. All three get called "error," but each has a different path to recovery. In
the first, the user fixes the input; in the second, they try again; in the third, there
is nothing they can do. They cannot be presented in the same box.

#### Errors Are Split by Source and Recoverability

The classification rests on two questions: where did the error come from, and can the
user fix it?

- **Input error, recoverable.** The value the user gave was not accepted. The path to
  recovery is correcting the value. The message stands next to the field and says what
  is expected.
- **State error, recoverable.** The operation cannot proceed because the system's
  state does not allow it — the record is with someone else, the limit is full. The
  path to recovery is a different action: placing a reservation, waiting for the due
  date. The message stands next to the action and shows the alternative.
- **Transient system error, recoverable.** The request did not arrive, or it timed
  out. The path to recovery is trying again. The message stands next to the operation
  and includes a retry path.
- **Permanent system error, unrecoverable.** There is nothing the user can do. The
  message states the situation and points to a different path; it does not say "try
  again," because trying again will not help.

The practical value of this distinction is that it determines the content of the
message. In a recoverable error, the message has to carry the **path to recovery**;
without it, the message is not an error message to the user, only a notice of an
obstacle. In an unrecoverable error, suggesting a path to recovery is misleading.

A warning is separated from an error on this same axis: with a warning, the operation
can still proceed. "This record is at another branch" is a warning, because the user
can still place a reservation. "Your borrowing limit is full" is an error, because the
operation cannot proceed. Color choice follows this distinction, not the other way
around.

#### The Moment of Validation Determines the Message Count

The most important decision for input errors is **when** validation runs. The same
validation rule produces a different number of messages depending on when it runs.

```js
// validation.mjs — the effect of validation timing on the number of error messages produced

// ISBN-13 checksum: the first 12 digits are summed with weights 1,3,1,3...; the check digit completes it.
function isbnValid(string) {
  if (!/^\d{13}$/.test(string)) return false;
  const b = [...string].map(Number);
  let sum = 0;
  for (let i = 0; i < 12; i++) sum += b[i] * (i % 2 === 0 ? 1 : 3);
  return (10 - (sum % 10)) % 10 === b[12];
}

// Is an input's invalidity certain before it is complete?
function certainlyInvalid(prefix) {
  return /[^0-9]/.test(prefix) || prefix.length > 13;
}

const SESSIONS = [
  { name: "correct typing",   target: "9789750718533" },
  { name: "wrong last digit", target: "9789750718534" },
  { name: "letter mixed in",  target: "97897507I8533" },
];

for (const o of SESSIONS) {
  const prefixes = Array.from({ length: o.target.length }, (_, i) => o.target.slice(0, i + 1));

  // 1) Validate on every keystroke
  const everyKeystroke = prefixes.filter((p) => !isbnValid(p)).length;
  // 2) Warn only on certain invalidity, wait otherwise
  const early = prefixes.filter((p) => certainlyInvalid(p)).length;
  // 3) Validate on leaving the field
  const onExit = isbnValid(o.target) ? 0 : 1;
  // 4) Validate on submit
  const onSubmit = onExit;

  console.log(
    `${o.name.padEnd(20)} every keystroke: ${String(everyKeystroke).padStart(2)}   on certain invalidity: ${String(early).padStart(2)}   ` +
      `on field exit: ${onExit}   on submit: ${onSubmit}   result: ${isbnValid(o.target) ? "valid" : "invalid"}`
  );
}

// At which character is certain invalidity detected?
console.log("\nsession              first certain-invalid prefix length  total length");
for (const o of SESSIONS) {
  const prefixes = Array.from({ length: o.target.length }, (_, i) => o.target.slice(0, i + 1));
  const i = prefixes.findIndex(certainlyInvalid);
  console.log(
    `${o.name.padEnd(20)} ${(i === -1 ? "none" : String(i + 1)).padStart(33)} ${String(o.target.length).padStart(15)}`
  );
}

// Are the catalog records' ISBN fields actually valid?
console.log("\nrecord isbn      valid");
for (const isbn of ["9789750718533", "9789944882101", "9786055829476"]) {
  console.log(`${isbn}   ${isbnValid(isbn)}`);
}
```

```
correct typing       every keystroke: 12   on certain invalidity:  0   on field exit: 0   on submit: 0   result: valid
wrong last digit     every keystroke: 13   on certain invalidity:  0   on field exit: 1   on submit: 1   result: invalid
letter mixed in      every keystroke: 13   on certain invalidity:  5   on field exit: 1   on submit: 1   result: invalid

session              first certain-invalid prefix length  total length
correct typing                                    none              13
wrong last digit                                  none              13
letter mixed in                                      9              13

record isbn      valid
9789750718533   true
9789944882101   true
9786055829476   true
```

The first row settles the decision on its own. The user typed the ISBN **correctly**,
and an interface that validates on every keystroke showed them twelve error messages.
None of them was wrong: every intermediate step really was an invalid ISBN. But none
of them was right either, because the user had not finished typing yet.

The rule that follows is: **an input is not validated before it is complete unless its
invalidity is certain.** Validating on field exit produces zero messages for correct
typing and one for incorrect typing; this is the behavior wanted.

The third row shows the exception to the rule. In the session with a letter mixed in,
the invalidity becomes certain at the ninth character; no character typed after that
can make the value valid. Withholding a certain invalidity until completion means
making the user type four more characters for nothing. The rule is therefore two-part:
uncertain invalidity waits, certain invalidity is reported immediately.

This distinction also shapes the form of the validation rule. A field's rule must be
able to answer not only "is this valid" but also "can this prefix still extend into a
valid value."

#### The Best Error Message Is the One Never Shown

Some errors are not errors; they are formatting noise in the input, and the interface
can correct them silently.

```js
// recovery.mjs — filtering out preventable errors and placing the message

function isbnValid(string) {
  if (!/^\d{13}$/.test(string)) return false;
  const b = [...string].map(Number);
  let sum = 0;
  for (let i = 0; i < 12; i++) sum += b[i] * (i % 2 === 0 ? 1 : 3);
  return (10 - (sum % 10)) % 10 === b[12];
}
// Clean up formatting noise: spaces, hyphens, en dashes, separator dots
const normalize = (s) => s.replace(/[\s\-‐-―.]/g, "");

const INPUTS = [
  "9789750718533",
  "978-975-07-1853-3",
  " 9789944882101 ",
  "978 605 5829 47 6",
  "9789750718534",
  "97897507I8533",
];

console.log("input                    raw valid  normalized               valid  category");
let preventable = 0, real = 0;
for (const g of INPUTS) {
  const n = normalize(g);
  const raw = isbnValid(g), final = isbnValid(n);
  let category;
  if (raw) category = "clean";
  else if (final) { category = "PREVENTABLE"; preventable++; }
  else { category = "real error"; real++; }
  console.log(
    `${JSON.stringify(g).padEnd(24)} ${String(raw).padStart(9)}  ${n.padEnd(24)} ${String(final).padStart(5)}  ${category}`
  );
}
console.log(`\npreventable errors: ${preventable} / ${INPUTS.length}   real errors: ${real} / ${INPUTS.length}`);

// Message placement: proximity rule (within-group gap < between-group gap)
const LABEL_Y = 20, FIELD_Y = 44, MESSAGE_Y = 18;
const WITHIN_GROUP = 4;   // label-to-field and field-to-message gap
const BETWEEN_GROUPS = 24; // gap between two field groups

const fields = ["Title", "Author", "ISBN", "Shelf Code"];
let y = 0;
const position = {};
for (const a of fields) {
  position[a] = { label: y, field: y + LABEL_Y + WITHIN_GROUP };
  y = position[a].field + FIELD_Y + BETWEEN_GROUPS;
}
console.log("\nfield      label y  field y  field bottom edge");
for (const a of fields) {
  console.log(`${a.padEnd(10)} ${String(position[a].label).padStart(8)} ${String(position[a].field).padStart(7)} ${String(position[a].field + FIELD_Y).padStart(16)}`);
}

const errorField = "ISBN";
const bottomEdge = position[errorField].field + FIELD_Y;
const nextLabel = position["Shelf Code"].label;
console.log(`\nif the message is placed below the field:`);
console.log(`  distance to message (from its own field): ${WITHIN_GROUP} px`);
console.log(`  message bottom edge: ${bottomEdge + WITHIN_GROUP + MESSAGE_Y} px`);
console.log(`  distance to next field's label: ${nextLabel - (bottomEdge + WITHIN_GROUP + MESSAGE_Y)} px`);
console.log(`  proximity rule (closer to its own field): ${WITHIN_GROUP < nextLabel - (bottomEdge + WITHIN_GROUP + MESSAGE_Y)}`);

console.log(`\nif the message is placed as a summary above the form:`);
console.log(`  vertical distance from summary to error field: ${position[errorField].field} px`);
console.log(`  fields in between: ${fields.indexOf(errorField)}`);

// If the proximity rule is not met: what must the between-group gap be at minimum?
const FACTOR = 3; // the message must be at least this many times closer to its own field than to the next group
const requiredGap = WITHIN_GROUP + MESSAGE_Y + FACTOR * WITHIN_GROUP;
console.log(`\nspace occupied below the field including the message block: ${WITHIN_GROUP + MESSAGE_Y} px`);
console.log(`minimum gap between groups: ${requiredGap} px  (current: ${BETWEEN_GROUPS} px)`);
console.log(`distance to next label at the new gap: ${requiredGap - WITHIN_GROUP - MESSAGE_Y} px, to its own field ${WITHIN_GROUP} px, ratio ${(requiredGap - WITHIN_GROUP - MESSAGE_Y) / WITHIN_GROUP}`);

// Do the following fields shift when the message appears? If space is reserved, they do not shift.
const shift = WITHIN_GROUP + MESSAGE_Y;
console.log(`\nif no space is reserved for the message, every following field shifts down by ${shift} px`);
console.log(`total shifting fields in a four-field form (ISBN in error): ${fields.length - 1 - fields.indexOf("ISBN")}`);
console.log(`if space is reserved, shift: 0 px, cost: a constant ${shift} px of empty space below every field`);
```

```
input                    raw valid  normalized               valid  category
"9789750718533"               true  9789750718533             true  clean
"978-975-07-1853-3"          false  9789750718533             true  PREVENTABLE
" 9789944882101 "            false  9789944882101             true  PREVENTABLE
"978 605 5829 47 6"          false  9786055829476             true  PREVENTABLE
"9789750718534"              false  9789750718534            false  real error
"97897507I8533"              false  97897507I8533            false  real error

preventable errors: 3 / 6   real errors: 2 / 6

field      label y  field y  field bottom edge
Title             0      24               68
Author           92     116              160
ISBN            184     208              252
Shelf Code      276     300              344

if the message is placed below the field:
  distance to message (from its own field): 4 px
  message bottom edge: 274 px
  distance to next field's label: 2 px
  proximity rule (closer to its own field): false

if the message is placed as a summary above the form:
  vertical distance from summary to error field: 208 px
  fields in between: 2

space occupied below the field including the message block: 22 px
minimum gap between groups: 34 px  (current: 24 px)
distance to next label at the new gap: 12 px, to its own field 4 px, ratio 3

if no space is reserved for the message, every following field shifts down by 22 px
total shifting fields in a four-field form (ISBN in error): 1
if space is reserved, shift: 0 px, cost: a constant 22 px of empty space below every field
```

Three of the six inputs look invalid only because they carry separators. Once the
separators are cleaned up, all three are valid. Showing an error message for these
three inputs does not give the user a problem they can solve; it rejects input that
was already correct.

The rule reads as follows: **no error is shown for an input the interface can
accept.** Separators, leading and trailing spaces, and case differences fall into this
class. The limit of silent correction is that it must not change meaning; correcting a
digit cannot be done silently, because which digit is wrong is not known.

#### The Message's Position Is Checked by the Proximity Rule

The second group of tables measures where the message should go and finds a flaw in
the form layout.

When the message is placed 4 pixels below the field, its bottom edge lands at 274
pixels, and the next field's label starts at 276 pixels. The gap between them is 2
pixels. The rule established in the Proximity and Grouping lesson is violated here: the
message is 4 pixels from the field it belongs to and 2 pixels from the field it does
not. Read on screen, the message appears to belong to the next field.

The correction can be computed. The message block occupies 22 pixels below the field;
requiring the message to sit at least three times closer to its own field pushes the
between-group gap up to 34 pixels. At the new gap, the message sits 4 pixels from its
own field and 12 pixels from the next label.

A summary above the form does not satisfy the proximity rule at all: there are 208
pixels and two fields between the summary and the field in error. This does not mean
the summary is wrong; the summary does a different job — it reports how many errors
there are and gives a link to each. But the summary does **not take the place** of the
message next to the field. The two are used together: the summary at the top of the
page, the message next to the field.

The final block shows a side effect. When the message appears, the fields below it
shift down 22 pixels; this is the small-scale version of the layout shift problem from
the previous lesson. While the user reads the message, the field they meant to click
moves. The solution is to reserve space for the message from the start; the cost is a
constant 22 pixels of empty space below every field. This cost is paid, because in a
shifting form, clicking the wrong field produces a new error.

#### Summary

- Errors are split by source and recoverability; the class determines the message's
  content and position.
- In a recoverable error, the message has to carry the path to recovery; in an
  unrecoverable error, suggesting a path to recovery is misleading.
- A warning and an error are split by whether the operation can still proceed; color
  choice follows this distinction.
- An incomplete input is not validated until its invalidity is certain; validating on
  every keystroke produces dozens of messages even for correct typing. Once invalidity
  becomes certain, it is reported immediately; the validation rule must be able to
  answer "can this prefix still extend into a valid value."
- No error is shown for an input the interface can accept; formatting noise is
  corrected silently, and no correction that changes meaning is made.
- The message's position is checked by the proximity rule, and space for the message
  is reserved from the start.

#### Next Step

Using an icon next to an error message is a common decision, and this lesson did not
justify that decision. The Functional Colors lesson counted the icon as the second
channel accompanying color, but the question of when the icon itself carries meaning
and when it merely takes up space stayed open. The next lesson splits icons into
semantic and decorative, shows what the distinction means in the accessibility tree,
and discusses whether an icon can take the place of text on its own.

### Icon Usage

Source: https://academia.sh/en/courses/interface-fundamentals/icon-usage

The distinction between semantic and decorative icons, computing an accessible name with a small model, checking an icon set for polysemy, and consistency between scale and stroke thickness.

The previous lesson left the icon placed next to an error message unjustified. The
Functional Colors lesson counted the icon as the second channel accompanying color, but
the question of when the icon itself carries information and when it merely takes up
space was never asked.

This lesson treats the icon through a single distinction: does the icon carry
information, or does it visually reinforce the information the neighboring text
already carries? The distinction is not aesthetic; it produces different outcomes in
markup, and an icon that falls on the wrong side either loses information entirely or
has the same information read out twice.

#### Two Classes, Two Different Outcomes

A **semantic icon** communicates a piece of information or an action on its own. There
is no text next to it. In the catalog interface, the magnifying glass at the end of the
search field and the remove icon at the end of a record row fall into this class.
These icons have to have a **name**; an icon button with no name is a button that does
not exist for the keyboard or a screen reader.

A **decorative icon** visually repeats what the neighboring text already communicates.
The exclamation icon at the start of an error message falls into this class; the
message already says "the ISBN number could not be validated." These icons should not
have a name; if they do, the same information is announced twice.

The distinction is tested with a simple question: **if the icon is removed, does
information disappear?** If it does, the icon is semantic; if it does not, the icon is
decorative. The same icon can fall into two different classes in two different places;
the magnifying glass carries meaning when it stands alone, and is decorative inside a
button labeled "Search."

#### The Accessible Name Can Be Computed

An element's accessible name is derived by following a defined order. In short, the
order is: an `aria-labelledby` reference, then the `aria-label` value, then text
derived from the element's content, and last the `title` attribute. This order can be
brought to life with a model, and icon setups can be checked one by one.

```js
// accessible-name.mjs — computing the accessible name of icon-bearing buttons

// A small tree model: each node carries a tag, an attribute set, text, and children.
const node = (tag, attributes = {}, children = [], text = "") => ({ tag, attributes, children, text });

// Tags that can derive a name from their content
const NAME_FROM_CONTENT = new Set(["button", "a", "th", "label", "legend", "summary"]);

function collectText(d, ref) {
  if (d.attributes["aria-hidden"] === "true") return "";
  if (d.tag === "svg") {
    if (d.attributes.role === "img") {
      const title = d.children.find((c) => c.tag === "title");
      return title ? title.text : "";
    }
    return "";
  }
  if (d.tag === "img") return d.attributes.alt ?? "";
  const parts = [d.text, ...d.children.map((c) => collectText(c, ref))];
  return parts.filter(Boolean).join(" ").replace(/\s+/g, " ").trim();
}

function accessibleName(d, registry = {}) {
  // 1) aria-labelledby
  if (d.attributes["aria-labelledby"]) {
    const names = d.attributes["aria-labelledby"]
      .split(/\s+/)
      .map((id) => (registry[id] ? collectText(registry[id], registry) : ""))
      .filter(Boolean);
    if (names.length) return { name: names.join(" "), source: "aria-labelledby" };
  }
  // 2) aria-label
  if (d.attributes["aria-label"] && d.attributes["aria-label"].trim()) {
    return { name: d.attributes["aria-label"].trim(), source: "aria-label" };
  }
  // 3) name from content
  if (NAME_FROM_CONTENT.has(d.tag)) {
    const m = collectText(d, registry);
    if (m) return { name: m, source: "content" };
  }
  // 4) title attribute (last resort)
  if (d.attributes.title && d.attributes.title.trim()) {
    return { name: d.attributes.title.trim(), source: "title (last resort)" };
  }
  return { name: "", source: "none" };
}

const icon = (attributes = {}, children = []) => node("svg", attributes, children);
const titleNode = (text) => node("title", {}, [], text);

const EXAMPLES = [
  ["bare icon", node("button", {}, [icon()])],
  ["aria-label + hidden icon", node("button", { "aria-label": "Borrow" }, [icon({ "aria-hidden": "true" })])],
  ["role=img + title", node("button", {}, [icon({ role: "img" }, [titleNode("Borrow")])])],
  ["hidden icon + text", node("button", {}, [icon({ "aria-hidden": "true" }), node("span", {}, [], "Borrow")])],
  ["named icon + text", node("button", {}, [icon({ role: "img" }, [titleNode("Borrow")]), node("span", {}, [], "Borrow")])],
  ["title only", node("button", { title: "Borrow" }, [icon()])],
];

console.log("setup                          accessible name        source                result");
for (const [name, tree] of EXAMPLES) {
  const s = accessibleName(tree);
  let result;
  if (!s.name) result = "UNNAMED";
  else if (/^(.+) \1$/.test(s.name)) result = "DUPLICATED";
  else if (s.source.startsWith("title")) result = "weak";
  else result = "valid";
  console.log(`${name.padEnd(29)} ${JSON.stringify(s.name).padEnd(22)} ${s.source.padEnd(21)} ${result}`);
}
```

```
setup                          accessible name        source                result
bare icon                     ""                     none                  UNNAMED
aria-label + hidden icon      "Borrow"               aria-label            valid
role=img + title              "Borrow"               content               valid
hidden icon + text            "Borrow"               content               valid
named icon + text             "Borrow Borrow"        content               DUPLICATED
title only                    "Borrow"               title (last resort)   weak
```

The first row is the most common mistake: the icon is placed inside the button, and
nothing else is written. The computed name is empty; the button is visible on screen
but has no name. A keyboard user who focuses the button cannot tell what it does.

The fifth row is the mistake in the opposite direction. The icon is both named and has
text next to it; the name computes to "Borrow Borrow." Naming a semantic icon in a
place where it should be decorative produces this result.

The sixth row works, but weakly. The `title` attribute is the last link in the naming
chain, and it is only visible while the pointer rests on it; it never appears with
touch input and is not reliable with keyboard access. Giving the name through `title`
sits somewhere between giving a name and giving none.

The second, third, and fourth rows are valid setups, and the choice among them depends
on the situation. If the icon stands alone, use the second or third; if the icon has
text next to it, use the fourth — the icon is hidden and the name comes from the text.

#### An Icon Set Must Be Internally Consistent

Even when individual icons are marked up correctly, a consistency problem can remain at
the level of the set: the same shape carrying two different meanings, or two close
meanings being drawn with the same shape.

```js
// icon-set.mjs — icon set consistency check and scale/stroke-thickness math

// Meaning-to-shape mapping of icons used in the catalog interface
const SET = [
  { meaning: "search",       shape: "magnifying-glass" },
  { meaning: "filter",       shape: "funnel" },
  { meaning: "borrow",       shape: "down-arrow-box" },
  { meaning: "download",     shape: "down-arrow-box" },
  { meaning: "remove",       shape: "trash-can" },
  { meaning: "delete",       shape: "x-mark" },
  { meaning: "close",        shape: "x-mark" },
  { meaning: "add to list",  shape: "plus" },
  { meaning: "new record",   shape: "plus-circle" },
  { meaning: "error",        shape: "exclamation-circle" },
  { meaning: "warning",      shape: "exclamation-triangle" },
  { meaning: "info",         shape: "i-circle" },
  { meaning: "success",      shape: "check-circle" },
];

// 1) Does the same shape carry more than one meaning?
const byShape = {};
for (const s of SET) (byShape[s.shape] ??= []).push(s.meaning);
console.log("shape                meanings                        status");
for (const [b, meanings] of Object.entries(byShape)) {
  console.log(
    `${b.padEnd(20)} ${meanings.join(", ").padEnd(31)} ${meanings.length > 1 ? "POLYSEMOUS" : "singular"}`
  );
}

// 2) Are close meanings drawn with different shapes?
const CLOSE_PAIRS = [["remove", "delete"], ["borrow", "download"], ["add to list", "new record"]];
console.log("\nclose-meaning pair          shapes                           status");
for (const [a, b] of CLOSE_PAIRS) {
  const ba = SET.find((s) => s.meaning === a).shape;
  const bb = SET.find((s) => s.meaning === b).shape;
  console.log(
    `${(a + " / " + b).padEnd(27)} ${(ba + " / " + bb).padEnd(32)} ${ba === bb ? "NOT SEPARATED" : "separated"}`
  );
}

// 3) Scale: as the box grows, does proportionally scaling stroke thickness preserve visual weight?
const BASE_BOX = 24, BASE_STROKE = 1.5;
console.log("\nbox   proportional stroke  rounded  stroke/box  deviation from base ratio");
for (const box of [14, 16, 18, 20, 24, 28, 32]) {
  const proportional = (BASE_STROKE * box) / BASE_BOX;
  const rounded = Math.round(proportional * 2) / 2; // round to half-pixel steps
  const ratio = rounded / box;
  const base = BASE_STROKE / BASE_BOX;
  console.log(
    `${String(box).padStart(4)} ${proportional.toFixed(3).padStart(19)} ${rounded.toFixed(2).padStart(8)} ${ratio.toFixed(4).padStart(11)} ${((ratio / base - 1) * 100).toFixed(2).padStart(25)}%`
  );
}

// 4) Icon button touch target: 24 px icon, 44 px target
const ICON = 24, TARGET = 44;
console.log(`\nicon ${ICON} px, touch target ${TARGET} px -> padding per side: ${(TARGET - ICON) / 2} px`);
console.log(`ratio of icon area to target: ${((ICON * ICON) / (TARGET * TARGET) * 100).toFixed(1)}%`);

// 5) Icon stroke separation from the surface: non-text contrast threshold 3.0
function channel(v) { const s = v / 255; return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}
const decode = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
console.log("\nicon color   vs surface    threshold 3.0");
for (const hex of ["#b7bcc2", "#969da6", "#757e8a", "#5e656e"]) {
  const k = contrast(decode(hex), [255, 255, 255]);
  console.log(`${hex}      ${k.toFixed(2).padStart(8)}:1  ${k >= 3 ? "passed" : "FAILED"}`);
}
```

```
shape                meanings                        status
magnifying-glass     search                          singular
funnel               filter                          singular
down-arrow-box       borrow, download                POLYSEMOUS
trash-can            remove                          singular
x-mark               delete, close                   POLYSEMOUS
plus                 add to list                     singular
plus-circle          new record                      singular
exclamation-circle   error                           singular
exclamation-triangle warning                         singular
i-circle             info                            singular
check-circle         success                         singular

close-meaning pair          shapes                           status
remove / delete             trash-can / x-mark               separated
borrow / download           down-arrow-box / down-arrow-box  NOT SEPARATED
add to list / new record    plus / plus-circle               separated

box   proportional stroke  rounded  stroke/box  deviation from base ratio
  14               0.875     1.00      0.0714                     14.29%
  16               1.000     1.00      0.0625                      0.00%
  18               1.125     1.00      0.0556                    -11.11%
  20               1.250     1.50      0.0750                     20.00%
  24               1.500     1.50      0.0625                      0.00%
  28               1.750     2.00      0.0714                     14.29%
  32               2.000     2.00      0.0625                      0.00%

icon 24 px, touch target 44 px -> padding per side: 10 px
ratio of icon area to target: 29.8%

icon color   vs surface    threshold 3.0
#b7bcc2          1.91:1  FAILED
#969da6          2.74:1  FAILED
#757e8a          4.11:1  passed
#5e656e          5.89:1  passed
```

The first table finds two cases of polysemy. The "x-mark" shape carries both the
delete and close meanings; these two are diametrically opposed actions in terms of
reversibility. The second collision is more serious: the "down-arrow-box" shape
represents both borrowing and downloading.

The second table separates out which collision is actually a problem. Remove and
delete are close meanings drawn with different shapes — no problem. Borrow and
download are both close in meaning and identical in shape; the user cannot tell from
the icon which button downloads the record's details and which one borrows the book.

Polysemy is not always a flaw. The x-mark's two meanings are separated by the context
in which they appear: the close x-mark sits at the corner of a panel, the delete
x-mark next to a list item. If context produces the separation, the collision is
acceptable. Borrow and download sit side by side in the same row, so context does not
produce separation there; one of the two shapes has to change.

The third table measures the scale problem. When the stroke thickness is scaled
proportionally with box size and rounded to the half pixel, the stroke-to-box ratio
deviates from the base ratio by 11 to 20 percent. The direction of the deviation is
also irregular: the stroke thins at 18 pixels and thickens at 20 pixels. When two icons
from the same set are placed side by side, one looks heavier than the other.

The solution is the same as the one from the typographic scale: **no intermediate size
is generated.** The icon set is drawn only at the sizes where the deviation is zero —
here, 16, 24, and 32 — and when an in-between size is needed, the nearest step is
chosen, not a scaled value.

The final two blocks give two limits. A twenty-four-pixel icon covers only 29.8
percent of a forty-four-pixel touch target; the rest is invisible padding, a reminder
that the clickable area has to be larger than the icon. The color of the icon's
stroke, in turn, is bound to the non-text contrast threshold: the neutral 300 and 400
steps do not clear the threshold, so the icon can be drawn no lighter than neutral 500.

#### An Icon Does Not Replace Text

A common design choice, made to save space, is to remove text labels and leave only
the icon. This choice delegates a piece of information to a learned convention, and
whether that convention exists varies from icon to icon.

The link between the magnifying glass and search is established. The link between
"down-arrow-box" and borrowing, by contrast, is not established; that link is this
interface's own invention, and the user can only learn it by using it. Using an
unestablished link without text forces the user into trial and error.

The criterion can be stated as: **an icon can be used without text only if the meaning
it carries is established outside this interface too.** If it is not established, text
stands next to the icon; the icon then moves into the decorative class and is hidden.

In two cases the text can be removed. First, when the action is reversible and the
cost of a wrong click is low. Second, when the same action is also offered with text
somewhere else in the interface; the icon is then a shortcut, not the only path.

#### Summary

- Icons split into semantic and decorative; the test is whether information
  disappears when the icon is removed.
- A semantic icon has to have an accessible name; a decorative icon has to be hidden,
  or the same information gets announced twice.
- The accessible name is computed by a defined order; leaving the name to the `title`
  attribute is a weak solution.
- An icon set is checked for polysemy; a collision is acceptable only if context
  produces the separation.
- Stroke thickness does not stay proportional at every size; a set is drawn only at
  the sizes where the deviation is zero, and no intermediate size is generated.
- An icon can be used without text only if the meaning it carries is established
  outside the interface.

#### Next Step

This lesson showed that an icon does not replace text, and handed the discussion over
to text. Interface text — button labels, field names, empty-state sentences, error
messages — has come up in every lesson so far but has never been treated as a design
decision in its own right. The next lesson treats interface text as design itself: it
shows, with computation, how action labels are chosen, which three parts an error
message consists of, and how the length of text constrains the layout.

### Microcopy

Source: https://academia.sh/en/courses/interface-fundamentals/microcopy

Computing interface text as a layout constraint, the width budget of action labels, the prefix collisions produced by title truncation, and the three parts of an error message.

The previous lesson showed that an icon does not replace text, and handed the
discussion over to text. Interface text — button labels, field names, empty-state
sentences, error messages — has appeared in every lesson of this course, but none of
them treated it as a design decision. Text is not a slot to be filled in once the
design is finished; it is one of the layout's hardest constraints.

**Microcopy** is the functional text of an interface: the short pieces that name an
action, report a state, or describe a field. This lesson takes up three questions: how
much space does a label take, what does truncation break, and what parts does an
error message consist of?

#### A Label Is a Width Budget

A button's width derives from its text. Different phrasings that name the same action
produce different widths, and those widths have to fit the action strip.

```js
// labels.mjs — action label width and fitting the action strip

// Candidate family's advance widths (em = 1000 units); the table from the Line Length lesson.
const G = {
  " ": 260, a: 556, b: 574, c: 500, d: 574, e: 545, f: 340, g: 574,
  h: 560, i: 244, j: 244, k: 520, l: 244, m: 856, n: 560,
  o: 570, p: 574, q: 574, r: 366, s: 480, t: 358, u: 560,
  v: 508, w: 800, x: 500, y: 508, z: 470, A: 660, B: 660, C: 686,
  D: 720, E: 610, F: 610, G: 740, H: 730, I: 280, J: 280, K: 660,
  L: 610, M: 890, N: 730, O: 760, P: 610, R: 660, S: 620,
  T: 620, U: 720, V: 660, W: 890, X: 660, Y: 620, Z: 600,
  ",": 260, ".": 260, ":": 260, "-": 340, "'": 200,
};
const DEFAULT = 560;
const SIZE = 16, PADDING = 16, GAP = 8;

const width = (s) => ([...s].reduce((t, c) => t + (G[c] ?? DEFAULT), 0) / 1000) * SIZE;
const buttonWidth = (s) => width(s) + 2 * PADDING;

const CANDIDATES = [
  ["long", ["Borrow", "Add to List", "Create Reservation"]],
  ["short", ["Borrow", "Add to List", "Reserve"]],
  ["very short", ["Take", "Add", "Reserve"]],
];

const STRIP = 360; // width remaining for the record-detail column on a narrow screen

console.log("label set     labels                                        button widths              total  360 px");
for (const [name, labels] of CANDIDATES) {
  const widths = labels.map(buttonWidth);
  const total = widths.reduce((a, b) => a + b, 0) + GAP * (labels.length - 1);
  console.log(
    `${name.padEnd(14)} ${labels.join(" / ").padEnd(44)} ${widths.map((v) => v.toFixed(0)).join(" ").padStart(22)} ${total.toFixed(0).padStart(7)}  ${total <= STRIP ? "fits" : "OVERFLOWS"}`
  );
}

// Width difference between different phrasings of the same action
console.log("\naction label           text width  button width");
for (const e of ["Borrow", "Please Borrow", "Borrow This Item", "Create Reservation", "Reserve"]) {
  console.log(`${e.padEnd(22)} ${width(e).toFixed(1).padStart(11)} ${buttonWidth(e).toFixed(1).padStart(13)}`);
}

// If equal-width buttons are used: the widest label determines everyone
const set = CANDIDATES[0][1];
const widest = Math.max(...set.map(buttonWidth));
const equalTotal = widest * set.length + GAP * (set.length - 1);
console.log(`\nif equal-width buttons are chosen: each button ${widest.toFixed(0)} px, total ${equalTotal.toFixed(0)} px`);
console.log(`variable-width total: ${(set.map(buttonWidth).reduce((a, b) => a + b, 0) + GAP * 2).toFixed(0)} px`);
console.log(`cost of switching to equal width: ${(equalTotal - (set.map(buttonWidth).reduce((a, b) => a + b, 0) + GAP * 2)).toFixed(0)} px`);
```

```
label set     labels                                        button widths              total  360 px
long           Borrow / Add to List / Create Reservation                85 111 171     384  OVERFLOWS
short          Borrow / Add to List / Reserve                            85 111 90     303  fits
very short     Take / Add / Reserve                                       68 61 90     235  fits

action label           text width  button width
Borrow                        53.3          85.3
Please Borrow                105.2         137.2
Borrow This Item             124.7         156.7
Create Reservation           139.3         171.3
Reserve                       58.4          90.4

if equal-width buttons are chosen: each button 171 px, total 530 px
variable-width total: 384 px
cost of switching to equal width: 146 px
```

The first table shows that text is a layout constraint. "Create Reservation" alone
produces a 171-pixel button; with three buttons placed side by side, the total rises to
384 pixels and does not fit the 360-pixel strip. The same three actions come down to
303 pixels with "Reserve" instead, and fit.

The conclusion that follows is that shortening is not a matter of style but a
measurement decision. The information lost in shortening is also accounted for:
"Create Reservation" and "Reserve" name the same action, but "Take" does not — the
information about what is being taken is lost.

The second table measures how far a courtesy word or the object of the action can
inflate a label. "Borrow" measures 53.3 pixels; the courteous form "Please Borrow"
comes to 105.2 pixels; the object-qualified form "Borrow This Item" comes to 124.7
pixels. Adding a word and naming the object can more than double the width. Action
labels take the short imperative form; this is not a matter of tone but a budget
decision of roughly seventy pixels.

The third block gives the cost of a layout habit. When equal-width buttons are chosen,
the widest label determines every button, and the total rises to 530 pixels — 1.4
times the variable-width total. This 146-pixel difference cannot be absorbed on a
narrow screen. Equal width is chosen only when the labels are already close in length
and the strip has room to spare.

#### Truncation Can Destroy Distinctiveness

Record titles in the result list are truncated when they do not fit the column.
Truncation's visible cost is that part of the information disappears; its invisible
cost is that two different records become **identical** on screen.

```js
// truncation.mjs — title truncation, prefix collisions, and the three parts of an error message

const G = {
  " ": 260, a: 556, b: 574, c: 500, d: 574, e: 545, f: 340, g: 574,
  h: 560, i: 244, j: 244, k: 520, l: 244, m: 856, n: 560,
  o: 570, p: 574, q: 574, r: 366, s: 480, t: 358, u: 560,
  v: 508, w: 800, x: 500, y: 508, z: 470, A: 660, B: 660, C: 686,
  D: 720, E: 610, F: 610, G: 740, H: 730, I: 280, J: 280, K: 660,
  L: 610, M: 890, N: 730, O: 760, P: 610, R: 660, S: 620,
  T: 620, U: 720, V: 660, W: 890, X: 660, Y: 620, Z: 600,
  ",": 260, ".": 260, ":": 260, "-": 340, "'": 200, "…": 800,
};
const DEFAULT = 560;
const width = (s, px) => ([...s].reduce((t, c) => t + (G[c] ?? DEFAULT), 0) / 1000) * px;

function truncate(s, px, limit) {
  if (width(s, px) <= limit) return s;
  const ellipsis = width("…", px);
  let result = "";
  for (const c of s) {
    if (width(result + c, px) + ellipsis > limit) break;
    result += c;
  }
  return result + "…";
}

const TITLES = [
  "Light and Shadow",
  "Studies in the History of the Republic of Turkey Volume I",
  "Studies in the History of the Republic of Turkey Volume II",
  "Protocol Reference Manual",
];
const SIZE = 20; // record title size, scale step 1

console.log(`record title size: ${SIZE} px`);
for (const col of [180, 260, 340, 520]) {
  const truncated = TITLES.map((t) => truncate(t, SIZE, col));
  const unique = new Set(truncated);
  console.log(`\ncolumn ${col} px  (unique titles: ${unique.size} / ${TITLES.length})`);
  for (let i = 0; i < TITLES.length; i++) {
    const k = truncated[i];
    const colliding = truncated.filter((x) => x === k).length > 1;
    console.log(`  ${JSON.stringify(k).padEnd(52)} ${width(k, SIZE).toFixed(0).padStart(4)} px  ${colliding ? "COLLIDES" : k === TITLES[i] ? "full" : "truncated"}`);
  }
}

// The three parts of an error message: what happened, why, what to do
const PARTS = [
  { name: "what happened", text: "The ISBN number could not be validated" },
  { name: "why", text: "The last digit does not match the check digit" },
  { name: "what to do", text: "Retype the number from the back cover of the book" },
];
const MESSAGE_SIZE = 13, LINE_LIMIT = 525;
console.log("\npart           text width (13 px)  fits one line (525 px)");
let total = 0;
for (const p of PARTS) {
  const g = width(p.text, MESSAGE_SIZE);
  total += g;
  console.log(`${p.name.padEnd(14)} ${g.toFixed(1).padStart(19)}  ${g <= LINE_LIMIT ? "yes" : "no"}`);
}
const all = PARTS.map((p) => p.text).join(". ");
console.log(`all three combined: ${width(all, MESSAGE_SIZE).toFixed(1)} px, line count: ${Math.ceil(width(all, MESSAGE_SIZE) / LINE_LIMIT)}`);
```

```
record title size: 20 px

column 180 px  (unique titles: 3 / 4)
  "Light and Shadow"                                    165 px  full
  "Studies in the His…"                                 174 px  COLLIDES
  "Studies in the His…"                                 174 px  COLLIDES
  "Protocol Referenc…"                                  178 px  truncated

column 260 px  (unique titles: 3 / 4)
  "Light and Shadow"                                    165 px  full
  "Studies in the History of th…"                       257 px  COLLIDES
  "Studies in the History of th…"                       257 px  COLLIDES
  "Protocol Reference Manual"                           246 px  full

column 340 px  (unique titles: 3 / 4)
  "Light and Shadow"                                    165 px  full
  "Studies in the History of the Republ…"               336 px  COLLIDES
  "Studies in the History of the Republ…"               336 px  COLLIDES
  "Protocol Reference Manual"                           246 px  full

column 520 px  (unique titles: 4 / 4)
  "Light and Shadow"                                    165 px  full
  "Studies in the History of the Republic of Turkey Volume I"  510 px  full
  "Studies in the History of the Republic of Turkey Volume II"  516 px  full
  "Protocol Reference Manual"                           246 px  full

part           text width (13 px)  fits one line (525 px)
what happened                237.3  yes
why                          260.1  yes
what to do                   302.8  yes
all three combined: 813.7 px, line count: 2
```

Both volumes come down to the identical truncated title at all three narrower column
widths. Four records appear on screen as three distinct titles; the user cannot tell
from the list which volume they are borrowing. When the column widens to 520 pixels,
the collision disappears.

The fix for this finding is not to widen the column. Widening is not always possible,
and titles can always get longer still. The fix is to truncate in a way that preserves
the **distinguishing part**: the volume information is separated from the title and
moved to its own metadata field, shown in a field that is never truncated. The
precondition for a truncation decision is knowing which piece of information is
distinguishing.

The second rule is that the full form of the truncated text stays accessible.
Truncation is a presentation decision; the record's accessible name continues to carry
the untruncated title.

#### An Error Message Has Three Parts

The last table measures the structure of an error message. A good message answers
three questions: what happened, why it happened, what to do. Each of the three stays
under the 525-pixel line limit on its own; combined into a single sentence, they rise
to 813.7 pixels and become two lines.

Two lines are not a flaw, but squeezing the parts into a single block of text is. Kept
separate, each part can be placed at a different typographic level: the "what
happened" part is brought forward with weight, while "why" and "what to do" stay at a
secondary text level. A user who recognizes the problem reads only the first part; one
who does not keeps reading.

Not all three parts are always necessary. The "why" part is written when the user
cannot guess the reason on their own; the ISBN's check digit is such a case. The "what
to do" part, by the rule set in the Error and Warning States lesson, is mandatory: if
the error is recoverable, the path to recovery has to be in the message.

#### The Unchanging Rules of Text

The microcopy decisions accumulated along the course's through-line come together in a
few rules.

**The action label names the action.** Generic words like "OK" and "Submit" do not
tell the user, in a confirmation dialog, what they are confirming. The pair "Borrow"
and "Cancel" lets the user read what each button does straight from the text itself.
This is also the only setup in which the user can decide without reading the
surrounding message.

**A label is written in the user's language, not the interface's.** "Create Record" is
a database operation; "Add Book" is the task the user is doing. The catalog
interface's vocabulary is the library's vocabulary.

**The same thing is called by the same name everywhere.** If one place says
"borrowing," another says "checkout," and a third says "loan," the user assumes there
are three separate operations. The rule set for visuals in the Repetition and
Consistency lesson applies to text as well.

**Placeholder text is not a label.** The Contrast and Accessibility lesson computed
this: placeholder text cannot do a label's job because it fails to clear the contrast
threshold. A field's name stands above the field; the placeholder only shows a format
example.

#### Summary

- Microcopy is a layout constraint; a label's width is computed and compared against
  the action strip's budget.
- Adding a courtesy word or naming the object noticeably lengthens a label; the short
  imperative form in action labels is a measurement decision.
- Equal-width buttons lock to the widest label and noticeably increase the total
  width; a narrow strip cannot absorb it.
- Truncation does not just cut information — it can make two records identical on
  screen; the distinguishing information is pulled out of the truncated field, and the
  full form of the truncated text stays accessible.
- An error message consists of what happened, why it happened, and what to do; the
  parts are kept separate and placed at separate typographic levels.
- A label names the action, is written in the user's language, and the same thing is
  called by the same name everywhere.

#### Course Wrap-Up

This course opened with a single claim: every visual decision carries a function, and
it is design if it can be justified. Across twenty-two lessons, the library catalog
interface was where this claim was tested.

Four topics built on one another. **Visual Principles** established how attention is
ordered, how elements are grouped, and how consistency produces learnability.
**Layout and Spacing** set these principles into a skeleton: the grid, the spacing
scale, density decisions, and the constraints screen size and input type bring.
**Typography and Color** made the text and surface that fill the skeleton measurable —
type sizes, the typographic scale, line length, color roles, contrast thresholds, and
the theme transformation. **Component States** turned the static image into an
interface that behaves over time: interaction states, waiting, emptiness, error,
icons, and text.

Three rules recurred throughout the course, resurfacing in every topic. First, **a
critical distinction is expressed on at least two channels**; this rule showed up as
size and weight in hierarchy, as color and text in semantic colors, and as surface
and position in component states. Second, **once a decision is tied to a number,
argument turns into verification**; thresholds did their job because they were
checkable, even when chosen somewhat arbitrarily. Third, **the cheapest fix for a
problem is most often not adding an element but removing one**; reducing the number of
levels, dropping a border, writing the reason instead of disabling a button were all
examples of this rule.

There is something this course did not cover, and the boundary needs to be drawn
there. Every decision made here was the **visual** answer to "what should the
interface do." Which tasks the interface should support, in what order the user
performs those tasks, and when they need which piece of information stayed outside
this course. In the catalog interface, the "search field, result list, record detail,
borrow" flow was given from the start; why it was that flow was never asked.

M15/K02 User Experience and Behavior Design opens with this question. It builds the
chain from user research to design decision: it covers when observation, interview,
and survey methods are chosen, how user profiles grounded in real data are built, and
how information architecture and flow design make task completion easier. What this
course measured was the inside of a screen; what the next course measures will be the
path between screens.

