Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/issue-82-pow-scale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@hebilicious/cssforge": minor
---

Every fluid type scale is also written with `pow()`, beside its `clamp()` steps, in every output.

- The `clamp()` steps keep their names and paths, `--typography_fluid-<scale>[-<prefix>]-<label>` at `typography_fluid.<scale>@<label>`. Steps 0 and above are unchanged.
- Each scale adds pow tokens under a `pow` segment: the inputs `min-width`, `max-width`, `min-font-size`, `max-font-size`, `min-type-scale` and `max-type-scale` as plain numbers, the helpers `progress`, `at-min` and `at-max`, and every step derived from them with `pow()`. They are named `--typography_fluid-<scale>[-<prefix>]-pow-<name>` and `--typography_fluid-<scale>[-<prefix>]-pow-<label>`, at `typography_fluid.<scale>.pow.<name>` and `typography_fluid.<scale>.pow@<label>`. Changing an input on `:root` tunes the whole scale at runtime. `pow()` needs Chrome 120, Firefox 118 or Safari 15.4.
- The pow tokens are ordinary tokens: the CSS, JSON, TypeScript and Style Dictionary outputs hold them, and a primitive can reference them. The helpers and steps are semantic tokens that list the tokens they reference.
- A step below 0 is now step 0 divided by `minTypeScale^n` at every width, so a small size never shrinks as the screen grows. Its size at `maxWidth` is `maxFontSize / minTypeScale^n` instead of utopia's `maxFontSize / maxTypeScale^n`. This changes the `clamp()` of negative steps when `minTypeScale` and `maxTypeScale` differ, and the fluid type checks use these sizes, once per step.
- A step label that gives a pow name, such as `min-width` or `pow-min-width`, is a key collision. A step cannot be labelled `pow`, the segment that holds the pow tokens.
- `relativeTo` is validated for every scale: `"container"` writes `cqi`, `"viewport"` writes `vi`, and the default `"viewport-width"` writes `vw`.
202 changes: 195 additions & 7 deletions README.md

Large diffs are not rendered by default.

202 changes: 195 additions & 7 deletions packages/cssforge/README.md

Large diffs are not rendered by default.

194 changes: 167 additions & 27 deletions packages/cssforge/src/modules/typography.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { calculateTypeScale, type UtopiaStep, type UtopiaTypeConfig } from "utopia-core";
import {
calculateClamp,
calculateTypeScale,
type UtopiaStep,
type UtopiaTypeConfig,
} from "utopia-core";
import { assertSettingsKeys, validateCustomLabel, validateName } from "../helpers.ts";
import type { Diagnostic, Output, ResolveMap } from "../lib.ts";
import {
type Diagnostic,
getReferencePaths,
type Output,
type ResolvedToken,
type ResolveMap,
type Variables,
} from "../lib.ts";

