Skip to content

[theme] Add enhanceDensity - #48749

Open
siriwatknp wants to merge 119 commits into
mui:masterfrom
siriwatknp:exp/density-button-prototype
Open

[theme] Add enhanceDensity#48749
siriwatknp wants to merge 119 commits into
mui:masterfrom
siriwatknp:exp/density-button-prototype

Conversation

@siriwatknp

@siriwatknp siriwatknp commented Jul 1, 2026

Copy link
Copy Markdown
Member

closes #48746

Preview: https://deploy-preview-48749--material-ui.netlify.app/material-ui/customization/density/

Summary

Adds an opt-in density system to @mui/material.

enhanceDensity(theme, scale?) — coherent control sizing. Out of the box, "medium" is not one size: at default props, controls span 32→56px (Button 36.5, Switch 38, IconButton 40, Checkbox/Radio 42, ToggleButton/Tab 48, outlined TextField 56 — measured, see table below). The enhancer maps every component onto a single named spacing scale so that same-size controls share the same box:

let theme = createTheme({ cssVariables: true });
theme = enhanceDensity(theme); // medium controls → 32px touch target, ladder 4/8/12/16/24/32/48

The scale ships as --mui-spacing-* CSS variables plus a keyed theme.spacing():

theme.spacing('small');   // 'var(--mui-spacing-small, calc(1.5 * var(--mui-spacing, 8px)))' (vars) / '12px' (static)
theme.spacing('-xSmall');  // negation supported

One value sits outside the ladder: touchTarget, the box that medium-size controls converge on. It sizes controls rather than spacing them, so it emits as a plain length — no CSS variable, not a spacing key — while still moving with the same override object.

The same names work in the sx spacing props, so application layout can sit on the scale the components use:

<Box sx={{ p: 'small', gap: 'xSmall', mt: '-xSmall' }} />

Every other CSS property takes the steps through theme.spacing(), which works anywhere and autocompletes.

There are no built-in density modes. A denser or roomier app overrides the ladder with a plain object (documented as copy-paste recipes), and every enhanced component follows:

theme = enhanceDensity(theme, {
  xxSmall: 2, xSmall: 4, small: 8, medium: 12,
  large: 16, xLarge: 24, xxLarge: 32, touchTarget: 24,
});

Not applying the enhancer changes nothing — components keep today's literal defaults, verified by a pixel-parity test suite and visual regression run.

Measured default heights (medium size) before/after:

Button IconButton Switch Checkbox/Radio ToggleButton Tab Outlined input
plain 36.5 40 38 42 48 48 56
enhanced 32 32 32 32 32 32 32

The interactive playground used to develop the mapping (per-component inspection, per-seam knobs, visual debug overlays, pixel-parity harness against master) is kept out of this PR as local tooling to keep the diff reviewable; a public sandbox showcase is on the TODO list.

For Reviewers

49 files: 24 under packages/ — most of it the per-component emission map and its tests — 24 in docs/ for the documentation page and its demos, and one annotation-verifier script.

Design decisions

  • theme.spacing carries the scale — no new theme node. The enhancer wraps the existing spacing function, so step names resolve alongside the numbers and raw CSS it already accepts. Nothing new to learn, and un-keyed calls behave exactly as before.
  • The steps are absolute px, independent of the theme's spacing unit. A scale override is a plain number, which can only mean px — and MUI X reads those same numbers to derive sizes in JS — so the steps it replaces have to be px too. Deriving them from the unit instead split the two apart: on spacing: '0.5rem', { small: 6 } emitted 6px beside medium's calc(2 * 0.5rem), and the overridden step quietly stopped scaling with its neighbours. Every unit shape now works, arrays included — previously the fractional steps had no index to land on, so xxSmall and small resolved to empty strings. On a CSS-variables theme a px-expressible unit still emits calc(<n> * var(--mui-spacing, <unit>)): the same length, reachable from plain CSS.
  • sx takes the step names too. Spacing props used to pass any string straight through, so name resolution had to move into getValue. A theme that never went through the enhancer registers no names and stays byte-identical to today. The prop types are deliberately unchanged — covering them would mean enumerating names per property family.
  • Two small @mui/system additions (createSpacing.ts): SpacingKeyOverrides, the augmentation hook that makes step names autocomplete (same convention as BreakpointOverrides), and spacing.unit, the raw option stored on the function so an enhancer can read it. This is the only public surface added outside @mui/material.
  • The whole component mapping lives in one file (sharedDensityComponents.ts), emitted as theme styleOverrides; the few values CSS can't reach go through defaultProps, with the app's own defaults winning. A user's existing styleOverrides are composed with the emissions, never replaced.
  • One shipped ladder, recipes for the rest. There are no built-in density modes — denser and roomier are plain scale objects an app passes in. defaultDensityScale is barrel-exported as private_defaultDensityScale so a sibling MUI X enhancer can merge those recipes over the canonical numbers.
  • Rework the density docs with the new API

