{
 "version": "2.1.3",
 "scope": "The Finish work shelf: seventeen practices chosen by hand. https://design.inadram.studio/house-standard/finish-work.html",
 "base": "https://design.inadram.studio/house-standard/",
 "tiers": {
  "write": "A machine may emit this edit and verify it moved something. Nothing reaches this tier unless a wrong application is visible and reversible.",
  "propose": "The edit can be generated precisely but a human must read the diff, because correctness depends on intent the tool cannot read.",
  "brief": "Detectable as absent, but the fix depends on code the tool has not seen. Report the location and hand over the prompt.",
  "teach": "No mechanical signal. Explain it and show the defect running."
 },
 "states": {
  "held": "present, and the tool can point at it",
  "missing": "applies here, absent, confusables ruled out",
  "unsure": "applies, and the tool cannot decide — carries blockedBy",
  "n/a": "the precondition is provably absent AND the predicate actually ran",
  "scoped-out": "excluded by narrowing"
 },
 "rules": [
  {
   "slug": "concentric-corner-radii",
   "name": "Concentric corner radii",
   "category": "Layout and stacking",
   "technique": "inner radius = outer radius − padding",
   "benefit": "Nested corners stay parallel, so an inset panel looks cut to fit its card rather than dropped in.",
   "onLabel": "Corner gap holds at diagonals",
   "offLabel": "Corner gap widens at diagonals",
   "prompt": "When a rounded box sits inset inside another, derive the inner radius rather than repeating the outer one: inner radius = outer radius minus the inset on that side. Equal radii leave a gap that widens by about 40% at each diagonal, which reads as a sloppy corner even when nobody can name why. Recompute whenever the inset or the outer radius changes, and clamp at zero so a large inset squares the inner corner off rather than inverting it.",
   "demo": "demo.html?pattern=concentric-corner-radii",
   "defect": "demo.html?pattern=concentric-corner-radii&off=1",
   "ruled": true,
   "tier": "propose",
   "decide": "runtime",
   "precondition": "An element with border-radius > 0 whose direct child also has border-radius > 0, with uniform padding between them, where the child's radius does not equal the parent's radius minus that padding.",
   "evidence": "inner radius equals outer radius minus the measured padding, within a pixel.",
   "absence": "The two radii differ from that relation by more than a pixel, so the gap between the arcs widens at the diagonals.",
   "apply": "Set the inner radius to calc(outer - padding) on the rule that owns the child.",
   "verify": "Measure both radii and the padding after the edit and check the relation holds.",
   "handsOff": false,
   "confusables": [
    "A child that is deliberately a different shape — a pill inside a card, a circular avatar in a rounded tile. The relation does not apply and enforcing it destroys the intent.",
    "Non-uniform padding, where there is no single radius that satisfies the relation on all four corners.",
    "A parent whose radius is larger than its padding plus the child's box, where the computed inner radius goes negative."
   ]
  },
  {
   "slug": "per-axis-radius-correction",
   "name": "Per-axis radius correction",
   "category": "Layout and stacking",
   "technique": "border-radius: calc(r / sx) calc(r / sy), recomputed per axis each frame",
   "benefit": "A panel that grows by scale keeps the same corner as everything around it, mid-animation included.",
   "onLabel": "Corners stay circular",
   "offLabel": "Corners stretch into ellipses",
   "prompt": "When a box is sized by a scale transform rather than by width and height, divide its radius per axis: the horizontal radius by sx, the vertical by sy. A single radius is stretched by the transform along with everything else, so a corner that should read as circular paints as an ellipse in the ratio between the two scales. Recompute on every frame the scale changes. Clamp each axis against half the untransformed length of that axis — half the width for the horizontal radius, half the height for the vertical — and treat that clamp as a bound for scales below one rather than part of the normal path, because a corner clamped on one axis only is no longer circular.",
   "demo": "demo.html?pattern=per-axis-radius-correction",
   "defect": "demo.html?pattern=per-axis-radius-correction&off=1",
   "ruled": true,
   "tier": "teach",
   "decide": "neither",
   "precondition": "An element animated with a non-uniform scale that also carries a border-radius.",
   "evidence": "The radius is divided per axis by the live scale each frame.",
   "absence": "A static radius, which the scale stretches into an ellipse for the length of the animation.",
   "apply": null,
   "verify": null,
   "handsOff": false,
   "confusables": [
    "A uniform scale, where the radius scales correctly and correction would be wrong.",
    "The correction needs the live scale value per frame, which exists only inside the animation's own loop. There is nothing in static source to edit."
   ]
  },
  {
   "slug": "optical-glyph-centring",
   "name": "Optical glyph centring",
   "category": "Text and typography",
   "technique": "Transform offset equal to the shape's area centroid",
   "benefit": "A play button looks centred, and the ink stops sliding sideways each time the icon swaps.",
   "onLabel": "Play mass sits on axis",
   "offLabel": "Play mass sits left of axis",
   "prompt": "Centre a glyph on its visual mass rather than on its bounding box. An asymmetric shape such as a right-pointing triangle carries its area centroid well off the box centre, so a box-centred triangle reads as sitting left of the axis; offset it by the centroid distance, about a sixth of its width for a triangle, so the centroid lands on the axis. Compute the offset per glyph rather than per button: a symmetric glyph's centroid is already its box centre, so it takes no offset. The correctness condition is that every state of a control that swaps icons is placed by its own glyph's centroid, so the ink sits on the same axis in each state and does not jump when the icon changes.",
   "demo": "demo.html?pattern=optical-glyph-centring",
   "defect": "demo.html?pattern=optical-glyph-centring&off=1",
   "ruled": true,
   "tier": "teach",
   "decide": "neither",
   "precondition": "An asymmetric glyph or icon centred by its bounding box inside a round or square control.",
   "evidence": "A transform offset equal to the shape's area centroid.",
   "absence": "The glyph is centred geometrically and reads as sitting off-centre.",
   "apply": null,
   "verify": null,
   "handsOff": false,
   "confusables": [
    "The offset is the area centroid of one glyph in one face at one size. It cannot be derived without rasterising that glyph, and a wrong constant makes the icon visibly worse than doing nothing.",
    "A symmetric glyph, where the centroid and the bounding-box centre coincide and any offset is damage."
   ]
  },
  {
   "slug": "thumb-clipped-label-inversion",
   "name": "Thumb-clipped label inversion",
   "category": "Layout and stacking",
   "technique": "A duplicate label layer clipped with clip-path driven by the thumb's own position value",
   "benefit": "Every label stays legible against whatever sits behind it for the full 900ms of travel.",
   "onLabel": "Label turns white under thumb",
   "offLabel": "Label turns white before thumb",
   "prompt": "When a sliding thumb carries the selected-state colour, do not swap a colour class on the label when the selection changes. Draw the label row once in the resting colour, overlay a pixel-identical copy in the selected colour, and clip that copy to the thumb's rect using the same position value that places the thumb, so each letter inverts as the edge passes over it. A class swap recolours the whole word the moment the selection changes, which leaves the incoming label light-on-light and the outgoing one dark-on-dark for the entire length of the thumb's travel.",
   "demo": "demo.html?pattern=thumb-clipped-label-inversion",
   "defect": "demo.html?pattern=thumb-clipped-label-inversion&off=1",
   "ruled": true,
   "tier": "teach",
   "decide": "neither",
   "precondition": "A slider or progress control with a label drawn across the track that the thumb passes over.",
   "evidence": "A duplicate inverted label layer clipped to the thumb's own position value.",
   "absence": "The whole label flips at a midpoint, or does not invert at all.",
   "apply": null,
   "verify": null,
   "handsOff": false,
   "confusables": [
    "Needs a hand-authored duplicate layer and a clip-path bound to the control's value. There is no edit to emit — it is a component rewrite.",
    "A label that never overlaps the thumb — one sitting above or beside the track — where there is nothing to invert and the duplicate layer is pure weight."
   ]
  },
  {
   "slug": "rubber-band-overscroll",
   "name": "Rubber-band overscroll",
   "category": "Scroll and overflow",
   "technique": "Fractional resistance past the boundary plus a spring return",
   "benefit": "A flick that runs out of list feels like the end of the list rather than a dropped gesture.",
   "onLabel": "List gives at edge, springs back",
   "offLabel": "List stops dead at edge",
   "prompt": "At a scroll boundary, let movement continue with diminishing returns rather than stopping abruptly: apply a fraction of the overscroll delta, around a third to a half, and shrink that fraction further as the distance past the edge grows so a long gesture bounds itself instead of dragging the content away. Spring back to the edge when input ends — touchend for a drag, and a short idle timeout for a wheel, which has no end event of its own. The resistance is the signal that the end has been reached, so a dead stop reads as a broken gesture. Use overscroll-behavior to stop the gesture chaining to the page behind.",
   "demo": "demo.html?pattern=rubber-band-overscroll",
   "defect": "demo.html?pattern=rubber-band-overscroll&off=1",
   "ruled": true,
   "tier": "teach",
   "decide": "neither",
   "precondition": "A custom scroller implemented in the page's own pointer code, not a native scroll container.",
   "evidence": "Travel past the boundary is divided by a resistance factor and released with a spring return.",
   "absence": "Travel is clamped hard at the boundary.",
   "apply": null,
   "verify": null,
   "handsOff": false,
   "confusables": [
    "A native scroll container, where the platform already does this and adding it fights the compositor.",
    "A scroller whose ends are meaningful limits rather than edges — a cropping tool, a timeline with hard bounds."
   ]
  },
  {
   "slug": "grab-point-preservation",
   "name": "Grab-point preservation",
   "category": "Pointer, touch and wheel",
   "technique": "Pointer-to-element offset captured on pointerdown",
   "benefit": "What you grabbed stays under the pointer, so the drop lands where you aimed it.",
   "onLabel": "Card holds still on grab",
   "offLabel": "Card jumps sideways on grab",
   "prompt": "On pointerdown, store the offset between the pointer and the element's own origin, and position the element from that offset for the rest of the drag. Centring the element under the pointer instead makes it jump the moment it is grabbed, and lands the drop that same distance from where it was aimed. Keep the offset across any transform or scroll that happens mid-drag, and drop using the offset you picked up with.",
   "demo": "demo.html?pattern=grab-point-preservation",
   "defect": "demo.html?pattern=grab-point-preservation&off=1",
   "ruled": true,
   "tier": "brief",
   "decide": "source",
   "precondition": "A pointerdown-initiated drag that positions the dragged element from the pointer coordinate.",
   "evidence": "The pointer-to-element offset is captured on pointerdown and subtracted on every move.",
   "absence": "The element is centred on the pointer, so it jumps on grab.",
   "apply": null,
   "verify": null,
   "handsOff": false,
   "confusables": [
    "A drag proxy deliberately centred on the cursor — a drag image, a crosshair tool.",
    "A drag that snaps to a grid on pick-up, where the offset is intentionally discarded."
   ]
  },
  {
   "slug": "shared-element-morph",
   "name": "Shared element morph",
   "category": "Motion and physics",
   "technique": "FLIP from the source rect, or view-transition-name",
   "benefit": "The item carries you into the detail view, so you never have to work out what you opened.",
   "onLabel": "Tile grows into detail panel",
   "offLabel": "Tile vanishes, panel fades in",
   "prompt": "When opening a detail view from a list item, animate the item itself into its new position and size rather than cross-fading between two states. Where the platform supports paired view-transition names, give the source and destination the same name and let the browser run the morph; otherwise measure both rectangles and animate transform and dimensions from the source to the destination yourself, handing off to the real element on completion. Run the same movement in reverse when the view closes, so the detail view returns to the item it came from rather than disappearing in place. Under reduced motion, swap between the two states with no movement at all.",
   "demo": "demo.html?pattern=shared-element-morph",
   "defect": "demo.html?pattern=shared-element-morph&off=1",
   "ruled": true,
   "tier": "brief",
   "decide": "source",
   "precondition": "A navigation or state change where one element visibly represents the same content on both sides — a tile and the detail view it opens.",
   "evidence": "A FLIP measurement from the source rect, or a matching view-transition-name on both elements.",
   "absence": "The source is removed and the destination faded in independently.",
   "apply": null,
   "verify": null,
   "handsOff": false,
   "confusables": [
    "Two views that share no element, where a morph invents a relationship that is not there. The catalogue's fade-through entry is the correct practice for that case.",
    "A view-transition-name that must be unique per transition; applying the same name to a list of items breaks the transition entirely."
   ]
  },
  {
   "slug": "shared-layout-indicator",
   "name": "Shared layout indicator",
   "category": "Motion and physics",
   "technique": "Single marker element transitioning transform and width",
   "benefit": "The eye follows the selection instead of having to find the marker again and re-read it.",
   "onLabel": "Pill travels between tabs",
   "offLabel": "Pill teleports between tabs",
   "prompt": "Render the active indicator for a tab set as one element that moves, not as a per-tab element that appears and disappears. Measure the selected item and transition the marker transform and width to match it, so selection reads as one object travelling rather than two separate states. Disable the transition on first paint so the marker does not fly in from the origin.",
   "demo": "demo.html?pattern=shared-layout-indicator",
   "defect": "demo.html?pattern=shared-layout-indicator&off=1",
   "ruled": true,
   "tier": "brief",
   "decide": "both",
   "precondition": "A set of sibling controls behaving as a single-select group, where the selected one carries a visual marker.",
   "evidence": "One marker element persists across selections and animates transform and width.",
   "absence": "The marker is a per-item pseudo-element or border that appears and disappears, so it teleports between items.",
   "apply": null,
   "verify": null,
   "handsOff": false,
   "confusables": [
    "A multi-select group, where a single travelling marker cannot represent the state.",
    "A group whose items are in separate stacking or scroll contexts, where one marker cannot span them.",
    "A list long enough to virtualise, where the marker's destination may not be mounted."
   ]
  },
  {
   "slug": "damped-motion",
   "name": "Damped spring settling",
   "category": "Motion and physics",
   "technique": "Spring damping ratio at or above about 0.5",
   "benefit": "A panel that is let go looks like it has weight and stops, instead of wobbling until the eye gives up.",
   "onLabel": "Sheet crosses its rest line once",
   "offLabel": "Sheet rings through its rest line",
   "prompt": "Tune every spring token so that a released element settles rather than oscillates. Raise the damping ratio until the response crosses its rest position at most once — roughly 0.5 and upwards for a sheet or a knob — instead of leaving it low enough that the element rings back and forth through the rest line. The correctness condition is measurable rather than a matter of taste: overshoot under about fifteen per cent of the travel, and no visible movement within roughly 400ms of release.",
   "demo": "demo.html?pattern=damped-motion",
   "defect": "demo.html?pattern=damped-motion&off=1",
   "ruled": true,
   "tier": "brief",
   "decide": "source",
   "precondition": "A spring implementation in the page's own code, with a damping ratio or a damping and stiffness pair available as literals.",
   "evidence": "The damping ratio resolves to about 0.5 or above, so the value crosses its rest line once.",
   "absence": "The ratio is below that and the value oscillates around rest.",
   "apply": null,
   "verify": null,
   "handsOff": false,
   "confusables": [
    "A deliberate bounce — a pull-to-refresh, a playful mascot — where underdamping is the intent.",
    "A spring library whose parameters are named differently, where a literal read of stiffness and damping computes the wrong ratio.",
    "A physical simulation rather than a UI transition, where the ratio is dictated by what is being simulated."
   ]
  },
  {
   "slug": "decelerate-in-accelerate-out",
   "name": "Decelerate in, accelerate out",
   "category": "Motion and physics",
   "technique": "Asymmetric easing: ease-out on enter, ease-in on exit",
   "benefit": "An opening panel feels caught rather than delivered, and a closing one lets attention go.",
   "onLabel": "Menu arrives, then settles",
   "offLabel": "Menu creeps, then rushes in",
   "prompt": "Ease entrances and exits in opposite directions rather than reusing one curve for both. Something arriving should cross most of its travel immediately and decelerate into place, so it reads as caught rather than delivered; something leaving should start slowly and accelerate off, so attention is released rather than dragged along. Reach for ease-out on enter and ease-in on exit, and keep the exit shorter than the entrance.",
   "demo": "demo.html?pattern=decelerate-in-accelerate-out",
   "defect": "demo.html?pattern=decelerate-in-accelerate-out&off=1",
   "ruled": true,
   "tier": "propose",
   "decide": "source",
   "precondition": "A transition or animation that runs in both directions between the same two states, with one timing function serving both.",
   "evidence": "Enter uses an ease-out family curve and exit an ease-in family curve, set on the two states rather than shared.",
   "absence": "A single symmetrical curve — ease, ease-in-out, linear — governs both directions.",
   "apply": "Split the timing function: ease-out on the entering state, ease-in on the exiting one.",
   "verify": "Read the computed transition-timing-function in both states and check they differ.",
   "handsOff": false,
   "confusables": [
    "A continuous or looping animation, which has no enter and no exit.",
    "A gesture-driven value still under the pointer, where any easing at all fights the finger.",
    "A two-way toggle where neither direction is arrival — a switch, a checkbox — and asymmetry reads as a glitch."
   ]
  },
  {
   "slug": "interruptible-animation",
   "name": "Interruptible animation",
   "category": "Motion and physics",
   "technique": "Retarget the running animation from its current value",
   "benefit": "A change of mind is answered from where the drawer is, so it settles into what was last asked for.",
   "onLabel": "Drawer turns back at the cancel",
   "offLabel": "Drawer keeps opening after cancel",
   "prompt": "When input arrives mid-animation, retarget from the value the element currently holds instead of queueing a second run or restarting from a preset start. Read the live position and solve the remaining motion from there, so the element decelerates out of that offset and reverses without jumping to either edge. The correctness condition has two halves: it must never travel away from the most recent committed target, and the state it leaves at rest must be the one the last input asked for, which means cancelling the completion handler of the run you interrupted.",
   "demo": "demo.html?pattern=interruptible-animation",
   "defect": "demo.html?pattern=interruptible-animation&off=1",
   "ruled": true,
   "tier": "brief",
   "decide": "source",
   "precondition": "An animation started in response to a state change that can be reversed before it finishes — a drawer, a menu, a sheet, an accordion.",
   "evidence": "The reversal reads the current computed or animated value and retargets from it, rather than starting a fresh animation from the nominal state.",
   "absence": "The reverse path sets the end state and starts a new animation from the start value, so a cancelled gesture finishes opening before it closes.",
   "apply": null,
   "verify": null,
   "handsOff": false,
   "confusables": [
    "A one-shot animation with no reverse — a toast, a confetti burst — where there is nothing to interrupt.",
    "A CSS transition, which already retargets from the current value; flagging it would be a false positive on correct code."
   ]
  },
  {
   "slug": "tabular-numbers",
   "name": "Tabular numbers",
   "category": "Text and typography",
   "technique": "font-variant-numeric: tabular-nums",
   "benefit": "A value can update every second without shoving the text and layout beside it sideways.",
   "onLabel": "Numbers hold still on every tick",
   "offLabel": "Numbers wobble on every tick",
   "prompt": "Apply tabular figures to any number that changes while on screen — counters, timers, durations, currency, table columns and diff counts. Set font-variant-numeric: tabular-nums so every digit occupies the same advance width and the text box stops resizing on each update. Keep proportional figures for static prose, where tabular spacing reads as too loose.",
   "demo": "demo.html?pattern=tabular-numbers",
   "defect": "demo.html?pattern=tabular-numbers&off=1",
   "ruled": true,
   "tier": "propose",
   "decide": "both",
   "precondition": "An element whose text content is rewritten with a different numeric string while it is on screen, and whose computed font-variant-numeric does not already contain tabular-nums.",
   "evidence": "font-variant-numeric on the element or an ancestor resolves to a value containing tabular-nums.",
   "absence": "It resolves to normal, and the element's width changes between two renders whose digit count is equal.",
   "apply": "Add font-variant-numeric: tabular-nums to the rule that owns the element, not inline.",
   "verify": "Render the two numeric states and compare offsetWidth. Equal digit counts must give equal widths.",
   "handsOff": false,
   "confusables": [
    "Body prose containing a figure. Tabular spacing reads as too loose in running text, and the practice's own card says to keep proportional figures there.",
    "A number that is written once and never updated. The declaration is inert and the diff is noise.",
    "A face with no tabular set, where the declaration silently does nothing and the reviewer believes it is fixed."
   ]
  },
  {
   "slug": "optical-alignment",
   "name": "Optical alignment",
   "category": "Text and typography",
   "technique": "hanging-punctuation: first, or a first-line indent of minus the mark's measured width",
   "benefit": "The column reads as one straight edge instead of a first line that looks wrongly indented.",
   "onLabel": "First line sits with the rest",
   "offLabel": "First line sits in from the rest",
   "prompt": "Hang a column's opening punctuation outside the measure instead of counting it as text: set hanging-punctuation: first, or pull the first line back by exactly the mark's advance width. The correctness condition is that the first letter of the first line lands on the same vertical edge as the first letter of every line below it, for every mark the column can open with. A quote, a bracket and an em dash are all different widths, so one hard-coded offset is right for one of them and wrong for the rest; measure the mark that is actually set.",
   "demo": "demo.html?pattern=optical-alignment",
   "defect": "demo.html?pattern=optical-alignment&off=1",
   "ruled": true,
   "tier": "brief",
   "decide": "runtime",
   "precondition": "A text block whose first rendered character is a quotation mark, an opening bracket or a hyphen, sitting in a column with a visible left edge shared with other blocks.",
   "evidence": "hanging-punctuation: first on the block, or a first-line indent equal to minus the mark's measured advance.",
   "absence": "Neither is present, so the mark's whitespace sets the optical edge in from the rest of the column.",
   "apply": null,
   "verify": null,
   "handsOff": false,
   "confusables": [
    "hanging-punctuation is not supported everywhere, so emitting it alone leaves most readers unchanged while the report claims the practice is held.",
    "A block with no shared edge to align against, where there is nothing to be out of line with.",
    "A centred or right-aligned block, where the left mark is not on the alignment edge at all."
   ]
  },
  {
   "slug": "color-scheme",
   "name": "Declared colour scheme",
   "category": "Layout and stacking",
   "technique": "color-scheme: light dark, so UA-painted widgets and system colours resolve against the right scheme",
   "benefit": "Everything the browser paints for itself arrives in your theme instead of fighting it.",
   "onLabel": "Native controls come up dark",
   "offLabel": "Native controls come up white",
   "prompt": "Declare which colour schemes a surface supports rather than only painting your own: set color-scheme: light dark on the root, and again on any subtree whose theme differs from its parent. That declaration is what decides the browser's own output — the native checkbox, select, range and scrollbar, the used value of system colours like FieldText and Canvas, and the background painted between navigations. Author styles alone leave all of it resolving against the inherited scheme, so a hand-built dark panel keeps light controls, black field text on a dark field, and a white flash on every load. Add a meta name=color-scheme alongside the CSS so the very first paint is already right.",
   "demo": "demo.html?pattern=color-scheme",
   "defect": "demo.html?pattern=color-scheme&off=1",
   "ruled": true,
   "tier": "propose",
   "decide": "both",
   "precondition": "A document that paints its own dark surface — a stylesheet setting a dark background on html or body, or a prefers-color-scheme block — and no color-scheme declaration on :root. Or, on a light page, a dark surface that holds native controls whose computed color-scheme has no dark, so they paint light against it.",
   "evidence": "color-scheme on :root naming every scheme the page actually supports, and color-scheme: dark on any dark surface inside a light page, so the controls on it resolve dark.",
   "absence": "No color-scheme reaches the dark surface. UA-painted widgets then resolve light against it: a white field and a light arrow on a dark panel.",
   "apply": "Add color-scheme to :root listing only the schemes the page really supports. For a dark surface inside a light page, declare color-scheme: dark on that surface rather than light dark on :root.",
   "verify": "Read the computed color-scheme on the root, or on the surface, and check a native control on it paints its background from that scheme.",
   "handsOff": false,
   "confusables": [
    "A light-only site. Declaring light dark there is a lie that makes the browser paint a dark scrollbar over a light page.",
    "An embedded frame that inherits its host's scheme, where declaring one overrides the host deliberately.",
    "A light card inside a dark page. The surface a field is painted against is the nearest one behind it, not the page, and declaring dark on the card turns its fields dark on a light card.",
    "A field the author fills light on purpose, like a white search box on a dark hero, with no text colour of its own. Declaring dark on the surface swaps the UA text colour to light on the white fill."
   ]
  },
  {
   "slug": "hit-slop",
   "name": "Hit slop",
   "category": "Pointer, touch and wheel",
   "technique": "::after at a negative inset, sized to the 44px minimum target",
   "benefit": "A tap that lands a few pixels off still reaches the control, and nothing in the row moves for it.",
   "onLabel": "Presses near glyph still land",
   "offLabel": "Presses near glyph fall through",
   "prompt": "Give every small tappable control a hit rect of at least 44px in both axes, however small the glyph it draws. Grow the target with a pseudo-element at a negative inset rather than with padding, so the rect expands without moving the icon or reflowing the row around it. A glyph that is its own hit rect drops every press that lands a few pixels wide, and that press falls through to whatever sits underneath — usually the surface the control was meant to dismiss.",
   "demo": "demo.html?pattern=hit-slop",
   "defect": "demo.html?pattern=hit-slop&off=1",
   "ruled": true,
   "tier": "brief",
   "decide": "runtime",
   "precondition": "An interactive element whose own hit rectangle is under 24x24 CSS px, that is not inline in a sentence, and whose nearest interactive neighbour is far enough away that growing the target cannot overlap it.",
   "evidence": "A pseudo-element or padding on the interactive element itself takes its hit area to at least 44x44 without moving the painted glyph. An overlay on a non-interactive ancestor is not evidence: it does not enlarge the control, it covers it.",
   "absence": "The hit rectangle stays under the minimum in every direction.",
   "apply": null,
   "verify": null,
   "handsOff": false,
   "confusables": [
    "WCAG 2.2 SC 2.5.8 exempts inline targets, targets with an equivalent elsewhere on the page, and targets whose size is essential. A naive sweep flags roughly 76 per cent of interactive elements on a real documentation page and about 40 per cent of those are exempt.",
    "A control already inside a 44px parent target, where the pseudo-element adds nothing and can start intercepting the parent's own presses.",
    "A dense toolbar where growing every target makes neighbours overlap and the wrong button fires.",
    "An ancestor carrying `::after{inset:0}` with nothing bound to it and no tabindex. It reads as hit slop and is a phantom target: it paints over the real control and swallows the press, so the control stops working at all rather than being merely small. SC 2.5.8's enclosing-target exemption needs an enclosing *target* — check that something is actually bound to the ancestor before treating it as one."
   ]
  },
  {
   "slug": "semantic-zoom",
   "name": "Latched semantic zoom",
   "category": "Pointer, touch and wheel",
   "technique": "Hysteresis: separate enter and leave thresholds per item",
   "benefit": "Detail arrives once and stays, so a zoom that wanders near the boundary does not shake the whole view.",
   "onLabel": "Labels switch once and hold",
   "offLabel": "Labels strobe at the threshold",
   "prompt": "When a representation changes with a continuous value such as zoom scale, switch it on two separated thresholds rather than one. Promote to the detailed form above the enter threshold and demote only below a distinctly lower leave threshold, and paint any selection or highlight on the part of the item that survives the change rather than on the row that appears with it. The band is wide enough when the jitter in the incoming value cannot span it, so a single pass through the boundary promotes each item exactly once.",
   "demo": "demo.html?pattern=semantic-zoom",
   "defect": "demo.html?pattern=semantic-zoom&off=1",
   "ruled": true,
   "tier": "brief",
   "decide": "source",
   "precondition": "A zoom or scale value that gates the appearance of detail through a single threshold comparison.",
   "evidence": "Separate enter and leave thresholds, so the state latches.",
   "absence": "One threshold governs both directions, so sitting on it strobes.",
   "apply": null,
   "verify": null,
   "handsOff": false,
   "confusables": [
    "A threshold crossed only programmatically, never by a continuous gesture, where there is nothing to jitter.",
    "A binary control rather than a continuous value."
   ]
  },
  {
   "slug": "momentum",
   "name": "Momentum",
   "category": "Motion and physics",
   "technique": "Velocity estimated from trailing pointer samples, projected to a bounded landing",
   "benefit": "A hard flick travels further than a slow nudge, so a custom rail feels like the platform's own scroller.",
   "onLabel": "Rail carries on past the finger",
   "offLabel": "Rail halts where the finger left",
   "prompt": "When a dragged surface is released, estimate the release velocity from the last few pointer samples and project a bounded landing position from it before settling. Travel after release must scale with that velocity, so a hard flick carries further than a slow nudge covering the same distance. Halting on the frame the pointer lifts, or coasting a fixed distance whatever the speed, both fail the condition.",
   "demo": "demo.html?pattern=momentum",
   "defect": "demo.html?pattern=momentum&off=1",
   "ruled": true,
   "tier": "teach",
   "decide": "neither",
   "precondition": "A pointer-dragged surface in the page's own code that stops dead on release.",
   "evidence": "Release velocity is estimated from trailing samples and projected to a bounded landing.",
   "absence": "The value stops at the last pointer position.",
   "apply": null,
   "verify": null,
   "handsOff": false,
   "confusables": [
    "Nothing in the DOM, the computed style or the listener set distinguishes a rail that should coast from a slider, a split pane or a sortable row that must stop dead. This is the clearest case in the catalogue of a practice whose applicability is pure intent.",
    "A drag whose result is a discrete drop, where coasting past the target is a bug."
   ]
  }
 ]
}