export interface FluidTypeScaleDefinition {
/**
Expand Down Expand Up @@ -51,6 +63,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`;

Expand All @@ -67,11 +81,67 @@ const readMinLegibleSize = (value: unknown, path: string): number | false => {
);
};

/** 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)}.`,
);
};

/**
* A step of a fluid type scale: its sizes in px at `minWidth` and `maxWidth`,
* and the `clamp()` that runs between them.
*/
type FluidTypeStep = Pick<
UtopiaStep,
"step" | "label" | "minFontSize" | "maxFontSize" | "clamp"
>;

/** Rounds a size to 4 decimals, as utopia-core rounds the sizes it reports. */
const round = (size: number) => Math.round((size + Number.EPSILON) * 10000) / 10000;

/**
* The canonical sizes of every step, which the checks and both representations
* of the scale use. Step 0 and above are utopia-core's. Step -n is step 0
* divided by `minTypeScale^n` at every width, so a small size never shrinks as
* the screen grows: it matches utopia at `minWidth`, and is
* `maxFontSize / minTypeScale^n` at `maxWidth`.
*/
const fluidTypeSteps = (config: UtopiaTypeConfig): FluidTypeStep[] =>
calculateTypeScale(config).map(({ step, label, minFontSize, maxFontSize, clamp }) => {
if (step >= 0) return { step, label, minFontSize, maxFontSize, clamp };
const divisor = config.minTypeScale ** -step;
const narrow = config.minFontSize / divisor;
const wide = config.maxFontSize / divisor;
return {
step,
label,
minFontSize: round(narrow),
maxFontSize: round(wide),
clamp: calculateClamp({
minSize: narrow,
maxSize: wide,
minWidth: config.minWidth,
maxWidth: config.maxWidth,
relativeTo: config.relativeTo,
}),
};
});

/** The sizes of a step in px at `minWidth` and `maxWidth`. */
type StepSizes = Pick<FluidTypeStep, "minFontSize" | "maxFontSize">;

/** 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.`,
Expand Down Expand Up @@ -109,6 +179,12 @@ const assertWcagGrowth = (step: UtopiaStep, label: string, path: string) => {
* };
* const { css } = processTypography(typography);
* ```
*
* Each fluid scale is written twice from the same step sizes. The step tokens
* `typography_fluid.<scale>@<label>` hold one self-contained `clamp()` each.
* The tokens under `typography_fluid.<scale>.pow` hold the scale's inputs as
* plain numbers, three helpers, and the steps derived from them with `pow()`,
* so changing an input on `:root` tunes the whole scale at runtime.
*/
export function processTypography(config: TypographyConfig): Output {
const cssOutput: string[] = [];
Expand All @@ -132,27 +208,94 @@ export function processTypography(config: TypographyConfig): Output {
);
const { prefix, ...utopiaConfig } = value;
if (prefix) validateName(prefix, `${moduleKey}.${scaleName}.prefix`);
const unit = readRelativeUnit(utopiaConfig.relativeTo, `${scalePath}.relativeTo`);

const scale = calculateTypeScale({
const scale = fluidTypeSteps({
labelStyle: settings?.customLabel ? "utopia" : "tshirt",
...utopiaConfig,
});

const resolvedPrefix = prefix ? `${scaleName}-${prefix}` : scaleName;
for (const step of scale) {
const { label, clamp } = step;
const resolvedLabel = settings?.customLabel
? (settings.customLabel[label] ?? label)
: label;
const keyOf = (name: string) => `--${moduleKey}-${resolvedPrefix}-${name}`;
const steps = scale.map((step) => {
const labelPath = `${moduleKey}.${scaleName}.settings.customLabel.${step.label}`;
const label = settings?.customLabel
? (settings.customLabel[step.label] ?? step.label)
: step.label;
// Validate the label that is actually emitted. A `customLabel` may
// resolve through the prototype chain, so iterating own values would
// miss a label that still reaches the generated key.
validateCustomLabel(
resolvedLabel,
`${moduleKey}.${scaleName}.settings.customLabel.${label}`,
validateCustomLabel(label, labelPath);
if (label === "pow") {
throw new Error(
`Invalid configuration at "${labelPath}": "pow" is the segment of the scale's pow tokens, so a step cannot take it as its label.`,
);
}
return { ...step, label };
});

const powPath = `${scalePath}.pow`;
const powKey = (name: string) => keyOf(`pow-${name}`);
const ref = (name: string) => `var(${powKey(name)})`;
const power = (name: string, n: number) =>
n === 1 ? ref(name) : `pow(${ref(name)}, ${n})`;
const powInputs: Array<[string, string]> = [
["min-width", `${utopiaConfig.minWidth / REM}`],
["max-width", `${utopiaConfig.maxWidth / REM}`],
["min-font-size", `${utopiaConfig.minFontSize / REM}`],
["max-font-size", `${utopiaConfig.maxFontSize / REM}`],
["min-type-scale", `${utopiaConfig.minTypeScale}`],
["max-type-scale", `${utopiaConfig.maxTypeScale}`],
[
"progress",
`clamp(0rem, (100${unit} - ${ref("min-width")} * 1rem) / (${ref("max-width")} - ${ref("min-width")}), 1rem)`,
],
["at-min", `calc(${ref("min-font-size")} * (1rem - ${ref("progress")}))`],
["at-max", `calc(${ref("max-font-size")} * ${ref("progress")})`],
];
const stepZero = steps.find(({ step }) => step === 0);
if (!stepZero) {
throw new Error(
`Invalid configuration at "${scalePath}": the scale has no step 0.`,
);
const stepPath = `${scalePath}@${resolvedLabel}`;
assertWcagGrowth(step, resolvedLabel, scalePath);
}
const powStep = (step: number) => {
if (step === 0) return `calc(${ref("at-min")} + ${ref("at-max")})`;
if (step < 0) {
return `calc(${ref(stepZero.label)} / ${power("min-type-scale", -step)})`;
}
return `calc(${ref("at-min")} * ${power("min-type-scale", step)} + ${ref("at-max")} * ${power("max-type-scale", step)})`;
};

// The pow values reference each other by key, so the shared resolver
// finds their reference paths through these aliases.
const powVariables: Variables = Object.fromEntries([
...powInputs.map(([name]) => [powKey(name).slice(2), `${powPath}.${name}`]),
...steps.map(({ label }) => [powKey(label).slice(2), `${powPath}@${label}`]),
]);
const tokenOf = (path: string, key: string, tokenValue: string): ResolvedToken => {
const referencePaths = getReferencePaths({
value: tokenValue,
variables: powVariables,
});
return {
variable: `${key}: ${tokenValue};`,
key,
value: tokenValue,
sourcePath: path,
...(referencePaths ? { referencePaths } : {}),
type: "typography",
tier: referencePaths ? "semantic" : "primitive",
};
};

const clampTokens: ResolvedToken[] = [];
const powTokens = powInputs.map(([name, inputValue]) =>
tokenOf(`${powPath}.${name}`, powKey(name), inputValue),
);
for (const step of steps) {
const stepPath = `${scalePath}@${step.label}`;
assertWcagGrowth(step, step.label, scalePath);
const smallest = Math.min(step.minFontSize, step.maxFontSize);
if (minLegibleSize !== false && smallest < minLegibleSize) {
diagnostics.push({
Expand All @@ -162,17 +305,14 @@ 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",
});
clampTokens.push(tokenOf(stepPath, keyOf(step.label), step.clamp));
powTokens.push(
tokenOf(`${powPath}@${step.label}`, powKey(step.label), powStep(step.step)),
);
}
for (const token of [...clampTokens, ...powTokens]) {
cssOutput.push(token.variable);
resolveMap.set(token.sourcePath, token);
}

// One static step is normal where a scale crosses over, so only a scale
Expand All @@ -183,7 +323,7 @@ export function processTypography(config: TypographyConfig): Output {
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 steps are effectively static. Widen the gap between minFontSize and maxFontSize or between minTypeScale and maxTypeScale, or use fixed sizes.`,
});
}
}
Expand Down
Loading
Loading