# Fluid Desk > Read a fluid type scale and see where it stops being a scale. `clamp(min, A*rem + > B*vw, max)` is a straight line in the viewport, so a scale is several lines - and > lines with different slopes CROSS. A smaller step becomes larger than a bigger one > over a band that is bounded on both sides, so the breakpoints either side of it both > pass. The ratio between two fluid steps is constant only if BOTH terms are > proportional. And a `vw` term answers none of a reader's font-size request. Free > browser-side arithmetic, five paid lanes. Live at https://fluid-desk.skillsafe.ai/ · API at https://fluid-desk.skillsafe.ai/api.html Derived from the `responsive-design` and `visual-design-foundations` skills in `wshobson/agents` (https://github.com/wshobson/agents). Not affiliated with or endorsed by wshobson or by the CSS Working Group. ## The one thing to know **Every clamp has two exact crossover viewports.** ```text clamp(26px, 1.2rem + 2.6vw, 48px) at a 16px root pinned at 26px until 262px <- (min - A*root) * 100 / B pinned at 48px from 1108px <- (max - A*root) * 100 / B ``` Neither number is in the declaration, and outside them the viewport term is doing nothing at all. **Two steps can cross, and the band can be bounded on both sides.** ```text h2 1.6rem + 1.2vw (max 48px) h3 0.6rem + 2.6vw (max 44px) lines cross at 1143px inverted from 1143px to 1520px correct again 1700px <- h3 hits its own maximum ``` A sweep at 1100px and 1920px passes twice while the hierarchy is upside down between them. **A bounded inversion is the one a breakpoint sweep cannot find.** **The ratio between two fluid steps is not constant.** It runs from 1.72 to 2.478 across 320px-1920px on one real scale - a drift of 44%. "Modular" and "fluid" are in tension, and there is exactly one way out: make BOTH the rem term and the vw term of each step the same multiple of the one below. Then the ratio is exactly constant at every viewport and the lines can never cross. Equal `vw` terms - the usual advice - stops them crossing and flattens the ratio toward 1:1 instead. **And a `vw` term does not answer the reader's font-size setting.** ```text at 1440px, asked to double: `2rem + 0vw` 32px -> 64px 2× answers 100% `0rem + 2vw` 28.8px -> 28.8px 1× answers 0% ``` Page zoom scales `vw`, but the browser's default-font-size preference does not - it scales `rem` and leaves viewport units alone. The share a step can answer is exactly the rem term's share of its size. **And the same is true of the BOUNDS**, because a clamp spends most of its range pinned against one rather than on its line. ## One clamp, width by width | Viewport | The preferred term | What clamp() returns | State | | --- | --- | --- | --- | | 280px | 26.5px | **26.5px** | on its line | | 320px | 27.5px | **27.5px** | on its line | | 400px | 29.6px | **29.6px** | on its line | | 600px | 34.8px | **34.8px** | on its line | | 800px | 40px | **40px** | on its line | | 1000px | 45.2px | **45.2px** | on its line | | 1200px | 50.4px | **48px** | pinned at its max | | 1440px | 56.6px | **48px** | pinned at its max | | 1920px | 69.1px | **48px** | pinned at its max | `clamp(26px, 1.2rem + 2.6vw, 48px)` at a 16px root. **The two crossover viewports are 262px and 1108px**, and neither is in the declaration: they are `(bound − A×root) × 100 ÷ B`. Everything below the first and above the second is a fixed size wearing a clamp. ## Two lines crossing | Viewport | `h2` | `h3` | Gap | Order | | --- | --- | --- | --- | --- | | 600px | 32.8px | 25.2px | 7.6px | correct | | 900px | 36.4px | 33px | 3.4px | correct | | 1000px | 37.6px | 35.6px | 2px | correct | | 1100px | 38.8px | 38.2px | 0.6px | correct | | 1143px | 39.3px | 39.3px | 0px | **INVERTED** | | 1300px | 41.2px | 43.4px | -2.2px | **INVERTED** | | 1450px | 43px | 44px | -1px | **INVERTED** | | 1520px | 43.8px | 44px | -0.16px | **INVERTED** | | 1700px | 46px | 44px | 2px | correct | | 1920px | 48px | 44px | 4px | correct | `h2` is `1.6rem + 1.2vw` and `h3` is `0.6rem + 2.6vw`, so the lines cross at **1143px**. The hierarchy is upside down from 1143px to 1520px, and correct again from 1700px once `h3` reaches its own maximum — so a sweep that checks 1100px and 1920px passes twice while the scale is broken between them. **A bounded inversion is the one a breakpoint sweep cannot find.** ## The ratio drift | Viewport | `h2` | `body` | Ratio | | --- | --- | --- | --- | | 320px | 27.5px | 16px | **1.72** | | 390px | 29.3px | 16px | **1.8338** | | 768px | 39.2px | 17.5px | **2.2418** | | 1024px | 45.8px | 18.5px | **2.4775** | | 1440px | 48px | 20px | **2.4** | | 1920px | 48px | 20px | **2.4** | The ratio runs from **1.72** to **2.4775**, a drift of 44%. A modular scale is a constant ratio and a fluid scale is a set of lines with different slopes; the two are in tension unless BOTH terms of each step are the same multiple of the one below — in which case the ratio is exactly constant at every viewport, and the lines can never cross. ## What the reader gets | Preferred term | The rem term's share | At the default root | At a doubled root | What the reader gets | Of the request | | --- | --- | --- | --- | --- | --- | | `2rem + 0vw` | 100% | 32px | 64px | **2×** | 100% | | `1.5rem + 0.5vw` | 76.9% | 31.2px | 55.2px | **1.769×** | 76.9% | | `1rem + 1vw` | 52.6% | 30.4px | 46.4px | **1.526×** | 52.6% | | `0.5rem + 1.5vw` | 27% | 29.6px | 37.6px | **1.27×** | 27% | | `0.25rem + 1.75vw` | 13.7% | 29.2px | 33.2px | **1.137×** | 13.7% | | `0rem + 2vw` | 0% | 28.8px | 28.8px | **1×** | 0% | Measured at 1440px with a 16px root asked to double. **The last column is exactly the rem term's share of the size**: page zoom scales `vw`, but the browser's default-font-size preference does not — it scales `rem` and leaves viewport units alone. An all-rem step answers in full; an all-`vw` step answers none of it and the reader gets the same pixels they started with. ## Constants and thresholds | Constant | Value | What it decides | | --- | --- | --- | | `RATIO_DRIFT` | 25% | a step-ratio drift worth warning about. Every fluid ratio drifts, so this is set where the drift has changed the design rather than where it is non-zero | | `ZOOM_SHORTFALL` | 50% | answering less than this share of a reader's request. No step with a `vw` term can answer in full, so this is a half rather than a whole | | `NEAR_CROSS_SHARE` | 15% | two steps this close, relative to the larger, are worth naming | | `TINY_SLOPE` | 0.2px per 100px | a viewport term small enough to be doing nothing | | `DEFAULT_ROOT_PX` | 16px | assumed root, and it multiplies every rem term | | `DEFAULT_ZOOM` | 2× | the enlargement WCAG 1.4.4 asks text to survive | | `DEFAULT_VIEWPORTS` | 320, 390, 768, 1024, 1440, 1920px | assumed when the sheet names none | Only `clamp()`'s own behaviour is CSS's; everything else is this page's threshold for when a difference is worth saying. The crossovers, the crossings and the zoom responses are measurements, not preferences — the thresholds decide only what gets called a warning. ## Sheet grammar A sheet is a header of `KEY: value` lines and a `SCALE:` block. ```text JOB: what this scale is (optional, echoed back) ROOT: 16 (px; multiplies every rem term, and moves every crossover viewport at once. Assumed 16) VIEWPORTS: 375, 768, 1024, 1440 (the widths you actually test at; assumed 320, 390, 768, 1024, 1440, 1920) RATIO: 1.25 (the ratio you intended, if any) ZOOM: 2 (the text enlargement to check; assumed 2) SCALE: clamp(, rem + vw, ) min= preferred=rem + vw max= size= ``` **The order you write the steps in is the hierarchy this page checks.** Largest first. Every ordered pair is compared, not just adjacent ones, because a step that overtakes one two rows above it is the same bug. **A bound carries its unit and it matters.** `clamp(40px, …, 72px)` and `clamp(2.5rem, …, 4.5rem)` render identically at a 16px root and behave completely differently for a reader who enlarges text, because a clamp spends most of its range pinned against a bound rather than on its line. `cqw` and `cqi` work in place of `vw`: the arithmetic is identical and the variable is the query container rather than the window, so every crossover on the answer becomes a crossover CONTAINER width. Anything the reader cannot place is listed as a problem rather than skipped. A sheet whose steps were quietly swallowed would be a smaller scale than the one written, and every crossing here is a comparison between pairs - so a missing step is a comparison that was never made. ## Lanes | Lane | What it produces | | --- | --- | | `plan` | Turn a type brief into a scale that holds | | `audit` (primary) | What this scale actually measures, and where it stops being a scale | | `order` | The hierarchy question: which pairs cross, and where | | `reader` | The reader question: how much of a font-size request each step answers | | `decide` | Decide what changes: a number, the shape, or the idea | ## A worked sheet ```text JOB: the marketing site's type scale ROOT: 16 VIEWPORTS: 375, 768, 1024, 1440, 1920 RATIO: 1.25 SCALE: h1 clamp(40px, 2rem + 3.5vw, 72px) h2 clamp(26px, 1.6rem + 1.2vw, 48px) h3 clamp(22px, 0.6rem + 2.6vw, 44px) body clamp(16px, 0.9rem + 0.4vw, 20px) small size=14px ``` | Step | Declared | Slope | Min until | Max from | Answers a 2× request | | --- | --- | --- | --- | --- | --- | | `h1` | 40px → 72px | 3.5vw | 229px | 1143px | 0% | | `h2` | 26px → 48px | 1.2vw | 33px | 1867px | 0% | | `h3` | 22px → 44px | 2.6vw | 477px | 1323px | 0% | | `body` | 16px → 20px | 0.4vw | 400px | 1400px | 0% | | `small` | 14px → 14px | flat | — | — | 0% | The sheet in the worked example. 5 steps over 5 viewports, 1 pair inverts, 3 ratios drifting, 5 under 50% of the 2x request, 1 never fluid. **Read the two middle columns together**: they are the whole shape of each clamp and neither is written down anywhere. And the last column is the rem term's share of the size at the width where it is worst — the part of a reader's request the step can answer. ## Findings | Finding | Severity | Scope | What it says | | --- | --- | --- | --- | | `NO-SCALE-TO-READ` | error | sheet | The sheet declares no steps | | `A-STEP-IS-BACKWARDS` | error | shape | A step's min is larger than its max | | `TWO-STEPS-INVERT` | warning | order | A smaller step becomes larger than a bigger one | | `AN-INVERSION-IS-INVISIBLE-AT-EVERY-DECLARED-VIEWPORT` | warning | order | The inversion falls between the widths you test at | | `A-STEP-IGNORES-THE-TEXT-SIZE-REQUEST` | warning | reader | A step answers less than half of a reader's font-size setting | | `THE-BOUNDS-ARE-IN-PIXELS-SO-THEY-DO-NOT-SCALE` | warning | reader | A clamp's min and max are pixel lengths, so both ends ignore the reader | | `A-STEP-IS-ENTIRELY-VIEWPORT-UNITS` | warning | reader | A step has no rem term at all | | `THE-RATIO-BETWEEN-STEPS-DRIFTS` | warning | ratio | A step ratio is not constant across the viewports | | `A-STEP-IS-NEVER-FLUID` | warning | shape | A clamp is pinned at every viewport you declared | | `A-VIEWPORT-TERM-DOES-ALMOST-NOTHING` | warning | shape | A vw term is too small to move the size usefully | | `THE-SHEET-HAS-LINES-THIS-PAGE-COULD-NOT-READ` | warning | sheet | Some lines were not readable | | `EVERY-CLAMP-HAS-TWO-CROSSOVER-VIEWPORTS` | note | shape | Where each step stops being its minimum and starts being its maximum | | `THE-SLOPE-OF-EACH-STEP` | note | shape | How fast each step grows with the window | | `THE-RATIO-AT-EVERY-VIEWPORT` | note | ratio | What each adjacent pair actually measures, width by width | | `HOW-MUCH-OF-THE-REQUEST-EACH-STEP-ANSWERS` | note | reader | The exact enlargement each step gives a reader who asks for more | | `THE-ORDER-THIS-SHEET-WAS-READ-IN` | note | order | Which end of the scale this sheet was read as starting from | | `A-STEP-IS-FIXED-BY-DESIGN` | note | shape | A step has no viewport term and is not trying to be fluid | | `TWO-STEPS-ARE-PARALLEL` | note | order | Two steps share a slope, so they never cross and their ratio flattens | | `THE-RATIO-IS-CONSTANT-BY-CONSTRUCTION` | note | order | Two steps have proportional terms, so their ratio is exactly constant | | `TWO-STEPS-COME-CLOSE-WITHOUT-CROSSING` | note | order | A pair narrows to almost nothing somewhere in the range | | `THE-ROOT-FONT-SIZE-MULTIPLIES-EVERY-REM-TERM` | note | reader | Every rem term is measured against the root | | `A-STEP-SPENDS-MOST-OF-ITS-RANGE-PINNED` | note | shape | A clamp is flat over most of the widths you declared | | `NO-INTENDED-RATIO-WAS-DECLARED` | note | ratio | The sheet states no ratio to check against | | `A-VIEWPORT-LIST-WAS-NOT-DECLARED` | note | sheet | The sheet does not say which widths matter | | `THE-UNIT-IS-A-CONTAINER-UNIT` | note | shape | A step is measured against its container, not the window | 25 findings: 2 errors, 9 warnings and 14 notes. **Nothing that fires on every usable sheet is a warning.** Every clamp has two crossovers, every step has a slope, and every ratio between two fluid steps drifts a little — so those are notes. What gets a warning is an order that inverts, a ratio that has moved enough to change the design, or a reader who gets less than half of what they asked for. ## What this page cannot do This page reads a description of a scale, not your stylesheet. It never parses CSS at large, lays out text or measures a font, so: - **It is the SIZE and nothing else.** Line height, letter spacing, measure, font metrics and the actual rendered height of a line all decide whether type reads well and none of them changes what `clamp()` returns. What is exact here is the number. - **`font-size` is not the height of anything you can see.** Two families at the same size have different cap heights, so a scale that is arithmetically perfect can still look wrong. That is a font-metrics question and this page has no font. - **The preferred term must be `A*rem + B*vw` (or `cqw`/`cqi`).** A `calc()` with nested arithmetic, a `min()`/`max()` inside the clamp, a `var()` this page cannot resolve, or a percentage are all reported rather than guessed at - and a scale built from them is outside what this page can check. - **The zoom claim is about the browser's DEFAULT-FONT-SIZE setting, not page zoom.** Page zoom scales the viewport in CSS pixels, so `vw` grows with it and a `vw`-heavy scale survives it. The default-font-size preference scales `rem` only, and that is the setting a reader who needs larger text usually changes. - **`cqw` needs a container to be a container.** If no ancestor declares `container-type`, `cqw` falls back to the small viewport unit and behaves as `vw` - so a container scale that was never given a container is a viewport scale with different spelling, and this page cannot see which you have. - **Nothing here says whether the scale is any good.** It says what it measures at every width you named, where its steps cross, and how much of a reader's request each one can answer. ## API `POST https://api.skillsafe.ai/v1/app-api/run` with an app session token. The body IS the input object — never wrapped in an `input` key. Fields: `task` (one of `plan`, `audit`, `order`, `reader`, `decide`), `rules`, `prescan`, plus the lane's own fields. `POST .../estimate` with the same body returns `hold_credits` and costs nothing. ## Provenance Every table and every figure above is generated from `scale.js` by `build-skill.js`. Nothing is typed twice, so nothing can drift.