Cost when unused: nothing changes unless enhanceDensity(theme) is called — no new runtime on the default path, and the enhancer tree-shakes when unimported. Un-enhanced parity is verified by a local pixel-parity harness against master; porting the density contract (per-size anchor boxes + unchanged-by-default) into the package test suite is on the TODO list.

Suggested review order: enhanceDensity.tsdensityScale.tscreateSpacing.ts (system) → skim sharedDensityComponents.ts for the emission pattern → tests.

TODO

  • Overall performance benchmark before/after enhanceDensity (render the existing docs templates as the test subject)
  • Documentation — revamp customization/density around the new API; move the size default-props approach to a subtopic
  • Make CI green
  • Port the density contract tests (per-size anchor boxes + unchanged-by-default parity) into packages/mui-material

@siriwatknp siriwatknp added the RFC Request For Comments. label Jul 1, 2026
@siriwatknp
siriwatknp force-pushed the exp/density-button-prototype branch from 59837ad to 5389da7 Compare July 1, 2026 03:54
@siriwatknp siriwatknp changed the title [prototype] CSS-var density adapter (enhanceDensity) + experiment page [prototype] CSS-var density adapter (enhanceDensity) — Button + experiment page Jul 1, 2026
@code-infra-dashboard

code-infra-dashboard Bot commented Jul 1, 2026

Copy link
Copy Markdown

Deploy preview

Bundle size

