Skip to content

UI scale

@flyze/lib-core-angular 1.0.0-alpha.34· latest

FlyzeUiScaleService scales the whole UI by one number: the root font size. Every dimension in the Flyze component libraries is authored in rem, so writing documentElement.style.fontSize = '125%' re-lays-out text, control heights, icons and gaps together and proportionally.

scale.setScale(125); // everything authored in rem grows by a quarter
scale.scale; // 125
scale.scale$; // Observable<number>

The service is providedIn: 'root' and needs no setup. It renders nothing, stores nothing, and has no UI of its own — building the setting is the app’s job: guides/scaling-an-app. Why the root font size and why not the theme: ADR 0010.

Metricauthoredat 100%at 125%at 150%
button medium heightrem(24)24px30px36px
its font sizerem(12)12px15px18px
its icon (derived)16px20px24px
its focus ring1.5px1.5px1.5px1.5px

The whole proportional tuple moves, not just the text: 15px text inside a still-24px box would overflow, and the click target would stay physically small on the very display that motivates scaling. Hairlines, focus rings, Angular Material’s own metrics and third-party chrome deliberately stay put — a 1px border at 1.25× renders as a smeared grey line.

Components need no cooperation to render at a new scale: rem resolves against the root at style recalc, so the browser reflows on its own and live preview is free. scale$ exists only for the few places that cached a pixel measurement in TypeScript.

The physical size a reader sees is OS display scaling × browser zoom × browser font-size preference × this scale. Every layer multiplies; none is overridden. That is why the service writes a percentage:

Root ruleDefault reader (16px)Reader who set 20px in the browser
font-size: 125%1rem = 20px1rem = 25px — both preferences apply
font-size: 20px1rem = 20px1rem = 20px — theirs is discarded

Consequence for TypeScript: scale is not a conversion factor. At a neutral 100%, a reader with a 20px browser setting still has 1rem = 20px. Anything that needs a real pixel length reads the resolved value instead:

scale.rootFontSize; // resolved length of 1rem in px, right now
scale.resolvedScaleFactor; // that over 16 — the factor an authored px value really gets

Both are snapshots, not streams, on purpose: the browser preference can change with no event this service could observe, so a stream would go stale silently. Best of all, measure the element you care about (clientHeight) — it is read after the browser resolved rem to px and is therefore correct at any scale, with no scale-awareness in the component.

Rounds to whole percent, clamps to 50–200, writes the DOM, and emits — in that order, and only when the value actually changed.

Three behaviors worth knowing:

  • At neutral it clears the inline style rather than writing 100% (#writeRootFontSize), so an app at 100% is byte-identical to one that never touched scaling and the reader’s own browser setting fully governs the baseline. resetScale() is the same thing, named.
  • A non-finite value is rejected with a warning and changes nothing. Without that guard NaN survives Math.round/min/max, the DOM write becomes 'NaN%' — silently dropped by the CSSOM — and the service would report NaN as the current scale.
  • Out-of-range values are clamped, not rejected. setScale(400) leaves you at 200 and reports 200.

An app is supposed to apply its persisted scale before Angular renders, or every load flashes at neutral and jumps. So on construction the service reads documentElement.style.fontSize:

  • a percentage → adopted (and re-clamped, so a stale 400% is repaired to 200%);
  • anything else, e.g. 20pxleft in place, with a warning. Clearing a value the app set for its own reasons, on mere injection, would be worse than the disagreement it warns about;
  • empty → neutral, nothing written.

This is the one place the service reads the DOM instead of owning it, and it exists so scale can never disagree with what is actually applied.

scale.pxToRem(12); // '0.75rem' — a px value authored at neutral, as a CSS length
scale.remToPx(1); // 20 — resolved at the current scale

pxToRem is the TypeScript counterpart of the component libraries’ SCSS fn.rem() and shares its 16px baseline. It is scale-independent by design — the browser applies the scale when it resolves the rem, so doing it here too would apply it twice. Use it wherever a numeric input means “px at 100%” and the value ends up in a style:

element.style.height = scale.pxToRem(config.minHeight); // 58 -> '3.625rem'

remToPx goes the other way, against the live rootFontSize, for the cases that must hand a plain number to something that cannot take a CSS length — a CDK virtual-scroll itemSize, say. The result is a snapshot: recompute it when scale$ emits.

Both warn and return a zero value ('0rem' / 0) for a non-finite input rather than propagating NaN into layout math.

  • The service claims the inline root font size exclusively. An app must not write documentElement.style.fontSize itself; a html { font-size } rule in a stylesheet is overridden by it whenever the scale is not neutral.
  • No persistence, no boot hook, no UI. All three belong to the app, exactly as the color scheme’s do — see the guide.
  • The 16px baseline is not a design choice. It is what a browser defines 1rem to be at its default setting, and it is the number every rem dimension in the component libraries was converted against ($base-font-size in their styles/_functions.scss). The two must stay equal; redefining FLYZE_UI_SCALE_BASE_FONT_SIZE would resize every component.
  • This library has no rem-authored components of its own to scale. It ships the knob for the component libraries, which must not depend on it — their half of the contract is being rem-clean, nothing more.