From bda79f66b3ca2d5984bf83040ae5b1bb9b6bc92f Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Thu, 8 Oct 2026 03:45:54 +0700 Subject: [PATCH 1/6] feat(typography): pow() fluid scale output with container support Add settings.output: "clamp" | "pow" to fluid type scales. Pow mode emits the six scale inputs and the fluid, at-narrow and at-wide helpers as scale-prefixed tokens and derives each step with pow(). Steps keep their clamp-mode names and paths, steps below 0 divide step 0 by the narrow ratio, and the fluid type checks use the sizes pow produces. relativeTo maps to 100vw, 100vi or 100cqi. Closes #82 Co-Authored-By: Claude --- .changeset/issue-82-pow-scale.md | 11 + README.md | 119 ++++++- packages/cssforge/README.md | 119 ++++++- packages/cssforge/src/modules/typography.ts | 135 ++++++-- .../cssforge/tests/fluid-pow-scale.test.ts | 300 ++++++++++++++++++ .../cssforge/references/source-schema-map.md | 14 + 6 files changed, 672 insertions(+), 26 deletions(-) create mode 100644 .changeset/issue-82-pow-scale.md create mode 100644 packages/cssforge/tests/fluid-pow-scale.test.ts diff --git a/.changeset/issue-82-pow-scale.md b/.changeset/issue-82-pow-scale.md new file mode 100644 index 0000000..318ce49 --- /dev/null +++ b/.changeset/issue-82-pow-scale.md @@ -0,0 +1,11 @@ +--- +"@hebilicious/cssforge": minor +--- + +Write a fluid type scale with `pow()` instead of `clamp()`. + +- `typography.fluid..settings.output: "pow"` emits the scale's six inputs (`narrow`, `wide`, `size-narrow`, `size-wide`, `ratio-narrow`, `ratio-wide`) as plain numbers, plus the `fluid`, `at-narrow` and `at-wide` helpers, named `--typography_fluid-[-]-`. Every step derives from them, so the scale can be tuned live in DevTools. `pow()` needs Chrome 120, Firefox 118 or Safari 15.4. +- Steps keep their clamp-mode names and references, `customLabel` included. A step below 0 is step 0 divided by `pow(ratio-narrow, n)`, and the fluid type checks use those sizes. +- The inputs and helpers are tokens in the JSON and TypeScript outputs and can be referenced as `typography_fluid..`. +- `relativeTo: "container"` writes `100cqi`, `"viewport"` writes `100vi`, and the default `"viewport-width"` writes `100vw`. +- `"clamp"` stays the default, and its output is unchanged. diff --git a/README.md b/README.md index b26077b..0038bf1 100644 --- a/README.md +++ b/README.md @@ -1428,7 +1428,8 @@ This will generate the following CSS : #### Fluid Typography Checks Each fluid step is checked from its smallest and largest size in px, as utopia computes them -at `minWidth` and `maxWidth`: +at `minWidth` and `maxWidth` (a [pow](#fluid-typography-with-pow) step below 0 is checked at +the sizes pow produces): - **Error: more than 2.5× growth.** A step whose maximum is more than 2.5 times its minimum fails the build. Past that ratio, 500% browser zoom cannot double the text at some viewport @@ -1514,6 +1515,116 @@ Warnings never fail the build. The CLI prints each one to stderr as `cssforge: warning: ` and still writes the outputs, and `getDiagnostics(config)` returns them to programmatic callers (see [Programmatic Usage](#programmatic-usage)). +#### Fluid Typography with pow() + +`settings.output` chooses how the steps reach the CSS. The default, `"clamp"`, writes each +step as a `clamp()` value. `"pow"` writes the six numbers that define the scale and derives +every step from them with `pow()`, so you can tune the whole scale live in DevTools. Change +an input on `:root`, where the steps are declared: a step takes its value there, so an input +set on a descendant changes nothing below it. It needs `pow()`: Chrome 120, Firefox 118, +Safari 15.4. + +In pow mode each scale emits these custom properties, named like its steps +(`--typography_fluid-[-]-`), so two scales never share one: + +- `narrow` and `wide`: `minWidth` and `maxWidth` in rem, as plain numbers. A length divided + by a length does not work in Firefox, so the inputs carry no unit. +- `size-narrow` and `size-wide`: `minFontSize` and `maxFontSize` in rem. +- `ratio-narrow` and `ratio-wide`: `minTypeScale` and `maxTypeScale`. +- `fluid`, `at-narrow` and `at-wide`: the helpers the steps are built from. + +Step 0 is `at-narrow + at-wide`, and step `n` above it is +`at-narrow × ratio-narrow^n + at-wide × ratio-wide^n`, which gives utopia's `minFontSize` and +`maxFontSize` at `minWidth` and `maxWidth`. A step below 0 is step 0 divided by +`pow(ratio-narrow, n)` at every width. It still grows with step 0, but its largest size is +`maxFontSize / minTypeScale^n` rather than utopia's `maxFontSize / maxTypeScale^n`, so a small +size never shrinks as the screen grows. The [checks](#fluid-typography-checks) use the sizes +each mode actually produces. + +The steps keep the names and references of clamp mode, `customLabel` included, so switching +`output` breaks no reference. The inputs and helpers are tokens too: they appear in the JSON +and TypeScript outputs, and you can reference them as `typography_fluid..`, for +example `typography_fluid.body.at-narrow`. A step label equal to one of these names is a key +collision and fails the build. + +`relativeTo` sets the width the scale follows: `"viewport-width"` (the default) writes +`100vw`, `"viewport"` writes `100vi`, and `"container"` writes `100cqi` for a scale that +follows its container. + + + +```typescript +export default defineConfig({ + typography: { + fluid: { + body: { + value: { + minWidth: 320, + minFontSize: 18, + minTypeScale: 1.2, + maxWidth: 1240, + maxFontSize: 20, + maxTypeScale: 1.25, + positiveSteps: 2, + negativeSteps: 1, + relativeTo: "container", + }, + settings: { + output: "pow", + }, + }, + }, + }, +}); +``` + +This will generate the following CSS : + +```css +/*____ CSSForge ____*/ +:root { +/*____ Typography ____*/ +--typography_fluid-body-narrow: 20; +--typography_fluid-body-wide: 77.5; +--typography_fluid-body-size-narrow: 1.125; +--typography_fluid-body-size-wide: 1.25; +--typography_fluid-body-ratio-narrow: 1.2; +--typography_fluid-body-ratio-wide: 1.25; +--typography_fluid-body-fluid: clamp(0rem, (100cqi - var(--typography_fluid-body-narrow) * 1rem) / (var(--typography_fluid-body-wide) - var(--typography_fluid-body-narrow)), 1rem); +--typography_fluid-body-at-narrow: calc(var(--typography_fluid-body-size-narrow) * (1rem - var(--typography_fluid-body-fluid))); +--typography_fluid-body-at-wide: calc(var(--typography_fluid-body-size-wide) * var(--typography_fluid-body-fluid)); +--typography_fluid-body-xl: calc(var(--typography_fluid-body-at-narrow) * pow(var(--typography_fluid-body-ratio-narrow), 2) + var(--typography_fluid-body-at-wide) * pow(var(--typography_fluid-body-ratio-wide), 2)); +--typography_fluid-body-l: calc(var(--typography_fluid-body-at-narrow) * var(--typography_fluid-body-ratio-narrow) + var(--typography_fluid-body-at-wide) * var(--typography_fluid-body-ratio-wide)); +--typography_fluid-body-m: calc(var(--typography_fluid-body-at-narrow) + var(--typography_fluid-body-at-wide)); +--typography_fluid-body-s: calc(var(--typography_fluid-body-m) / var(--typography_fluid-body-ratio-narrow)); +} +``` + + + ### Primitives More flexible than other types, primitives allow you to define any type of token by @@ -1696,8 +1807,10 @@ following convention : ### Referencing Fluid Typography To reference fluid typography, use the `@` symbol and the label of the scale; ie: -`typography_fluid.comicsans@a`. Do not include the prefix in the reference. The labels -follow the following convention : +`typography_fluid.comicsans@a`. Do not include the prefix in the reference. A scale with +`settings.output: "pow"` also exposes its inputs, such as `typography_fluid.comicsans.at-wide` +(see [Fluid Typography with pow()](#fluid-typography-with-pow)). The labels follow the +following convention : - 3xs - 2xs diff --git a/packages/cssforge/README.md b/packages/cssforge/README.md index b26077b..0038bf1 100644 --- a/packages/cssforge/README.md +++ b/packages/cssforge/README.md @@ -1428,7 +1428,8 @@ This will generate the following CSS : #### Fluid Typography Checks Each fluid step is checked from its smallest and largest size in px, as utopia computes them -at `minWidth` and `maxWidth`: +at `minWidth` and `maxWidth` (a [pow](#fluid-typography-with-pow) step below 0 is checked at +the sizes pow produces): - **Error: more than 2.5× growth.** A step whose maximum is more than 2.5 times its minimum fails the build. Past that ratio, 500% browser zoom cannot double the text at some viewport @@ -1514,6 +1515,116 @@ Warnings never fail the build. The CLI prints each one to stderr as `cssforge: warning: ` and still writes the outputs, and `getDiagnostics(config)` returns them to programmatic callers (see [Programmatic Usage](#programmatic-usage)). +#### Fluid Typography with pow() + +`settings.output` chooses how the steps reach the CSS. The default, `"clamp"`, writes each +step as a `clamp()` value. `"pow"` writes the six numbers that define the scale and derives +every step from them with `pow()`, so you can tune the whole scale live in DevTools. Change +an input on `:root`, where the steps are declared: a step takes its value there, so an input +set on a descendant changes nothing below it. It needs `pow()`: Chrome 120, Firefox 118, +Safari 15.4. + +In pow mode each scale emits these custom properties, named like its steps +(`--typography_fluid-[-]-`), so two scales never share one: + +- `narrow` and `wide`: `minWidth` and `maxWidth` in rem, as plain numbers. A length divided + by a length does not work in Firefox, so the inputs carry no unit. +- `size-narrow` and `size-wide`: `minFontSize` and `maxFontSize` in rem. +- `ratio-narrow` and `ratio-wide`: `minTypeScale` and `maxTypeScale`. +- `fluid`, `at-narrow` and `at-wide`: the helpers the steps are built from. + +Step 0 is `at-narrow + at-wide`, and step `n` above it is +`at-narrow × ratio-narrow^n + at-wide × ratio-wide^n`, which gives utopia's `minFontSize` and +`maxFontSize` at `minWidth` and `maxWidth`. A step below 0 is step 0 divided by +`pow(ratio-narrow, n)` at every width. It still grows with step 0, but its largest size is +`maxFontSize / minTypeScale^n` rather than utopia's `maxFontSize / maxTypeScale^n`, so a small +size never shrinks as the screen grows. The [checks](#fluid-typography-checks) use the sizes +each mode actually produces. + +The steps keep the names and references of clamp mode, `customLabel` included, so switching +`output` breaks no reference. The inputs and helpers are tokens too: they appear in the JSON +and TypeScript outputs, and you can reference them as `typography_fluid..`, for +example `typography_fluid.body.at-narrow`. A step label equal to one of these names is a key +collision and fails the build. + +`relativeTo` sets the width the scale follows: `"viewport-width"` (the default) writes +`100vw`, `"viewport"` writes `100vi`, and `"container"` writes `100cqi` for a scale that +follows its container. + + + +```typescript +export default defineConfig({ + typography: { + fluid: { + body: { + value: { + minWidth: 320, + minFontSize: 18, + minTypeScale: 1.2, + maxWidth: 1240, + maxFontSize: 20, + maxTypeScale: 1.25, + positiveSteps: 2, + negativeSteps: 1, + relativeTo: "container", + }, + settings: { + output: "pow", + }, + }, + }, + }, +}); +``` + +This will generate the following CSS : + +```css +/*____ CSSForge ____*/ +:root { +/*____ Typography ____*/ +--typography_fluid-body-narrow: 20; +--typography_fluid-body-wide: 77.5; +--typography_fluid-body-size-narrow: 1.125; +--typography_fluid-body-size-wide: 1.25; +--typography_fluid-body-ratio-narrow: 1.2; +--typography_fluid-body-ratio-wide: 1.25; +--typography_fluid-body-fluid: clamp(0rem, (100cqi - var(--typography_fluid-body-narrow) * 1rem) / (var(--typography_fluid-body-wide) - var(--typography_fluid-body-narrow)), 1rem); +--typography_fluid-body-at-narrow: calc(var(--typography_fluid-body-size-narrow) * (1rem - var(--typography_fluid-body-fluid))); +--typography_fluid-body-at-wide: calc(var(--typography_fluid-body-size-wide) * var(--typography_fluid-body-fluid)); +--typography_fluid-body-xl: calc(var(--typography_fluid-body-at-narrow) * pow(var(--typography_fluid-body-ratio-narrow), 2) + var(--typography_fluid-body-at-wide) * pow(var(--typography_fluid-body-ratio-wide), 2)); +--typography_fluid-body-l: calc(var(--typography_fluid-body-at-narrow) * var(--typography_fluid-body-ratio-narrow) + var(--typography_fluid-body-at-wide) * var(--typography_fluid-body-ratio-wide)); +--typography_fluid-body-m: calc(var(--typography_fluid-body-at-narrow) + var(--typography_fluid-body-at-wide)); +--typography_fluid-body-s: calc(var(--typography_fluid-body-m) / var(--typography_fluid-body-ratio-narrow)); +} +``` + + + ### Primitives More flexible than other types, primitives allow you to define any type of token by @@ -1696,8 +1807,10 @@ following convention : ### Referencing Fluid Typography To reference fluid typography, use the `@` symbol and the label of the scale; ie: -`typography_fluid.comicsans@a`. Do not include the prefix in the reference. The labels -follow the following convention : +`typography_fluid.comicsans@a`. Do not include the prefix in the reference. A scale with +`settings.output: "pow"` also exposes its inputs, such as `typography_fluid.comicsans.at-wide` +(see [Fluid Typography with pow()](#fluid-typography-with-pow)). The labels follow the +following convention : - 3xs - 2xs diff --git a/packages/cssforge/src/modules/typography.ts b/packages/cssforge/src/modules/typography.ts index bb57bad..983bd2a 100644 --- a/packages/cssforge/src/modules/typography.ts +++ b/packages/cssforge/src/modules/typography.ts @@ -26,9 +26,20 @@ export interface FluidTypeScaleDefinition { * @default 12 */ minLegibleSize?: number | false; + /** + * How the steps are written. `"clamp"` bakes each step into a `clamp()` + * value. `"pow"` emits the scale's six inputs and derives each step from + * them with `pow()`, so the scale can be tuned in the browser. `pow()` + * needs Chrome 120, Firefox 118 or Safari 15.4. + * @default "clamp" + */ + output?: FluidTypeOutput; }; } +/** How a fluid type scale writes its steps. */ +export type FluidTypeOutput = "clamp" | "pow"; + interface TypographyWeight { value: { [key: string]: string; @@ -51,6 +62,8 @@ const MAX_FLUID_GROWTH = 2.5; /** A step that changes less than 10% across the viewport range is effectively static. */ const MIN_FLUID_CHANGE = 1.1; const DEFAULT_MIN_LEGIBLE_SIZE = 12; +/** The px per rem utopia-core divides by when it writes rem values. */ +const REM = 16; const px = (size: number) => `${Number(size.toFixed(2))}px`; @@ -67,11 +80,49 @@ const readMinLegibleSize = (value: unknown, path: string): number | false => { ); }; +const readOutput = (value: unknown, path: string): FluidTypeOutput => { + if (value === undefined) return "clamp"; + if (value === "clamp" || value === "pow") return value; + throw new Error( + `Invalid configuration at "${path}": expected "clamp" or "pow", received ${JSON.stringify(value)}.`, + ); +}; + +/** The unit utopia-core writes for each `relativeTo`, which defaults to the viewport width. */ +const RELATIVE_UNITS = { viewport: "vi", "viewport-width": "vw", container: "cqi" }; + +const readRelativeUnit = (value: unknown, path: string): string => { + if (value === undefined) return RELATIVE_UNITS["viewport-width"]; + for (const [relativeTo, unit] of Object.entries(RELATIVE_UNITS)) { + if (value === relativeTo) return unit; + } + throw new Error( + `Invalid configuration at "${path}": expected "viewport", "viewport-width" or "container", received ${JSON.stringify(value)}.`, + ); +}; + +/** The sizes of a step in px at `minWidth` and `maxWidth`. */ +type StepSizes = Pick; + +/** + * The sizes a step is emitted with. A pow step below 0 divides step 0 by the + * narrow ratio at every width, so its wide end differs from utopia's. + */ +const emittedSizes = ( + step: UtopiaStep, + output: FluidTypeOutput, + { maxFontSize, minTypeScale }: UtopiaTypeConfig, +): StepSizes => { + if (output === "clamp" || step.step >= 0) return step; + const wide = maxFontSize / minTypeScale ** -step.step; + return { minFontSize: step.minFontSize, maxFontSize: Math.round(wide * 10000) / 10000 }; +}; + /** The larger end of a step divided by the smaller one, as a negative step can shrink. */ -const changeRatio = ({ minFontSize, maxFontSize }: UtopiaStep) => +const changeRatio = ({ minFontSize, maxFontSize }: StepSizes) => Math.max(minFontSize, maxFontSize) / Math.min(minFontSize, maxFontSize); -const assertWcagGrowth = (step: UtopiaStep, label: string, path: string) => { +const assertWcagGrowth = (step: StepSizes, label: string, path: string) => { if (!(step.minFontSize > 0) || !(step.maxFontSize > 0)) { throw new Error( `Invalid configuration at "${path}": step "${label}" ranges from ${px(step.minFontSize)} to ${px(step.maxFontSize)}. Every step needs a size greater than 0px; raise minFontSize or reduce negativeSteps.`, @@ -123,13 +174,14 @@ export function processTypography(config: TypographyConfig): Output { const scalePath = `${moduleKey}.${scaleName}`; assertSettingsKeys( settings, - ["customLabel", "minLegibleSize"], + ["customLabel", "minLegibleSize", "output"], `${scalePath}.settings`, ); const minLegibleSize = readMinLegibleSize( settings?.minLegibleSize, `${scalePath}.settings.minLegibleSize`, ); + const output = readOutput(settings?.output, `${scalePath}.settings.output`); const { prefix, ...utopiaConfig } = value; if (prefix) validateName(prefix, `${moduleKey}.${scaleName}.prefix`); @@ -139,8 +191,8 @@ export function processTypography(config: TypographyConfig): Output { }); const resolvedPrefix = prefix ? `${scaleName}-${prefix}` : scaleName; - for (const step of scale) { - const { label, clamp } = step; + const keyOf = (name: string) => `--${moduleKey}-${resolvedPrefix}-${name}`; + const labelOf = ({ label }: UtopiaStep) => { const resolvedLabel = settings?.customLabel ? (settings.customLabel[label] ?? label) : label; @@ -151,9 +203,62 @@ export function processTypography(config: TypographyConfig): Output { resolvedLabel, `${moduleKey}.${scaleName}.settings.customLabel.${label}`, ); + return resolvedLabel; + }; + const emit = (path: string, key: string, tokenValue: string) => { + const variable = `${key}: ${tokenValue};`; + cssOutput.push(variable); + resolveMap.set(path, { + variable, + key, + value: tokenValue, + sourcePath: path, + type: "typography", + tier: "primitive", + }); + }; + + let stepValue = (step: UtopiaStep) => step.clamp; + if (output === "pow") { + const ref = (name: string) => `var(${keyOf(name)})`; + const power = (name: string, n: number) => + n === 1 ? ref(name) : `pow(${ref(name)}, ${n})`; + const unit = readRelativeUnit(utopiaConfig.relativeTo, `${scalePath}.relativeTo`); + const inputs: Array<[string, string]> = [ + ["narrow", `${utopiaConfig.minWidth / REM}`], + ["wide", `${utopiaConfig.maxWidth / REM}`], + ["size-narrow", `${utopiaConfig.minFontSize / REM}`], + ["size-wide", `${utopiaConfig.maxFontSize / REM}`], + ["ratio-narrow", `${utopiaConfig.minTypeScale}`], + ["ratio-wide", `${utopiaConfig.maxTypeScale}`], + [ + "fluid", + `clamp(0rem, (100${unit} - ${ref("narrow")} * 1rem) / (${ref("wide")} - ${ref("narrow")}), 1rem)`, + ], + ["at-narrow", `calc(${ref("size-narrow")} * (1rem - ${ref("fluid")}))`], + ["at-wide", `calc(${ref("size-wide")} * ${ref("fluid")})`], + ]; + for (const [name, inputValue] of inputs) { + emit(`${scalePath}.${name}`, keyOf(name), inputValue); + } + + const stepZero = scale.find(({ step }) => step === 0); + const stepZeroRef = stepZero ? `var(${keyOf(labelOf(stepZero))})` : ""; + stepValue = ({ step }) => { + if (step === 0) return `calc(${ref("at-narrow")} + ${ref("at-wide")})`; + if (step < 0) return `calc(${stepZeroRef} / ${power("ratio-narrow", -step)})`; + return `calc(${ref("at-narrow")} * ${power("ratio-narrow", step)} + ${ref("at-wide")} * ${power("ratio-wide", step)})`; + }; + } + + const checkedSizes: StepSizes[] = []; + for (const step of scale) { + const resolvedLabel = labelOf(step); const stepPath = `${scalePath}@${resolvedLabel}`; - assertWcagGrowth(step, resolvedLabel, scalePath); - const smallest = Math.min(step.minFontSize, step.maxFontSize); + const sizes = emittedSizes(step, output, utopiaConfig); + checkedSizes.push(sizes); + assertWcagGrowth(sizes, resolvedLabel, scalePath); + const smallest = Math.min(sizes.minFontSize, sizes.maxFontSize); if (minLegibleSize !== false && smallest < minLegibleSize) { diagnostics.push({ code: "typography-below-legibility-floor", @@ -162,28 +267,18 @@ export function processTypography(config: TypographyConfig): Output { message: `Typography step ${stepPath} reaches ${px(smallest)}, below the ${px(minLegibleSize)} legibility floor. Raise minFontSize, lower the type scale or negativeSteps, or set the scale's settings.minLegibleSize.`, }); } - const key = `--${moduleKey}-${resolvedPrefix}-${resolvedLabel}`; - const variable = `${key}: ${clamp};`; - cssOutput.push(variable); - resolveMap.set(stepPath, { - variable, - key, - value: clamp, - sourcePath: stepPath, - type: "typography", - tier: "primitive", - }); + emit(stepPath, keyOf(resolvedLabel), stepValue(step)); } // One static step is normal where a scale crosses over, so only a scale // that is static at every step is reported, once. - const largestChange = Math.max(...scale.map(changeRatio)); + const largestChange = Math.max(...checkedSizes.map(changeRatio)); if (largestChange < MIN_FLUID_CHANGE) { diagnostics.push({ code: "typography-static-scale", severity: "warning", path: scalePath, - message: `Typography scale ${scalePath} changes by at most ${Number(((largestChange - 1) * 100).toFixed(1))}% across the viewport range, under the ${Math.round((MIN_FLUID_CHANGE - 1) * 100)}% a fluid size needs to be noticeable, so its clamp() values are effectively static. Widen the gap between minFontSize and maxFontSize or between minTypeScale and maxTypeScale, or use fixed sizes.`, + message: `Typography scale ${scalePath} changes by at most ${Number(((largestChange - 1) * 100).toFixed(1))}% across the viewport range, under the ${Math.round((MIN_FLUID_CHANGE - 1) * 100)}% a fluid size needs to be noticeable, so its ${output}() values are effectively static. Widen the gap between minFontSize and maxFontSize or between minTypeScale and maxTypeScale, or use fixed sizes.`, }); } } diff --git a/packages/cssforge/tests/fluid-pow-scale.test.ts b/packages/cssforge/tests/fluid-pow-scale.test.ts new file mode 100644 index 0000000..3d91508 --- /dev/null +++ b/packages/cssforge/tests/fluid-pow-scale.test.ts @@ -0,0 +1,300 @@ +import { calculateTypeScale } from "utopia-core"; +import { generateJSON } from "../src/generator.ts"; +import type { CSSForgeConfig } from "../src/mod.ts"; +import { + defineConfig, + generateCSS, + getDiagnostics, + processTypography, +} from "../src/mod.ts"; +import { getLines } from "./helpers.ts"; +import { assert, assertEquals, assertThrows, Deno } from "./vitest-compat.ts"; + +type FluidScale = NonNullable[string]; +type FluidSettings = NonNullable; + +/** The good-css example scale: 20rem to 77.5rem, 18px to 20px, ratios 1.2 and 1.25. */ +const example = { + minWidth: 320, + maxWidth: 1240, + minFontSize: 18, + maxFontSize: 20, + minTypeScale: 1.2, + maxTypeScale: 1.25, + positiveSteps: 4, + negativeSteps: 2, +} satisfies FluidScale["value"]; + +const scale = ( + value: FluidScale["value"], + settings: FluidSettings = { output: "pow" }, +): Partial => ({ typography: { fluid: { body: { value, settings } } } }); + +const v = (name: string) => `var(--typography_fluid-body-${name})`; + +Deno.test("pow scale - emits the six inputs, the helpers and the steps", () => { + const lines = getLines(processTypography(scale(example).typography ?? {}).css.root); + + assertEquals(lines, [ + "--typography_fluid-body-narrow: 20;", + "--typography_fluid-body-wide: 77.5;", + "--typography_fluid-body-size-narrow: 1.125;", + "--typography_fluid-body-size-wide: 1.25;", + "--typography_fluid-body-ratio-narrow: 1.2;", + "--typography_fluid-body-ratio-wide: 1.25;", + `--typography_fluid-body-fluid: clamp(0rem, (100vw - ${v("narrow")} * 1rem) / (${v("wide")} - ${v("narrow")}), 1rem);`, + `--typography_fluid-body-at-narrow: calc(${v("size-narrow")} * (1rem - ${v("fluid")}));`, + `--typography_fluid-body-at-wide: calc(${v("size-wide")} * ${v("fluid")});`, + `--typography_fluid-body-3xl: calc(${v("at-narrow")} * pow(${v("ratio-narrow")}, 4) + ${v("at-wide")} * pow(${v("ratio-wide")}, 4));`, + `--typography_fluid-body-2xl: calc(${v("at-narrow")} * pow(${v("ratio-narrow")}, 3) + ${v("at-wide")} * pow(${v("ratio-wide")}, 3));`, + `--typography_fluid-body-xl: calc(${v("at-narrow")} * pow(${v("ratio-narrow")}, 2) + ${v("at-wide")} * pow(${v("ratio-wide")}, 2));`, + `--typography_fluid-body-l: calc(${v("at-narrow")} * ${v("ratio-narrow")} + ${v("at-wide")} * ${v("ratio-wide")});`, + `--typography_fluid-body-m: calc(${v("at-narrow")} + ${v("at-wide")});`, + `--typography_fluid-body-s: calc(${v("m")} / ${v("ratio-narrow")});`, + `--typography_fluid-body-xs: calc(${v("m")} / pow(${v("ratio-narrow")}, 2));`, + ]); +}); + +/** + * The emitted formula transcribed to JS: the size of `step` in px at a + * viewport `width` in px, from the inputs derived independently of the module. + */ +const powSize = (step: number, width: number) => { + const rem = 16; + const narrow = example.minWidth / rem; + const wide = example.maxWidth / rem; + const fluid = Math.min(1, Math.max(0, (width / rem - narrow) / (wide - narrow))); + const atNarrow = (example.minFontSize / rem) * (1 - fluid); + const atWide = (example.maxFontSize / rem) * fluid; + const stepZero = atNarrow + atWide; + const size = + step >= 0 + ? atNarrow * example.minTypeScale ** step + atWide * example.maxTypeScale ** step + : stepZero / example.minTypeScale ** -step; + return size * rem; +}; + +Deno.test("pow scale - the formula matches utopia at the narrow and wide widths", () => { + const utopia = calculateTypeScale(example); + const stepZeroWide = utopia.find(({ step }) => step === 0)?.maxFontSize ?? Number.NaN; + + for (const { step, minFontSize, maxFontSize } of utopia) { + const narrow = powSize(step, example.minWidth); + const wide = powSize(step, example.maxWidth); + assert(Math.abs(narrow - minFontSize) < 1e-3, `step ${step} narrow: ${narrow}`); + // Below step 0 the wide end divides step 0 by the narrow ratio, as good-css does. + const expectedWide = + step >= 0 ? maxFontSize : stepZeroWide / example.minTypeScale ** -step; + assert(Math.abs(wide - expectedWide) < 1e-3, `step ${step} wide: ${wide}`); + } + // Step xs at the wide end is 20 / 1.2^2 px, not utopia's 20 / 1.25^2 px. + assert(Math.abs(powSize(-2, example.maxWidth) - 13.8889) < 1e-3); + // Outside the range the scale stops at its ends. + assertEquals(powSize(2, 100), powSize(2, example.minWidth)); + assertEquals(powSize(2, 4000), powSize(2, example.maxWidth)); +}); + +Deno.test("pow scale - clamp stays the default and its output is unchanged", () => { + const css = generateCSS(scale(example, {})); + + assertEquals(generateCSS(scale(example, { output: "clamp" })), css); + assert(css.includes("--typography_fluid-body-m: clamp("), css); + assert(!css.includes("pow(") && !css.includes("-narrow:"), css); +}); + +Deno.test("pow scale - steps keep their names and paths with a prefix and custom labels", () => { + const value = { ...example, positiveSteps: 1, negativeSteps: 1, prefix: "text" }; + const customLabel = { "-1": "small", "0": "base", "1": "large" }; + const config = (output: "clamp" | "pow") => + defineConfig({ + typography: { fluid: { body: { value, settings: { output, customLabel } } } }, + primitives: { + heading: { + value: { + h1: { + value: { fontSize: "var(--size)" }, + variables: { size: "typography_fluid.body@large" }, + }, + }, + }, + }, + }); + + for (const output of ["clamp", "pow"] as const) { + const css = generateCSS(config(output)); + for (const label of ["small", "base", "large"]) { + assert( + css.includes(`--typography_fluid-body-text-${label}: `), + `${output} ${label}`, + ); + } + assert( + css.includes("--heading-h1-fontSize: var(--typography_fluid-body-text-large);"), + ); + } + + const css = generateCSS(config("pow")); + assert(css.includes("--typography_fluid-body-text-narrow: 20;")); + assert( + css.includes( + "--typography_fluid-body-text-small: calc(var(--typography_fluid-body-text-base) / var(--typography_fluid-body-text-ratio-narrow));", + ), + ); +}); + +Deno.test("pow scale - the inputs and helpers are tokens in the JSON output", () => { + type Token = { key: string; value: string; variable: string }; + const json = JSON.parse(generateJSON(scale(example))) as { + typography_fluid: Record | Token>; + }; + const inputs = json.typography_fluid.body as Record | undefined; + const stepZero = json.typography_fluid["body@m"] as Token | undefined; + + assertEquals(inputs?.narrow, { + key: "--typography_fluid-body-narrow", + value: "20", + variable: "--typography_fluid-body-narrow: 20;", + }); + assertEquals(Object.keys(inputs ?? {}), [ + "narrow", + "wide", + "size-narrow", + "size-wide", + "ratio-narrow", + "ratio-wide", + "fluid", + "at-narrow", + "at-wide", + ]); + assertEquals(stepZero?.value, `calc(${v("at-narrow")} + ${v("at-wide")})`); +}); + +Deno.test("pow scale - the helpers can be referenced to build a pair", () => { + const css = generateCSS({ + ...scale(example), + primitives: { + space: { + value: { + pair: { + value: { gap: "calc(var(--n) + 2 * var(--w))" }, + variables: { + n: "typography_fluid.body.at-narrow", + w: "typography_fluid.body.at-wide", + }, + }, + }, + }, + }, + }); + + assert( + css.includes( + "--space-pair-gap: calc(var(--typography_fluid-body-at-narrow) + 2 * var(--typography_fluid-body-at-wide));", + ), + ); +}); + +Deno.test("pow scale - relativeTo picks the unit of the fluid width", () => { + const fluidLine = (relativeTo?: "viewport" | "viewport-width" | "container") => + getLines( + processTypography(scale({ ...example, relativeTo }).typography ?? {}).css.root, + ).find((line) => line.startsWith("--typography_fluid-body-fluid:")); + + assert(fluidLine()?.includes("(100vw - ")); + assert(fluidLine("viewport-width")?.includes("(100vw - ")); + assert(fluidLine("viewport")?.includes("(100vi - ")); + assert(fluidLine("container")?.includes("(100cqi - ")); +}); + +Deno.test("pow scale - two scales never share an input name", () => { + const css = generateCSS({ + typography: { + fluid: { + body: { value: example, settings: { output: "pow" } }, + display: { value: { ...example, prefix: "big" }, settings: { output: "pow" } }, + }, + }, + }); + + assert(css.includes("--typography_fluid-body-narrow: 20;")); + assert(css.includes("--typography_fluid-display-big-narrow: 20;")); +}); + +Deno.test("pow scale - a step label that takes an input name is a key collision", () => { + const error = assertThrows(() => + generateCSS(scale(example, { output: "pow", customLabel: { "1": "fluid" } })), + ); + + assertEquals( + error.message, + 'Token key collision: "typography_fluid.body.fluid" and "typography_fluid.body@fluid" both generate "--typography_fluid-body-fluid". Rename one of the configuration paths.', + ); + generateCSS(scale(example, { output: "clamp", customLabel: { "1": "fluid" } })); +}); + +Deno.test("pow scale - rejects an unknown output or relativeTo", () => { + const output = assertThrows(() => + generateCSS(scale(example, { output: "calc" } as unknown as FluidSettings)), + ); + assert( + output.message.includes('"typography_fluid.body.settings.output"') && + output.message.includes('"clamp" or "pow"'), + output.message, + ); + + const relativeTo = assertThrows(() => + generateCSS( + scale({ ...example, relativeTo: "page" } as unknown as FluidScale["value"]), + ), + ); + assert( + relativeTo.message.includes('"typography_fluid.body.relativeTo"'), + relativeTo.message, + ); +}); + +Deno.test("pow scale - checks negative steps at the sizes pow produces", () => { + // Step xs is 17.28 / 1.2^2 = 12px at the narrow end in both modes. At the wide + // end clamp gives 20 / 1.6^2 = 7.81px and pow gives 20 / 1.2^2 = 13.89px. + const value = { + ...example, + minFontSize: 17.28, + minTypeScale: 1.2, + maxTypeScale: 1.6, + positiveSteps: 0, + negativeSteps: 2, + }; + + const clamp = getDiagnostics(scale(value, { output: "clamp" })); + assertEquals( + clamp.map(({ code, path }) => ({ code, path })), + [{ code: "typography-below-legibility-floor", path: "typography_fluid.body@xs" }], + ); + assertEquals(getDiagnostics(scale(value)), []); + + const floor = getDiagnostics(scale(value, { output: "pow", minLegibleSize: 13 })); + assertEquals( + floor.map(({ path }) => path), + ["typography_fluid.body@xs"], + ); + assert(floor[0]?.message.includes("12px"), floor[0]?.message); +}); + +Deno.test("pow scale - the 2.5x error and static warning apply as in clamp", () => { + const growing = { ...example, maxFontSize: 45.01, positiveSteps: 0, negativeSteps: 0 }; + for (const output of ["clamp", "pow"] as const) { + const error = assertThrows(() => generateCSS(scale(growing, { output }))); + assert(error.message.includes("2.5"), error.message); + } + + const flat = { + ...example, + maxFontSize: 18.5, + maxTypeScale: 1.2, + positiveSteps: 1, + negativeSteps: 0, + }; + const [diagnostic] = getDiagnostics(scale(flat)); + assertEquals(diagnostic?.code, "typography-static-scale"); + assert(diagnostic?.message.includes("pow()"), diagnostic?.message); +}); diff --git a/skills/cssforge/references/source-schema-map.md b/skills/cssforge/references/source-schema-map.md index 84492e4..ef2a26c 100644 --- a/skills/cssforge/references/source-schema-map.md +++ b/skills/cssforge/references/source-schema-map.md @@ -45,6 +45,20 @@ color output settings. `typography-below-legibility-floor` per step (path `typography_fluid.@