---
title: RFC — The `scaling` axis (global zoom), separate from density
type: rfc
audience: human + agent
status: implemented
source: migrated from src/uix/eidos/SCALING_RFC.md (2026-07-02, docs-book F7.4)
---
# RFC — The `scaling` axis (global zoom), separate from density
> **Status: ✅ IMPLEMENTED.** (designed 2026-06-01 · implemented 2026-06-02)
> Born from P1-2: density-driven typography (`content-scale`) was wrongly framed.
> Density and zoom are different things; this RFC introduces zoom as its own axis.
>
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress)
Re-audit of the whole component catalog at pilot depth (91 fichas + the
checkpoint verdicts in docs/audit/components/) and the executed fix packages.
- P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 +
API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d,
component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts
(role=application removed ×4, aria-selected off the Day, drp translationRef,
field data-state prune, pin-input commit-set, media-player renames), 13 new
sema packs + 12 morfos family-default → pack.
- P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar);
onValueCommit terminal-callback norm (pin-input/search/password/textarea +
date/time/color-field add); typed validation reason + onInvalid (tags-input,
css-field); index/onIndexChange (carousel); deselectable; openDelay/
groupSkipDelay; allowCustomValue; defaultValue prune.
- P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across
cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in
field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel);
touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual,
44 AAA). S6 (Field composition) + S8 (calendar-surface) pending.
Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline);
morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched
components; per-component vitest suites green.
Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md
Excluded (broken by the N1 rename, left broken per user decision, not staged):
words/**, palabras/**, chronos, web/routes/alpha/**.
Reconciliation pending: the touch-rows ::before for checkbox/switch reverses
changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed
markers to a labeled-row/Field task — flagged for the user in the handoff.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
> **Code**: `lib/config-types.ts` (`SCALING_KEYS` / `ScalingKey` / `DEFAULT_SCALING` /
> **`ScalingParticipationMap`** — the axis definition, added 2026-07-06),
> `lib/primitives/static.ts` (`STATIC_SCALING` + **`STATIC_SCALING_PARTICIPATION`**,
> the canonical per-family defaults), `lib/render-css.ts`
> (`appendScalingDeclarations` / `appendScaledMetricDeclarations` / `renderScalingBlocks`
> — the emitters consume the map, they never decide participation),
> `active-eidos.svelte.ts` (`scaling` / `scalingSource` / `getScaling()` / `data-scaling`).
> Doc consolidated in [`theming/reference.md`](../theming/reference.md) §23.
> `--density-scale` and `--density-content-scale` were removed in this sprint.
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress)
Re-audit of the whole component catalog at pilot depth (91 fichas + the
checkpoint verdicts in docs/audit/components/) and the executed fix packages.
- P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 +
API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d,
component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts
(role=application removed ×4, aria-selected off the Day, drp translationRef,
field data-state prune, pin-input commit-set, media-player renames), 13 new
sema packs + 12 morfos family-default → pack.
- P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar);
onValueCommit terminal-callback norm (pin-input/search/password/textarea +
date/time/color-field add); typed validation reason + onInvalid (tags-input,
css-field); index/onIndexChange (carousel); deselectable; openDelay/
groupSkipDelay; allowCustomValue; defaultValue prune.
- P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across
cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in
field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel);
touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual,
44 AAA). S6 (Field composition) + S8 (calendar-surface) pending.
Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline);
morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched
components; per-component vitest suites green.
Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md
Excluded (broken by the N1 rename, left broken per user decision, not staged):
words/**, palabras/**, chronos, web/routes/alpha/**.
Reconciliation pending: the touch-rows ::before for checkbox/switch reverses
changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed
markers to a labeled-row/Field task — flagged for the user in the handoff.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
>
> **2026-07-06 — participation became model data.** Which families the zoom
> multiplies is now DECLARED per element (`primitives.scaling`, partial maps
> merge over the canonical defaults) instead of hardcoded in the emitters.
> This also reconciles an unrecorded drift: commit `845d6579` (2026-06-22)
> had made radius "scaling-responsive" against this RFC without updating any
> doc — radius participation is now canonically `false` (chrome family), and
> the original "as in Radix" justification below was corrected (Radix DOES
> scale its radius; crispness is our own rationale).
## 0. Motivation
`scaling` in Radix Themes (90/95/100/105/110%) is a **global zoom** that scales
space, typography, heights and line-box with ONE factor — accessibility/zoom, not
density. Eidos had a dead `--density-content-scale` (±4%) that tried to scale
typography under the density axis, which **conflates density with zoom** :
- **Density** (`compact` / `comfortable` / `spacious` ): tightens layout (space +
control height), **text stays stable** (legibility — Material/Carbon). Already done.
- **Scaling** (this RFC): zoom — scales the px metrics **including typography** .
The two axes are **orthogonal** and **compose** (you can have `compact` + `110%` ).
## 1. Decisions
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress)
Re-audit of the whole component catalog at pilot depth (91 fichas + the
checkpoint verdicts in docs/audit/components/) and the executed fix packages.
- P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 +
API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d,
component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts
(role=application removed ×4, aria-selected off the Day, drp translationRef,
field data-state prune, pin-input commit-set, media-player renames), 13 new
sema packs + 12 morfos family-default → pack.
- P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar);
onValueCommit terminal-callback norm (pin-input/search/password/textarea +
date/time/color-field add); typed validation reason + onInvalid (tags-input,
css-field); index/onIndexChange (carousel); deselectable; openDelay/
groupSkipDelay; allowCustomValue; defaultValue prune.
- P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across
cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in
field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel);
touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual,
44 AAA). S6 (Field composition) + S8 (calendar-surface) pending.
Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline);
morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched
components; per-component vitest suites green.
Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md
Excluded (broken by the N1 rename, left broken per user decision, not staged):
words/**, palabras/**, chronos, web/routes/alpha/**.
Reconciliation pending: the touch-rows ::before for checkbox/switch reverses
changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed
markers to a labeled-row/Field task — flagged for the user in the handoff.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
- **Discrete**, Radix-class axis: `90 / 95 / 100 / 105 / 110` (%). Default `100` .
Projected as `[data-scaling='90']` (blocks, cacheable) + `--scaling` .
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress)
Re-audit of the whole component catalog at pilot depth (91 fichas + the
checkpoint verdicts in docs/audit/components/) and the executed fix packages.
- P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 +
API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d,
component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts
(role=application removed ×4, aria-selected off the Day, drp translationRef,
field data-state prune, pin-input commit-set, media-player renames), 13 new
sema packs + 12 morfos family-default → pack.
- P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar);
onValueCommit terminal-callback norm (pin-input/search/password/textarea +
date/time/color-field add); typed validation reason + onInvalid (tags-input,
css-field); index/onIndexChange (carousel); deselectable; openDelay/
groupSkipDelay; allowCustomValue; defaultValue prune.
- P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across
cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in
field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel);
touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual,
44 AAA). S6 (Field composition) + S8 (calendar-surface) pending.
Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline);
morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched
components; per-component vitest suites green.
Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md
Excluded (broken by the N1 rename, left broken per user decision, not staged):
words/**, palabras/**, chronos, web/routes/alpha/**.
Reconciliation pending: the touch-rows ::before for checkbox/switch reverses
changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed
markers to a labeled-row/Field task — flagged for the user in the handoff.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
- **The axis is DEFINED by a per-element participation map** (user decision
2026-07-06): every orthogonal scale family declares whether the zoom
multiplies it — `ScalingParticipationMap` (`config-types.ts`), canonical
defaults in `STATIC_SCALING_PARTICIPATION` (`primitives/static.ts`). The
generator derives ALL emission from the map; participation is model data
(introspectable, guarded), never an emitter side effect. Canonical taxonomy:
- **METRIC families scale** (what things occupy): `space` ,
`control-height` , `font-size` , `icon-size` , `blur` (blur joined
2026-06-15, §29).
- **CHROME families stay crisp** (what draws the edges): `radius` ,
`border-width` , `shadow` — design-integer px under zoom.
- Excluded outright: `line-height` (a **unitless ratio** ; it already
scales via font-size — multiplying it would **double** the effect),
`z-index` , `opacity` , `motion` (durations/eases; `motion-distance`
declares `false` in the map).
- **Corrected reference note**: Radix Themes DOES scale its radius
(`calc(base × scaling × radius-factor)`). Keeping radius crisp is OUR
decision on our own rationale (chrome crispness + canonical axis
semantics), not Radix parity. Radius keeps its own magnitude knob
(`--radius-factor`) — roundness is themeable; zoom participation is a
system-definition decision (config → regeneration), not a runtime theme
knob.
- Map boundaries: it governs DIRECT multiplication of raw px only — a
value referencing a participating token (a gap on `var(--space-*)` )
scales through the reference; `shadow` is declared for completeness but
is structurally fixed (composite values can't wrap in one `calc()` ).
- **Composition with density** (multiplicative) on the 2 shared metrics:
`--space-N: calc(base × var(--density-space-scale) × var(--scaling))` ,
`--control-height-N: calc(base × var(--density-control-scale) × var(--scaling))` .
- **Prune** `--density-scale` (master) + `--density-content-scale` : they were the
half-baked attempt at what this axis does properly. Reconciles §20.1 (density =
stable text; scaling = zoom includes text).
## 2. Design
**Primitive** (`lib/primitives/static.ts`):
```ts
export const STATIC_SCALING = { '90': 0.9, '95': 0.95, '100': 1, '105': 1.05, '110': 1.1 }
```
Active default: `100` .
**Types** (`config-types.ts`): `SCALING_KEYS = ['90','95','100','105','110']` ,
`ScalingKey` . The density primitive loses `scale` + `contentScale` .
**Emission** (`render-css.ts`):
- `:root` declares `--scaling-90: 0.9 … --scaling-110: 1.1` (constants) and
`--scaling: var(--scaling-100)` (active).
- Blocks `[data-scaling='90'] { --scaling: var(--scaling-90); }` … (the ≠100 ones).
- `font-size` + `icon-size` move from verbatim to `calc(value × var(--scaling))` .
- `space` + `control-height` add `× var(--scaling)` to the `calc` they already have.
- `line-height` , `radius` , `border` , `shadow` , etc.: **unchanged** .
**Runtime** (`active-eidos.svelte.ts`): new `scalingSource` (get/onChange,
mirror of `densitySource` ); `apply()` writes `data-scaling` on the host (next to
`data-mode` / `data-density` ); `dispose()` cleans it up. `EIDOS_SCALING_ATTR` .
**Contract** (`contract.ts`): emits `--scaling` + the constants as knobs.
## 3. Conflicts (resolved)
| Point | Resolution |
|---|---|
| Density × scaling on space/control-height | Multiplicative composition in the `calc` . Orthogonal in intent. |
| line-height (ratio) | Do NOT scale — scales via font-size. |
feat(audit): component re-audit + fixes P0–P5 (P4 complete, P5 in progress)
Re-audit of the whole component catalog at pilot depth (91 fichas + the
checkpoint verdicts in docs/audit/components/) and the executed fix packages.
- P0–P3: doctrine (sema participation, CANON §3 intent, component-guide §5 +
API naming, §32 outline), guards (morfo-vocabulary-check S10/S11d,
component-audit A-3.1/A-1.4/S9, eidos-lint classHooks), morfo contracts
(role=application removed ×4, aria-selected off the Day, drp translationRef,
field data-state prune, pin-input commit-set, media-player renames), 13 new
sema packs + 12 morfos family-default → pack.
- P4 (N1–N10) COMPLETE: selectionMode (select/combobox/toggle-group/calendar);
onValueCommit terminal-callback norm (pin-input/search/password/textarea +
date/time/color-field add); typed validation reason + onInvalid (tags-input,
css-field); index/onIndexChange (carousel); deselectable; openDelay/
groupSkipDelay; allowCustomValue; defaultValue prune.
- P5 (in progress): S1 class-hooks codemod (22 hooks → data-attrs across
cropper/button/image-picker/toggle/image-adjustments); S5 outline remnant in
field.css; depth pass (data-depth=overlay on tooltip/toast/float-panel);
touch-rows (--touch-target token, decoupled ::before hit-slop, area-not-visual,
44 AAA). S6 (Field composition) + S8 (calendar-surface) pending.
Verification: errors-outside-(alpha|words|palabras|chronos) == 59 (baseline);
morfo-vocabulary-check exit 0; eidos-lint class-hooks/invalid 0 on touched
components; per-component vitest suites green.
Handoff + remaining plan: docs/process/continue-audit-fixes-2026-07.md
Excluded (broken by the N1 rename, left broken per user decision, not staged):
words/**, palabras/**, chronos, web/routes/alpha/**.
Reconciliation pending: the touch-rows ::before for checkbox/switch reverses
changelog §37's earlier pseudo-element rejection (WCAG-overlap) that had routed
markers to a labeled-row/Field task — flagged for the user in the handoff.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
| radius/border/shadow/z/opacity/motion | Do NOT scale — chrome/structural families per the participation map. (The original "Radix parity" claim was wrong: Radix scales its radius; this is our own crispness decision.) |
| Dead `--density-scale` + `content-scale` | Pruned — superseded by this axis. |
| `size` prop | Orthogonal — `size` picks the tier, scaling multiplies the tier's value. Composes. |
| Browser zoom | Composes multiplicatively (both px). App-level, distinct. |
| §20.1 (typography doesn't scale with density) | Still true — density doesn't scale it; **scaling does** . Two axes. |
## 4. Implementation plan
1. `STATIC_SCALING` + remove `scale` /`contentScale` from `STATIC_DENSITY` .
2. Types (`SCALING_KEYS`/`ScalingKey`; adjust `DensityPrimitiveSet` ).
3. `render-css` : scaling emission + blocks + `× var(--scaling)` on the 4 families;
prune density scale/content-scale emission.
4. `ActiveEidos` : `scalingSource` + `data-scaling` + cleanup.
5. `contract` : scaling tokens; remove density scale/content-scale.
6. Regenerate `generated/base.css` .
7. Tests: update the shape of `--space-4` /`--control-height` (× scaling); scaled
font-size test; `[data-scaling]` block; `scalingSource` test.
8. Verify (`check` + `vitest src/uix/eidos` + browser) + doc in theming reference.