Bundle Parsed size Gzip size
@mui/material 🔺+25.1KB(+4.70%) 🔺+5.31KB(+3.44%)
@mui/lab 🔺+198B(+0.59%) 🔺+62B(+0.71%)
@mui/private-theming 0B(0.00%) 0B(0.00%)
@mui/system 🔺+261B(+0.38%) 🔺+89B(+0.36%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@siriwatknp siriwatknp changed the title [prototype] CSS-var density adapter (enhanceDensity) — Button + experiment page [WIP][prototype] CSS-var density adapter (enhanceDensity) — Button + experiment page Jul 1, 2026
@siriwatknp siriwatknp changed the title [WIP][prototype] CSS-var density adapter (enhanceDensity) — Button + experiment page [WIP][prototype] CSS-var density adapter (enhanceDensity) Jul 1, 2026
@siriwatknp siriwatknp changed the title [WIP][prototype] CSS-var density adapter (enhanceDensity) [WIP][prototype] density system Jul 1, 2026
@siriwatknp
siriwatknp force-pushed the exp/density-button-prototype branch from 4549eae to 6ce32e1 Compare August 12, 2026 07:37
@siriwatknp
siriwatknp force-pushed the exp/density-button-prototype branch from 97b009b to c9bc7e8 Compare August 27, 2026 03:09
Comment thread packages/mui-material/src/styles/densityScale.ts Outdated
@siriwatknp
siriwatknp force-pushed the exp/density-button-prototype branch from c9bc7e8 to c371ca2 Compare August 27, 2026 04:06
Comment thread packages/mui-material/src/styles/densityScale.ts Outdated
Comment on lines +109 to +126
const spacing = (...args: ReadonlyArray<number | string>): string => {
if (!args.some(isKeyArg)) {
return String(prevSpacing(...args));
}
return args
.map((arg) => {
if (typeof arg === 'string') {
if (isDensityKey(arg)) {
return resolveKey(arg, false);
}
if (arg.startsWith('-') && isDensityKey(arg.slice(1))) {
return resolveKey(arg.slice(1) as DensityKey, true);
}
}
return String(prevSpacing(arg));
})
.join(' ');
};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a way to simplify this, reduce iteration and conditions? make it more performant?

Rewrites customization/density around the opt-in enhancer: usage with a
drag-to-compare demo, the named spacing scale and the augmented
theme.spacing(), scale overrides and recipes, proportional scaling, and
the pre-existing component-props approach kept as a section. Flags the
page as a new feature in the sidebar.
…, spacing via lookup

DensityMultipliers unexported; applyDensity takes Record<DensityKey, number>.
DensityScaleOverrides is px numbers — same type as defaultDensityScale, so X
merges by spread and gridHeights reads steps directly (regex parse + silent
canonical fallback gone); overrides negate to -6px, symmetric with the
multiplier path. Every key + negated pull resolved once per theme, so the
spacing wrapper is a hash lookup — isDensityKey/isKeyArg and the per-arg
DENSITY_KEYS scan dropped.
Removes the --mui-scaling dial: the private applyScaling pass (dial var,
spacing-unit and radius re-emit, typography calc wrapping), its tests, and
the docs section. The step block still resolves against the theme's own
--mui-spacing, so a custom spacing unit is unaffected. Density is now the
only lever in this PR.
sx spacing values short-circuited on strings, so a step name reached the
stylesheet verbatim and did nothing. getValue now resolves names the
transformer advertises via `keys` and passes every other string through as
raw CSS, which covers p/m (with their side and axis forms) and gap, plus
responsive values. On a CSS variables theme the transformer is built from
theme.vars.spacing, so createUnaryUnit routes names back through the
scale-aware spacing function while numbers keep their existing output.

Types: the spacing props now use SpacingPropValue (SpacingKey | number |
(string & {})). A bare `string` member absorbed the key literals during
union reduction, which is why they never autocompleted.
Reverts the sx spacing prop types to their csstype-derived form. Resolving
the step names is worth having; enumerating 43 properties in @mui/system to
autocomplete them is not — the list would have to grow per property family,
and sizing (width/height, where touch-target is most useful) runs through a
different transform with conflicting number semantics, so it could not be
covered the same way. theme.spacing() stays the documented path for every
property the spacing props do not cover.
The interactive box sizes controls rather than spacing them, so it never
belonged on the spacing ladder: it emitted a --mui-spacing-touch-target
variable and resolved through theme.spacing() and the sx spacing props,
where it is meaningless, while sx sizing props (height, the one place it is
useful) could never reach it.

It now ships as a plain px length passed into the shared emissions. The
override signature is unchanged — enhanceDensity(theme, { 'touch-target': 40 })
still moves every control — and private_defaultDensityScale keeps the value
so sibling packages can derive from it. Consequences, both intended: it no
longer tracks a custom spacing unit, and it can no longer be overridden from
CSS at runtime.
…heme-components helpers to utils

The key resolution lives in @mui/system but was only covered through the
density enhancer in @mui/material. Adds tests at the level the behavior
belongs to — spacing (padding/margin), cssGrid (gap) and styleFunctionSx —
driven by a spacing function that advertises names via `keys` rather than by
the density scale, so they test the contract and not the feature. Covered:
resolution on every spacing prop, responsive values, the theme.vars path
where the transformer is a string, raw CSS / typo / multiplier passthrough,
and a transformer that registers nothing. Reverting getValue fails 12 of them.

addRootOverride and addDefaultProps move to src/utils as internal helpers,
kept off the utils barrel like areArraysEqual and contains.
The curtain compared two showcases side by side; the seam cut through
components mid-word because each side rendered at a different width, and
nothing named the value behind any dimension.

One button, one switch instead. Every number is read off the rendered
element, so the annotations track what the theme emits.
Outlined button so the border and padding read as separate bands. The
padding ring and the icon-to-label gap now carry the colors devtools
uses, and each label points at the center of what it measures with one
dashed leader instead of a pair of bracketing guides.
Dashes on the padding band and the icon so each measured region reads
the same way, and the resolved value moves into parentheses after the
name it resolves from.
Scale sizing key, identifiers, docs prose, annotation tokens, and the verify
gate all follow. Also updates the secondary-action gutter assertion to the
tuned 12px value from 89debb4.
},
{
props: { variant: 'filled' },
style: { transform: 'translate(var(--_inlinePad), var(--_restY)) scale(1)' },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The translate(var(--_inlinePad), var(--_restY)) transforms here and at 561/565, and the Switch translateX(calc(var(--_width) ...)) at 711/731, are never mirrored by cssjanus. Its translate handling only matches numeric literals (signedQuantPattern in cssjanus 2.3.1), so with direction: 'rtl' and the documented stylis-plugin-rtl cache, master's literal translate(14px, ...) / translateX(20px) gets flipped but these rules do not, and they win at equal specificity.

In practice: an outlined or filled label at rest sits 14px past the input's right edge, outside the box, and then jumps inside on focus because the literal shrink rule at 569 is flipped. A checked Switch thumb translates rightwards, out of the track. LTR and non-enhanced themes are unaffected.

Options I can see: emit the inline offset through a [dir="rtl"] & (or theme.direction === 'rtl') variant that negates the var, or write --_inlinePad on the FormControl with a sign that flips under RTL. The Switch checked transform needs the same treatment. An RTL render test would catch this class of issue.

{
props: ({ ownerState }: { ownerState: { formControl?: object | undefined } }) =>
!!ownerState.formControl,
style: { transform: 'translate(0, var(--_restY)) scale(1)' },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above says a missing writer "must break visibly", but the only writers for --_restY / --_inlinePad are the .MuiInputLabel-root:has(~ &) rules on OutlinedInput (326), FilledInput (426) and Input (484). Any other input root inside a FormControl is a legitimate case: a bare <InputBase />, a styled(InputBase) custom input like the CustomizedSelects docs demo, or MUI X's PickersTextField.

With those, the formControl variant here produces transform: translate(0, var(--_restY)) scale(1) with the var unset. That is invalid at computed-value time, so the label falls back to transform: none and lands in the top-left corner of the control instead of sitting inside it.

I would give the label a fallback that matches master (var(--_restY, 20px) for the standard rest position, var(--_inlinePad, 14px) for outlined/filled), or scope these variants to the three roots that actually have a writer.

// (empty values) — so the ladder falls back to its canonical 8px basis.
const stepValue: (multiplier: number) => string = Array.isArray((prevSpacing as any).unit)
? (multiplier) => `${multiplier * 8}px`
: (multiplier) => String(prevSpacing(multiplier));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A second application is easy to hit: a nested <ThemeProvider theme={(outer) => enhanceDensity(outer, {...})}>, or an MUI X enhancer that calls enhanceDensity internally on top of the app's own call. With outer = enhanceDensity(createTheme(), { small: 6 }) and an inner enhanceDensity(outer, { 'touch-target': 40 }):

  • stepValue resolves through the first wrapper's numeric path, so theme.spacing('small') reverts to 12px and the 6px override is silently lost.
  • generateStyleSheets() (167) returns two :root step blocks.
  • addRootOverride (addRootOverride.ts:30-34) sees the DENSITY_LAYERS marker and splices a full second copy of every emission before the user tail.
  • addDefaultProps (addDefaultProps.ts:30) keeps the first pass's MuiCircularProgress size 32px, because "user defaults win" now applies to the first pass.

A marker on the theme (or on spacing) that lets a second call reuse the stored scale, merge the new overrides and skip re-emission would cover all four. A test for the twice-applied case would be good as well.

'MuiInputBase',
{
height: 'auto',
paddingBlock: spacing('x-small'),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The standard Input, bare InputBase and FilledInput land on three different heights. With the defaults: <TextField variant="standard" /> renders 8 + 24 + 6 = 38px, <InputBase /> 8 + 24 + 8 = 40px, and <TextField variant="filled" /> stays at master's 56px, while OutlinedInput lands on 32px. The PR table and the demo caption ("same box") say inputs share the target, so a form that mixes variants gets three control heights.

Nothing catches it: scripts/verifyDensityAnnotations.mjs:167 skips height rows that have no token, and enhanceDensity.test.ts never measures an input height. If filled is meant to keep 56px on purpose, the docs should say so, and the standard/bare paths should still derive from touchTarget the way the outlined one does.

props: { size: 'small' },
style: {
[`.${formControlClasses.root}:has(> &)`]: {
'--_outlinedInputPadBlock': spacing('xx-small'),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

size="small" pins --_outlinedInputPadBlock to the fixed xx-small step, while medium derives (touch-target - 1lh) / 2. At the default scale both come out at 4 + 24 + 4 = 32px (master: 40 vs 56). Under the docs' "high" recipe (touch-target: 24, xx-small: 2) medium becomes 24px while small stays 28px, so the order inverts.

Every other sized control derives small from smallBox = touch-target - x-small. The outlined input (and the Autocomplete small variant that reuses this) should probably do the same: (smallBox - 1lh) / 2.

@@ -0,0 +1,1456 @@
import type { Breakpoint } from '..';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only '..' import under packages/*/src outside tests. Why not use import type { Breakpoint } from '@mui/system' (have seen it somewhere else) instead?

Comment thread scripts/verifyDensityAnnotations.mjs Outdated
* exercise it (a `gap` needs children to put a gap between).
*
* Run against the demo-shot Vite host:
* npx vite .demo-harness --port 5099 &

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First time I read about .demo-harness. Is this referenced elsewhere or a dead end?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this file should not be in git, I will remove it.

Comment thread docs/data/material/customization/density/density.md Outdated
expect(theme.components.MuiButton?.styleOverrides?.root).to.not.equal(undefined);
});

test('a user styleOverride on the incoming theme stays the winning layer', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This contract test only checks array order and never renders, so it cannot see the specificity problem in the :has(). Other gaps worth covering given the findings above: applying the enhancer twice, function- and array-valued user styleOverrides, a user slotProps default on the same slot as a density default, cssVarPrefix: '', negative keys through sx (mt: '-x-small'), and a lookup-table spacing function through the "no broken values" walker at 409.

// Fractional multipliers land on holes with array spacing
// (`createTheme({ spacing: [0, 4, 8] })` has no index 0.25) — route them
// through the canonical 8px basis there instead of emitting ''.
const fraction: (multiplier: number) => string = Array.isArray((spacing as any).unit)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same array check and same 8px basis as densityScale.ts:75. If applyDensity exposed its resolver (or attached it to the theme), this file would not need its own copy, and the lookup-table guard only has to land once.

siriwatknp and others added 5 commits September 11, 2026 11:35
- simpler intro, drop per-component px list (implementation detail)
- fix CSS-vars `theme.spacing()` return: carries the px fallback
- "All components" heading, "applied to" wording, icon-size rename artifact
- closed-scale callout info -> warning

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
The default fallback is the computed `calc()`, not a literal px — a plain
length only appears for a step given an explicit value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
xx-small -> xxSmall, x-small -> xSmall, x-large -> xLarge,
xx-large -> xxLarge, touch-target -> touchTarget, icon-size -> iconSize.
CSS vars follow (--mui-spacing-xxSmall), as do negated pulls and sx values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Sep 11, 2026
siriwatknp and others added 9 commits September 11, 2026 16:14
The ladder is fractions of the spacing unit; an array defines none, so the
old guards silently substituted an 8px basis and ignored the user's array.
Warn in dev and return the theme unenhanced, which lets `fraction()` go —
all 17 sites are plain `spacing()` now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
`applyDensity` infers its return; the array-spacing bail returns the input
as-is. `enhanceDensity` no longer promises a non-optional `components` —
it emits for some components, not all, so `theme.components?.*` is right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
A tiny-size component; it doesn't need density adjustments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
`--_arrowSize` replaces the hardcoded 1em/0.71em arrow geometry and
`--_spacing` the 14px/24px placement margins, so a theme retargets one
variable instead of re-declaring every placement selector.

Shrinks the shared density layer by 41 lines (2 added, 43 removed): the
four placement margin rules and the popper arrow re-asserts collapse to a
single `--_spacing` and the arrow slot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
`--_dialogMargin` replaces the hardcoded 32px margin and the five
`calc(100% - 64px)` bounds, so a theme retargets one variable instead of
restating every maxWidth/scroll/fullWidth rule.

Shrinks the shared density layer by 41 lines (1 added, 42 removed): the
whole `fullScreen: false`-scoped variants block — including the generated
per-breakpoint `scroll: 'body'` entries — becomes a single var assignment.
Master's own fullScreen rules now win on their own, so the scoping guard
is no longer needed.

The media-query boundaries stay at master's literal 32*2, since media
queries cannot read custom properties.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
The steps were multiples of the theme's spacing unit while a `scale`
override was a plain number, i.e. px. On a rem theme the two landed in
different families: `{ small: 6 }` emitted `6px` next to `medium`'s
`calc(2 * 0.5rem)`, and the overridden step silently stopped scaling with
its neighbours. An array unit was worse — the fractional steps have no
index to land on, so `xxSmall` and `small` resolved to empty strings and
43 emitted values carried the hole. It was refused outright for that.

The ladder is now absolute px and overrides take the same path, so every
unit shape works: number, `<number>px`, rem/em/%, function, array. On a
vars theme a px-expressible unit still emits `calc(<n> * var(--mui-spacing,
<unit>))` — the same length, but reachable from plain CSS.

`STEP_MULTIPLIERS` and `defaultDensityScale` were the same ladder written
twice (multipliers x 8); `DEFAULT_STEP_PX` is now the single source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
The scale table showed px that were only true at the default unit; it now
reads as absolute values, with a section on how `spacing` relates to the
steps. The annotation tokens follow the emissions off the spacing unit
(`0.75 x spacing` -> `6px`), and a caption whose token already IS the
measurement no longer prints it twice (`6px (6px)` -> `6px`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
@siriwatknp
siriwatknp force-pushed the exp/density-button-prototype branch from 98245a9 to fbf1e49 Compare September 14, 2026 06:32
siriwatknp and others added 6 commits September 14, 2026 13:54
The camelCase scale rename swept the demos too, but `aspect` is the
annotation vocabulary (`padding`/`margin`/`gap`/`icon`/`touch-target`),
not a scale key. `'touchTarget'` never matched `Aspect`; it drew correctly
only because touch-target is the fall-through branch, and `docs/` was not
in the typecheck that ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
Half its fields only applied to one aspect — `after` to gap, `side` to a
band, `outlined`/`pointer`/`wrap` to touch-target — enforced by comment,
so `{ aspect: 'gap', outlined: true }` compiled and silently did nothing.
`resolveClaims` already branches on aspect; the type now says so, and each
branch carries only the knobs it reads. `Aspect` derives from the union so
the vocabulary is declared once.

The touch-target block was an implicit fall-through that would not narrow;
it now guards explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
`densityAnnotations` and `densityAnnotationSpecs` are imported by the
demos, never displayed as one, so their JS twins were ~1,400 lines that
only existed to drift. The demos resolve the TypeScript directly; the
existing `ignoreList` already had the mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
`touchTarget` and `iconSize` emitted as literal px, so a vars theme could
retune every step from plain CSS but not the boxes those steps padded —
the one dimension a dense layout most wants to move. They now ship as
`--<prefix>-touchTarget` and `--<prefix>-iconSize`, under their own names
rather than the spacing namespace: they size a box rather than space one,
and `theme.spacing()` still does not resolve them.

Components emit the reference with the px as its fallback, so a static
theme is unchanged and a `scale` override moves variable and fallback
together. The sizing table moves next to the step table in `densityScale`,
which now owns the whole `:root` emission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
})(
memoTheme(({ theme }) => ({
margin: 32,
'--_dialogMargin': '32px',

@siriwatknp siriwatknp Sep 14, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using to private variables reduce the styleOverrides ~40 LOCs.

{
props: ({ ownerState }) => ownerState.arrow,
style: {
'--_arrowSize': '1em',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using private variables reduces the styleOverrides ~100LOCs

siriwatknp and others added 2 commits September 14, 2026 14:36
Local tooling, like the playground it checks — nothing references it and
it is not wired into CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
The density page is the guide; a second API blurb only gives the scale
names somewhere else to go stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

customization: theme Higher level theming customizability. package: material-ui Specific to Material UI. PR: out-of-date The pull request has merge conflicts and can't be merged. type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RFC] enhanceDensity — normalize components into a consistent scale

6 participants