From ffe175fd3bc4b43a76960f3485b78730e739ff9a Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Fri, 5 Jun 2026 12:41:17 +0900 Subject: [PATCH 01/28] [material-ui] Add CSS-var density adapter experiment (Button) Expose Button padding as overridable CSS vars resolved inline from a (variant,size) lookup; add enhanceDensity to wire tokens to a --mui-density-* scale. Literal-px fallbacks keep the default pixel-identical. Design in CONTEXT.md + docs/adr/0001; demo at /experiments/density-tokens. --- CONTEXT.md | 93 +++++++++ docs/adr/0001-css-var-density-adapter.md | 89 +++++++++ docs/pages/experiments/density-tokens.tsx | 178 ++++++++++++++++++ packages/mui-material/src/Button/Button.js | 38 +++- .../mui-material/src/styles/enhanceDensity.ts | 124 ++++++++++++ packages/mui-material/src/styles/index.d.ts | 1 + packages/mui-material/src/styles/index.js | 1 + 7 files changed, 515 insertions(+), 9 deletions(-) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-css-var-density-adapter.md create mode 100644 docs/pages/experiments/density-tokens.tsx create mode 100644 packages/mui-material/src/styles/enhanceDensity.ts diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000000000..7e07c49a824983 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,93 @@ +# Density (CSS-var adapter) + +How Material UI component dimensions (padding / gap / height) are exposed as +hand-authorable CSS variables so a designer can tune component density — per +component, per size, or holistically — without touching component source, doing +`calc` arithmetic, or riding the single `--mui-spacing` dial. + +This is the **adapter** sibling of the earlier `--mui-spacing`-derived +experiment (`feat/components-theme-spacing`): instead of one global dial, each +dimension is an overridable token whose default is a literal px. + +## Language + +**Component spacing token** (public, base): +A per-component, per-CSS-property variable a designer may set, shape +`--Component-` — PascalCase component, camelCase **logical** CSS +property, unprefixed (e.g. `--Button-paddingInline`, `--Chip-gap`). Matches the +existing component-var convention (`--AppBar-background`). Setting it reflows +that property across every variant and size of the component. +_Avoid_: kebab property (`--Button-padding-inline`), `--mui-`-prefixed component vars, "variable". + +**Sized token** (public, size-specific): +A size-scoped override, shape `--Component--` +(e.g. `--Button-small-paddingInline`). Reflows only that one size. **More +specific than the base token** — when both are set, the sized token wins. +_Avoid_: "size variant token". + +**Internal resolution var**: +A private variable, shape `--_Component-` (leading underscore), +**set via inline style** from the rendered `(variant, size)` and consumed once +in the styled root. It carries the full fallback chain (sized token → base +token → literal). Lowest priority, so any public token or plain `styleOverrides` +property still wins. +_Avoid_: exposing it as API, "private token". + +**Token fallback**: +The literal px at the end of the chain — today's exact value for that +`(variant, size)` cell. Makes the default render pixel-identical and bundle-light, +at the cost that the single `--mui-spacing` dial no longer reflows the component. +_Avoid_: "default value", "initial". + +**Density scale** (tier-1): +A named, ordered set of density steps (`xxs / xs / sm / md / lg …`), values +derived from `theme.spacing`, surfaced as `--mui-density-*` CSS vars. The shared, +designer-facing holistic-density surface. **Emitted by `enhanceDensity`, not +`createTheme`** (runtime opt-in); its **types ship built-in** +(`theme.vars.density.*` always type-checks). +_Avoid_: "spacing scale" (that is `theme.spacing`), "grid". + +**enhanceDensity**: +A single post-`createTheme` function (mirroring `enhanceHighContrast`) that does +**both**: (a) emits the **density scale** as `--mui-density-*` and populates +`theme.vars.density`, and (b) injects per-component `styleOverrides.root` mapping +**component spacing tokens** to density steps +(`--Button-paddingInline: theme.vars.density.md`). `createTheme` is untouched. +Opt-in: without it, components render their literal-px defaults; with it, tuning +the density scale (or scoping `--mui-density-*`) reflows every wired component. +_Avoid_: "density preset" (that is the resulting effect, not the function). + +## Relationships + +- The styled root reads **one** internal resolution var per property; **no JS + conditional** lives in the styles implementation. The `(variant, size)` → px + matrix is a **lookup table** in the component body, applied via inline style. +- Override priority (high → low): plain `styleOverrides` property → **sized + token** → **base token** → internal resolution var (literal fallback). +- Custom (user-defined) sizes work for free: the sized-token name is built from + the runtime size string; no static per-size CSS is emitted. +- **enhanceDensity** (opt-in) connects tier-2 component tokens to the tier-1 + **density scale**; un-enhanced, the literal fallbacks reproduce today's pixels. +- This experiment does **not** ride `--mui-spacing`; holistic density comes from + the density scale, not that dial. + +## Example dialogue + +> **Dev:** "If I set `--Button-paddingInline` on the theme, what happens to a +> small outlined button?" +> **Domain expert:** "It reflows to your value — base token covers every +> variant and size. Unless you also set `--Button-small-paddingInline`; the +> **sized token** is more specific and wins for small." +> **Dev:** "And with nothing set?" +> **Domain expert:** "The **internal resolution var**, set inline from the +> `(variant, size)` cell, falls through to the literal px — pixel-identical to +> today. The `--mui-spacing` dial does nothing here; for holistic density you +> run **enhanceDensity** and tune the **density scale**." + +## Flagged ambiguities + +- "spacing token" meant both a `theme.spacing` key and a per-component value — + resolved: `theme.spacing` is untouched; per-component vars are **component + spacing tokens** (base) and **sized tokens**. +- "spacing scale" (earlier draft, tier-1) — renamed **density scale** and moved + to `theme.density`, to disambiguate from `theme.spacing`. diff --git a/docs/adr/0001-css-var-density-adapter.md b/docs/adr/0001-css-var-density-adapter.md new file mode 100644 index 00000000000000..6361de7ba99cba --- /dev/null +++ b/docs/adr/0001-css-var-density-adapter.md @@ -0,0 +1,89 @@ +# Component density via a CSS-var adapter, resolved inline + +Component dimensions are exposed as public CSS variables with **literal-px +fallbacks**, resolved through an **internal var set by inline style**, instead of +riding the single `--mui-spacing` dial (`feat/components-theme-spacing`) or +emitting a static per-(variant, size) token matrix (`poc/css-vars-map`). + +## Context + +We want designers to tune density — per component, per size, or holistically — +without editing component source, writing `calc`, or accepting that every +dimension reflows off one global `--mui-spacing` value. + +Constraints that shaped the design: + +- **Pixel-identical default.** The un-configured theme must render today's exact + px for every `(variant, size)` cell (Argos zero-diff). +- **No token bloat at build time.** Don't emit a static CSS rule (or named + token) per variant×size×property cell. +- **Support user-provided sizes.** A custom `size` added via the theme must get + the same tunability as built-in sizes. +- **No JS conditionals in the styles implementation.** The `styled()` body must + not branch on `ownerState.size`/`variant` to pick a value. +- **Non-breaking.** Existing variant/size padding and existing + `styleOverrides`/`sx` overrides must keep working unchanged. + +## Decision + +Three token layers per component property, e.g. inline padding on Button: + +- **Base token** `--Button-paddingInline` (public) — reflows all variants/sizes. +- **Sized token** `--Button--paddingInline` (public) — reflows one size; + **more specific than base** (size wins). +- **Internal resolution var** `--_Button-paddingInline` (private, leading + underscore) — set via **inline style** from the rendered `(variant, size)`, + carrying the chain `var(--Button--paddingInline, var(--Button-paddingInline, ))`. + +The styled root has **one** consumption point per property and **no conditional**: + +```js +const ButtonRoot = styled(ButtonBase)({ + paddingInline: 'var(--_Button-paddingInline)', + paddingBlock: 'var(--_Button-paddingBlock)', +}); +``` + +The component body holds the `(variant, size)` → px values as a **lookup table** +(today's exact numbers) and applies them via inline style: + +```js +const [block, inline] = PADDING[variant][size]; +const sizingVars = { + '--_Button-paddingBlock': `var(--Button-${size}-paddingBlock, var(--Button-paddingBlock, ${block}))`, + '--_Button-paddingInline': `var(--Button-${size}-paddingInline, var(--Button-paddingInline, ${inline}))`, +}; + +``` + +Holistic density is a separate, opt-in layer driven by a **single** +`enhanceDensity(theme)` function (mirroring `enhanceHighContrast`) that does +both jobs: it **emits** the density scale as `--mui-density-*` (and populates +`theme.vars.density`), and **maps** base tokens to density steps via injected +`styleOverrides.root` (`--Button-paddingInline: var(--mui-density-md)`). +`createTheme` is left untouched. Types for `theme.vars.density` ship built-in; +the vars exist at runtime only after `enhanceDensity` runs. + +We considered making `density` a first-class `createTheme` node so the normal +css-var generator emits the vars. That is more "correct" (the vars participate +in the standard generation and can be re-scoped at any level), but it requires +`createTheme`/css-vars surgery. For an experiment we chose the self-contained +function: easy to A/B, easy to delete, no core change. The cost is that +post-hoc-emitted vars live outside the standard `theme.vars` pipeline. + +Scope: **Button only** for this experiment. + +## Consequences + +- **Pixel-identical default & non-breaking.** Literals come from the lookup + table; the internal var is the lowest-priority fallback, so public tokens, + `styleOverrides`, and `sx` all still win via the cascade. +- **Custom sizes work for free** — the sized-token name is built from the + runtime size string; nothing static is emitted per size. +- **Inline style is the price.** Every instance carries a `style` attr with the + resolution vars (larger HTML, no CSS dedup of those values) — accepted to kill + the static token matrix and support arbitrary sizes. +- **No `--mui-spacing` reflow.** Components opt out of the global dial; holistic + density flows through the density scale + `enhanceDensity` instead. +- **calc resolves only in a real browser** (jsdom does not), so density + assertions belong in browser/visual tests, not jsdom unit tests. diff --git a/docs/pages/experiments/density-tokens.tsx b/docs/pages/experiments/density-tokens.tsx new file mode 100644 index 00000000000000..ec6381b6178e48 --- /dev/null +++ b/docs/pages/experiments/density-tokens.tsx @@ -0,0 +1,178 @@ +'use client'; +import * as React from 'react'; +import { createTheme, ThemeProvider, enhanceDensity } from '@mui/material/styles'; +import CssBaseline from '@mui/material/CssBaseline'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Paper from '@mui/material/Paper'; +import Slider from '@mui/material/Slider'; +import Stack from '@mui/material/Stack'; +import Switch from '@mui/material/Switch'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; + +// Density experiment — CSS-var adapter (docs/adr/0001-css-var-density-adapter.md). +// Button consumes `var(--_Button-padding*)`, resolved inline from a (variant, +// size) lookup through `var(--Button--prop, var(--Button-prop, ))`. +// `enhanceDensity` wires `--Button-*` to the `--mui-density-*` scale. + +const VARIANTS = ['text', 'outlined', 'contained'] as const; +const SIZES = ['small', 'medium', 'large'] as const; + +const theme = enhanceDensity(createTheme({ cssVariables: true })); + +function ButtonMatrix() { + return ( + + {VARIANTS.map((variant) => ( + + {SIZES.map((size) => ( + + ))} + + ))} + + ); +} + +function Panel({ + title, + caption, + style, + children, +}: { + title: string; + caption: string; + style?: React.CSSProperties; + children: React.ReactNode; +}) { + return ( + + {title} + + {caption} + + {children} + + ); +} + +export default function DensityTokens() { + // --mui-density-* live retune (overrides the scale at this scope). + const [densityXs, setDensityXs] = React.useState(6); + const [densityLg, setDensityLg] = React.useState(16); + // Per-token overrides (granular, base + sized). + const [baseInline, setBaseInline] = React.useState(''); + const [smallInline, setSmallInline] = React.useState(''); + + const densityScope: React.CSSProperties = { + // Retunes every enhanced button without rebuilding the theme. + ['--mui-density-xs' as any]: `${densityXs}px`, + ['--mui-density-lg' as any]: `${densityLg}px`, + }; + + const tokenScope: React.CSSProperties = { + ...(baseInline ? { ['--Button-paddingInline' as any]: baseInline } : null), + ...(smallInline ? { ['--Button-small-paddingInline' as any]: smallInline } : null), + }; + + return ( + + + + + + Density tokens — CSS-var adapter + + + Button padding is exposed as --Button-paddingInline /{' '} + --Button-paddingBlock (base), --Button-<size>-paddingInline{' '} + (sized, wins over base), with a literal-px fallback so the default is pixel-identical.{' '} + enhanceDensity wires the base tokens to the{' '} + --mui-density-* scale. + + + + + + + + + + + + + + Density scale + + --mui-density-xs (base block): {densityXs}px + setDensityXs(value as number)} + /> + + + + --mui-density-lg (base inline): {densityLg}px + + setDensityLg(value as number)} + /> + + + + + + + Per-token override (granular) + setBaseInline(event.target.value)} + /> + setSmallInline(event.target.value)} + /> + + + Scoped preview + + + + + + + + enhanceDensity toggle + + } + label="enhanceDensity is applied to this page's theme (createTheme is untouched)." + /> + + + ); +} diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index cbed494f1e7399..53277e3074a57d 100644 --- a/packages/mui-material/src/Button/Button.js +++ b/packages/mui-material/src/Button/Button.js @@ -73,6 +73,15 @@ const commonIconStyles = [ }, ]; +// [block, inline] padding per (variant, size), in px — today's exact values. +// Resolved to `--_Button-padding*` via inline style so the (variant, size) +// matrix needs no static CSS and custom sizes work; see docs/adr/0001. +const buttonPadding = { + text: { small: ['4px', '5px'], medium: ['6px', '8px'], large: ['8px', '11px'] }, + outlined: { small: ['3px', '9px'], medium: ['5px', '15px'], large: ['7px', '21px'] }, + contained: { small: ['4px', '10px'], medium: ['6px', '16px'], large: ['8px', '22px'] }, +}; + const ButtonRoot = styled(ButtonBase, { shouldForwardProp: (prop) => rootShouldForwardProp(prop) || prop === 'classes', name: 'MuiButton', @@ -100,7 +109,9 @@ const ButtonRoot = styled(ButtonBase, { return { ...theme.typography.button, minWidth: 64, - padding: '6px 16px', + // `--_Button-padding*` are set via inline style from the (variant, size) + // lookup; the literals here are only a safety fallback (medium contained). + padding: 'var(--_Button-paddingBlock, 6px) var(--_Button-paddingInline, 16px)', border: 0, borderRadius: (theme.vars || theme).shape.borderRadius, transition: theme.transitions.create( @@ -145,7 +156,6 @@ const ButtonRoot = styled(ButtonBase, { { props: { variant: 'outlined' }, style: { - padding: '5px 15px', border: '1px solid currentColor', borderColor: `var(--variant-outlinedBorder, currentColor)`, backgroundColor: `var(--variant-outlinedBg)`, @@ -158,7 +168,6 @@ const ButtonRoot = styled(ButtonBase, { { props: { variant: 'text' }, style: { - padding: '6px 8px', color: `var(--variant-textColor)`, backgroundColor: `var(--variant-textBg)`, }, @@ -225,7 +234,6 @@ const ButtonRoot = styled(ButtonBase, { variant: 'text', }, style: { - padding: '4px 5px', fontSize: theme.typography.pxToRem(13), }, }, @@ -235,7 +243,6 @@ const ButtonRoot = styled(ButtonBase, { variant: 'text', }, style: { - padding: '8px 11px', fontSize: theme.typography.pxToRem(15), }, }, @@ -245,7 +252,6 @@ const ButtonRoot = styled(ButtonBase, { variant: 'outlined', }, style: { - padding: '3px 9px', fontSize: theme.typography.pxToRem(13), }, }, @@ -255,7 +261,6 @@ const ButtonRoot = styled(ButtonBase, { variant: 'outlined', }, style: { - padding: '7px 21px', fontSize: theme.typography.pxToRem(15), }, }, @@ -265,7 +270,6 @@ const ButtonRoot = styled(ButtonBase, { variant: 'contained', }, style: { - padding: '4px 10px', fontSize: theme.typography.pxToRem(13), }, }, @@ -275,7 +279,6 @@ const ButtonRoot = styled(ButtonBase, { variant: 'contained', }, style: { - padding: '8px 22px', fontSize: theme.typography.pxToRem(15), }, }, @@ -550,6 +553,18 @@ const Button = React.forwardRef(function Button(inProps, ref) { const classes = useUtilityClasses(ownerState); + // Resolve the (variant, size) padding cell to the internal vars. Each falls + // through: sized token -> base token -> literal, so overriding either public + // token at any scope reflows the button (see docs/adr/0001). Unknown variant + // falls back to the root default, unknown size to the variant's medium. + const variantPadding = buttonPadding[variant]; + const [paddingBlock, paddingInline] = (variantPadding && + (variantPadding[size] || variantPadding.medium)) || ['6px', '16px']; + const densityVars = { + '--_Button-paddingBlock': `var(--Button-${size}-paddingBlock, var(--Button-paddingBlock, ${paddingBlock}))`, + '--_Button-paddingInline': `var(--Button-${size}-paddingInline, var(--Button-paddingInline, ${paddingInline}))`, + }; + const startIcon = (startIconProp || (loading && loadingPosition === 'start')) && ( {startIconProp || ( @@ -602,6 +617,7 @@ const Button = React.forwardRef(function Button(inProps, ref) { type={type} id={loading ? loadingId : idProp} {...other} + style={{ ...densityVars, ...other.style }} classes={forwardedClasses} > {startIcon} @@ -721,6 +737,10 @@ Button.propTypes /* remove-proptypes */ = { * Element placed before the children. */ startIcon: PropTypes.node, + /** + * @ignore + */ + style: PropTypes.object, /** * The system prop that allows defining system overrides as well as additional CSS styles. */ diff --git a/packages/mui-material/src/styles/enhanceDensity.ts b/packages/mui-material/src/styles/enhanceDensity.ts new file mode 100644 index 00000000000000..6d37be4d2769c5 --- /dev/null +++ b/packages/mui-material/src/styles/enhanceDensity.ts @@ -0,0 +1,124 @@ +import { Theme } from './createTheme'; + +/** + * Named density steps, surfaced as `--mui-density-*` CSS vars. Components wired + * by `enhanceDensity` pull their spacing tokens from these. + */ +export interface DensityScale { + xxs: string; + xs: string; + sm: string; + md: string; + lg: string; + xl: string; +} + +export interface DensityOptions { + /** + * Override any density step. Defaults derive from `theme.spacing`. + */ + scale?: Partial | undefined; +} + +type DensityKey = keyof DensityScale; + +const densityKeys: DensityKey[] = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl']; + +// Default scale: t-shirt steps derived from the theme spacing unit. +const defaultMultiplier: Record = { + xxs: 0.5, + xs: 0.75, + sm: 1, + md: 1.5, + lg: 2, + xl: 3, +}; + +const cssVar = (key: DensityKey) => `--mui-density-${key}`; + +/** + * Enhances a created theme with a holistic density layer. + * + * Does both jobs in one call (mirroring `enhanceHighContrast`): + * 1. **Emits** the density scale as `--mui-density-*` CSS vars at `:root` + * (via `MuiCssBaseline` — requires ``) and exposes them on + * `theme.density` / `theme.vars.density`. + * 2. **Maps** each component's spacing tokens to a density step through injected + * `styleOverrides.root` (e.g. `--Button-paddingInline: var(--mui-density-lg)`). + * + * `createTheme` is left untouched; without this function components render their + * literal-px defaults. See `docs/adr/0001-css-var-density-adapter.md`. + * + * @param themeInput - The created theme to enhance. + * @param options - Override the density scale. + * @returns The enhanced theme. + * + * @example + * const theme = enhanceDensity(createTheme({ cssVariables: true })); + * + * @example + * const theme = enhanceDensity(createTheme(), { scale: { lg: '12px' } }); + */ +export default function enhanceDensity< + T extends { + spacing: (value: number) => string | number; + components?: Theme['components'] | undefined; + vars?: Record | undefined; + }, +>(themeInput: T, options?: DensityOptions): T & { density: DensityScale } { + const scale = densityKeys.reduce((acc, key) => { + acc[key] = options?.scale?.[key] ?? String(themeInput.spacing(defaultMultiplier[key])); + return acc; + }, {} as DensityScale); + + const rootVars = densityKeys.reduce>((acc, key) => { + acc[cssVar(key)] = scale[key]; + return acc; + }, {}); + + const varRefs = densityKeys.reduce((acc, key) => { + acc[key] = `var(${cssVar(key)})`; + return acc; + }, {} as DensityScale); + + const theme = { ...themeInput } as T & { density: DensityScale }; + theme.density = scale; + theme.vars = { ...themeInput.vars, density: varRefs }; + + const c = themeInput.components; + const existingBaseline = c?.MuiCssBaseline?.styleOverrides; + const baselineObject = + existingBaseline && typeof existingBaseline === 'object' ? existingBaseline : undefined; + + theme.components = { + ...c, + MuiCssBaseline: { + ...c?.MuiCssBaseline, + styleOverrides: { + ...baselineObject, + ':root': { + ...(baselineObject as any)?.[':root'], + ...rootVars, + }, + }, + }, + MuiButton: { + ...c?.MuiButton, + styleOverrides: { + ...c?.MuiButton?.styleOverrides, + root: [ + c?.MuiButton?.styleOverrides?.root, + { + // Base tokens cover every size, so density flattens the size matrix + // to one comfortable value (set sized tokens to keep per-size + // steps). Medium block/inline map to xs/lg by default. + '--Button-paddingBlock': varRefs.xs, + '--Button-paddingInline': varRefs.lg, + }, + ], + }, + }, + }; + + return theme; +} diff --git a/packages/mui-material/src/styles/index.d.ts b/packages/mui-material/src/styles/index.d.ts index 1241e52782be10..90e00f9a7207ca 100644 --- a/packages/mui-material/src/styles/index.d.ts +++ b/packages/mui-material/src/styles/index.d.ts @@ -9,6 +9,7 @@ export { CssThemeVariables, } from './createTheme'; export { default as enhanceHighContrast, HighContrastTokens } from './enhanceHighContrast'; +export { default as enhanceDensity, DensityScale, DensityOptions } from './enhanceDensity'; export { default as adaptV4Theme, DeprecatedThemeOptions } from './adaptV4Theme'; export { Shadows } from './shadows'; export { ZIndex } from './zIndex'; diff --git a/packages/mui-material/src/styles/index.js b/packages/mui-material/src/styles/index.js index b9dfa4913c7d61..c78e02b9604d71 100644 --- a/packages/mui-material/src/styles/index.js +++ b/packages/mui-material/src/styles/index.js @@ -26,6 +26,7 @@ export function experimental_sx() { } export { default as createTheme } from './createTheme'; export { default as enhanceHighContrast } from './enhanceHighContrast'; +export { default as enhanceDensity } from './enhanceDensity'; export { default as unstable_createMuiStrictModeTheme } from './createMuiStrictModeTheme'; export { default as createStyles } from './createStyles'; export { getUnit as unstable_getUnit, toUnitless as unstable_toUnitless } from './cssUtils'; From 40ea056a325d66ad21d6172de2b6bcb728290fc3 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Fri, 5 Jun 2026 12:51:49 +0900 Subject: [PATCH 02/28] [docs] Fix density experiment CI: Head description, prettier, vale prose --- CONTEXT.md | 8 ++++---- docs/adr/0001-css-var-density-adapter.md | 6 +++--- docs/pages/experiments/density-tokens.tsx | 17 +++++++++++------ 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 7e07c49a824983..80e2b1d3f25e8a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # Density (CSS-var adapter) -How Material UI component dimensions (padding / gap / height) are exposed as +How Material UI component dimensions (padding / gap / height) are exposed as hand-authorable CSS variables so a designer can tune component density — per component, per size, or holistically — without touching component source, doing `calc` arithmetic, or riding the single `--mui-spacing` dial. @@ -14,14 +14,14 @@ dimension is an overridable token whose default is a literal px. **Component spacing token** (public, base): A per-component, per-CSS-property variable a designer may set, shape `--Component-` — PascalCase component, camelCase **logical** CSS -property, unprefixed (e.g. `--Button-paddingInline`, `--Chip-gap`). Matches the +property, unprefixed (for example `--Button-paddingInline`, `--Chip-gap`). Matches the existing component-var convention (`--AppBar-background`). Setting it reflows that property across every variant and size of the component. _Avoid_: kebab property (`--Button-padding-inline`), `--mui-`-prefixed component vars, "variable". **Sized token** (public, size-specific): A size-scoped override, shape `--Component--` -(e.g. `--Button-small-paddingInline`). Reflows only that one size. **More +(for example `--Button-small-paddingInline`). Reflows only that one size. **More specific than the base token** — when both are set, the sized token wins. _Avoid_: "size variant token". @@ -59,7 +59,7 @@ _Avoid_: "density preset" (that is the resulting effect, not the function). ## Relationships -- The styled root reads **one** internal resolution var per property; **no JS +- The styled root reads **one** internal resolution var per property; **no JavaScript conditional** lives in the styles implementation. The `(variant, size)` → px matrix is a **lookup table** in the component body, applied via inline style. - Override priority (high → low): plain `styleOverrides` property → **sized diff --git a/docs/adr/0001-css-var-density-adapter.md b/docs/adr/0001-css-var-density-adapter.md index 6361de7ba99cba..a7edd5c13c9b51 100644 --- a/docs/adr/0001-css-var-density-adapter.md +++ b/docs/adr/0001-css-var-density-adapter.md @@ -19,14 +19,14 @@ Constraints that shaped the design: token) per variant×size×property cell. - **Support user-provided sizes.** A custom `size` added via the theme must get the same tunability as built-in sizes. -- **No JS conditionals in the styles implementation.** The `styled()` body must +- **No JavaScript conditionals in the styles implementation.** The `styled()` body must not branch on `ownerState.size`/`variant` to pick a value. - **Non-breaking.** Existing variant/size padding and existing `styleOverrides`/`sx` overrides must keep working unchanged. ## Decision -Three token layers per component property, e.g. inline padding on Button: +Three token layers per component property, for example inline padding on Button: - **Base token** `--Button-paddingInline` (public) — reflows all variants/sizes. - **Sized token** `--Button--paddingInline` (public) — reflows one size; @@ -53,7 +53,7 @@ const sizingVars = { '--_Button-paddingBlock': `var(--Button-${size}-paddingBlock, var(--Button-paddingBlock, ${block}))`, '--_Button-paddingInline': `var(--Button-${size}-paddingInline, var(--Button-paddingInline, ${inline}))`, }; - +; ``` Holistic density is a separate, opt-in layer driven by a **single** diff --git a/docs/pages/experiments/density-tokens.tsx b/docs/pages/experiments/density-tokens.tsx index ec6381b6178e48..00cc00dc4c322a 100644 --- a/docs/pages/experiments/density-tokens.tsx +++ b/docs/pages/experiments/density-tokens.tsx @@ -84,17 +84,20 @@ export default function DensityTokens() { return ( - + Density tokens — CSS-var adapter Button padding is exposed as --Button-paddingInline /{' '} - --Button-paddingBlock (base), --Button-<size>-paddingInline{' '} - (sized, wins over base), with a literal-px fallback so the default is pixel-identical.{' '} - enhanceDensity wires the base tokens to the{' '} - --mui-density-* scale. + --Button-paddingBlock (base),{' '} + --Button-<size>-paddingInline (sized, wins over base), with a + literal-px fallback so the default is pixel-identical. enhanceDensity wires + the base tokens to the --mui-density-* scale. @@ -117,7 +120,9 @@ export default function DensityTokens() { Density scale - --mui-density-xs (base block): {densityXs}px + + --mui-density-xs (base block): {densityXs}px + Date: Fri, 5 Jun 2026 12:55:30 +0900 Subject: [PATCH 03/28] [docs] Fix Button a11y snapshot (avoid-inline-spacing) + mobile layout of density experiment --- .../components/buttons/buttons.a11y.json | 8 ++++++ docs/pages/experiments/density-tokens.tsx | 27 +++++++++++-------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/docs/data/material/components/buttons/buttons.a11y.json b/docs/data/material/components/buttons/buttons.a11y.json index c440f41f0f264e..146595d0af9bad 100644 --- a/docs/data/material/components/buttons/buttons.a11y.json +++ b/docs/data/material/components/buttons/buttons.a11y.json @@ -21,6 +21,10 @@ "status": "pass", "tags": ["wcag2a"] }, + "avoid-inline-spacing": { + "status": "pass", + "tags": ["wcag21aa"] + }, "button-name": { "status": "pass", "tags": ["wcag2a"] @@ -61,6 +65,10 @@ "status": "pass", "tags": ["wcag2a"] }, + "avoid-inline-spacing": { + "status": "pass", + "tags": ["wcag21aa"] + }, "button-name": { "status": "pass", "tags": ["wcag2a"] diff --git a/docs/pages/experiments/density-tokens.tsx b/docs/pages/experiments/density-tokens.tsx index 00cc00dc4c322a..e7a62a057b0495 100644 --- a/docs/pages/experiments/density-tokens.tsx +++ b/docs/pages/experiments/density-tokens.tsx @@ -26,15 +26,20 @@ const theme = enhanceDensity(createTheme({ cssVariables: true })); function ButtonMatrix() { return ( - + {VARIANTS.map((variant) => ( - - {SIZES.map((size) => ( - - ))} - + + + {variant} + + + {SIZES.map((size) => ( + + ))} + + ))} ); @@ -52,7 +57,7 @@ function Panel({ children: React.ReactNode; }) { return ( - + {title} {caption} @@ -117,7 +122,7 @@ export default function DensityTokens() { - + Density scale @@ -145,7 +150,7 @@ export default function DensityTokens() { - + Per-token override (granular) Date: Fri, 5 Jun 2026 16:03:25 +0900 Subject: [PATCH 04/28] [material-ui] Shorten Button internal density var to unprefixed --_padding* --- CONTEXT.md | 14 ++++++++------ docs/adr/0001-css-var-density-adapter.md | 10 +++++----- docs/pages/experiments/density-tokens.tsx | 2 +- packages/mui-material/src/Button/Button.js | 12 +++++++----- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 80e2b1d3f25e8a..bb6361ffbe3504 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -26,12 +26,14 @@ specific than the base token** — when both are set, the sized token wins. _Avoid_: "size variant token". **Internal resolution var**: -A private variable, shape `--_Component-` (leading underscore), -**set via inline style** from the rendered `(variant, size)` and consumed once -in the styled root. It carries the full fallback chain (sized token → base -token → literal). Lowest priority, so any public token or plain `styleOverrides` -property still wins. -_Avoid_: exposing it as API, "private token". +A private variable, shape `--_` (leading underscore, **no component +prefix**), **set via inline style** from the rendered `(variant, size)` and +consumed once in the styled root of the same element. It carries the full +fallback chain (sized token → base token → literal). No prefix is needed because +the reader is co-located with the inline setter, so an ancestor's value never +bleeds into a descendant. Lowest priority, so any public token or plain +`styleOverrides` property still wins. +_Avoid_: exposing it as API, prefixing with the component name, "private token". **Token fallback**: The literal px at the end of the chain — today's exact value for that diff --git a/docs/adr/0001-css-var-density-adapter.md b/docs/adr/0001-css-var-density-adapter.md index a7edd5c13c9b51..848e004cc37bf6 100644 --- a/docs/adr/0001-css-var-density-adapter.md +++ b/docs/adr/0001-css-var-density-adapter.md @@ -31,7 +31,7 @@ Three token layers per component property, for example inline padding on Button: - **Base token** `--Button-paddingInline` (public) — reflows all variants/sizes. - **Sized token** `--Button--paddingInline` (public) — reflows one size; **more specific than base** (size wins). -- **Internal resolution var** `--_Button-paddingInline` (private, leading +- **Internal resolution var** `--_paddingInline` (private, leading underscore) — set via **inline style** from the rendered `(variant, size)`, carrying the chain `var(--Button--paddingInline, var(--Button-paddingInline, ))`. @@ -39,8 +39,8 @@ The styled root has **one** consumption point per property and **no conditional* ```js const ButtonRoot = styled(ButtonBase)({ - paddingInline: 'var(--_Button-paddingInline)', - paddingBlock: 'var(--_Button-paddingBlock)', + paddingInline: 'var(--_paddingInline)', + paddingBlock: 'var(--_paddingBlock)', }); ``` @@ -50,8 +50,8 @@ The component body holds the `(variant, size)` → px values as a **lookup table ```js const [block, inline] = PADDING[variant][size]; const sizingVars = { - '--_Button-paddingBlock': `var(--Button-${size}-paddingBlock, var(--Button-paddingBlock, ${block}))`, - '--_Button-paddingInline': `var(--Button-${size}-paddingInline, var(--Button-paddingInline, ${inline}))`, + '--_paddingBlock': `var(--Button-${size}-paddingBlock, var(--Button-paddingBlock, ${block}))`, + '--_paddingInline': `var(--Button-${size}-paddingInline, var(--Button-paddingInline, ${inline}))`, }; ; ``` diff --git a/docs/pages/experiments/density-tokens.tsx b/docs/pages/experiments/density-tokens.tsx index e7a62a057b0495..e67a73f8f22d16 100644 --- a/docs/pages/experiments/density-tokens.tsx +++ b/docs/pages/experiments/density-tokens.tsx @@ -15,7 +15,7 @@ import Typography from '@mui/material/Typography'; import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; // Density experiment — CSS-var adapter (docs/adr/0001-css-var-density-adapter.md). -// Button consumes `var(--_Button-padding*)`, resolved inline from a (variant, +// Button consumes `var(--_padding*)`, resolved inline from a (variant, // size) lookup through `var(--Button--prop, var(--Button-prop, ))`. // `enhanceDensity` wires `--Button-*` to the `--mui-density-*` scale. diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index 53277e3074a57d..6d995eaf30ddde 100644 --- a/packages/mui-material/src/Button/Button.js +++ b/packages/mui-material/src/Button/Button.js @@ -109,9 +109,11 @@ const ButtonRoot = styled(ButtonBase, { return { ...theme.typography.button, minWidth: 64, - // `--_Button-padding*` are set via inline style from the (variant, size) - // lookup; the literals here are only a safety fallback (medium contained). - padding: 'var(--_Button-paddingBlock, 6px) var(--_Button-paddingInline, 16px)', + // `--_padding*` are set via inline style from the (variant, size) lookup; + // the literals here are only a safety fallback (medium contained). The + // internal var is unprefixed — it's read on the same element that sets it + // inline, so no component prefix is needed to avoid inheritance bleed. + padding: 'var(--_paddingBlock, 6px) var(--_paddingInline, 16px)', border: 0, borderRadius: (theme.vars || theme).shape.borderRadius, transition: theme.transitions.create( @@ -561,8 +563,8 @@ const Button = React.forwardRef(function Button(inProps, ref) { const [paddingBlock, paddingInline] = (variantPadding && (variantPadding[size] || variantPadding.medium)) || ['6px', '16px']; const densityVars = { - '--_Button-paddingBlock': `var(--Button-${size}-paddingBlock, var(--Button-paddingBlock, ${paddingBlock}))`, - '--_Button-paddingInline': `var(--Button-${size}-paddingInline, var(--Button-paddingInline, ${paddingInline}))`, + '--_paddingBlock': `var(--Button-${size}-paddingBlock, var(--Button-paddingBlock, ${paddingBlock}))`, + '--_paddingInline': `var(--Button-${size}-paddingInline, var(--Button-paddingInline, ${paddingInline}))`, }; const startIcon = (startIconProp || (loading && loadingPosition === 'start')) && ( From 2748d1cc2b2461d37c667c7eee0b26740b5af3c3 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Sun, 7 Jun 2026 13:31:00 +0700 Subject: [PATCH 05/28] [material-ui] Refine Button density adapter: variants-resolved --_pad + --Button-pad seam - Root consumes var(--Button-pad, var(--_pad)); --_pad universal default on root - (variant,size) literals + built-in-size routing live in variants (deduped CSS) - Inline bridge only for custom sizes (keeps custom sizes tunable, zero inline for built-ins) - Two-var rationale + accepted trade-offs documented in ADR-0001 + CONTEXT - enhanceDensity maps sized tokens (--Button--pad) to density scale --- CONTEXT.md | 112 +++++++++----- docs/adr/0001-css-var-density-adapter.md | 143 +++++++++++++----- docs/pages/experiments/density-tokens.tsx | 57 ++++--- packages/mui-material/src/Button/Button.js | 61 +++++--- .../mui-material/src/styles/enhanceDensity.ts | 14 +- 5 files changed, 254 insertions(+), 133 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index bb6361ffbe3504..bbd23f85b2f781 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -9,34 +9,53 @@ This is the **adapter** sibling of the earlier `--mui-spacing`-derived experiment (`feat/components-theme-spacing`): instead of one global dial, each dimension is an overridable token whose default is a literal px. +## Layers + +The component is read as **three layers of responsibility**, each owning a slice +of one cascade: + +- **Agnostic** — the styled root, no design meaning (no size/variant/color). Its + spacing surface is one public var it consumes directly, falling back to the + internal default (`padding: var(--Button-pad, var(--_pad))`). +- **Material UI** — Material Design's sizes/variants, all in `variants`: the + `(variant, size)` literal defaults (`--_pad`) and the sized-token routing + (`--Button-pad`) for built-in sizes. A custom size routes inline instead (it + needs the runtime size string). +- **Design system** — tunes the public **sized tokens** the Material UI layer + routes over its default (wired via `enhanceDensity`). + ## Language -**Component spacing token** (public, base): -A per-component, per-CSS-property variable a designer may set, shape -`--Component-` — PascalCase component, camelCase **logical** CSS -property, unprefixed (for example `--Button-paddingInline`, `--Chip-gap`). Matches the -existing component-var convention (`--AppBar-background`). Setting it reflows -that property across every variant and size of the component. -_Avoid_: kebab property (`--Button-padding-inline`), `--mui-`-prefixed component vars, "variable". - -**Sized token** (public, size-specific): -A size-scoped override, shape `--Component--` -(for example `--Button-small-paddingInline`). Reflows only that one size. **More -specific than the base token** — when both are set, the sized token wins. -_Avoid_: "size variant token". - -**Internal resolution var**: -A private variable, shape `--_` (leading underscore, **no component -prefix**), **set via inline style** from the rendered `(variant, size)` and -consumed once in the styled root of the same element. It carries the full -fallback chain (sized token → base token → literal). No prefix is needed because -the reader is co-located with the inline setter, so an ancestor's value never -bleeds into a descendant. Lowest priority, so any public token or plain +**Agnostic var** (public, the layer-1 surface): +The single per-property variable the styled root consumes, shape +`--Component-` — PascalCase component, short semantic key (`pad`, `gap`), +unprefixed (for example `--Button-pad`). Matches the existing component-var +convention (`--AppBar-background`). The root reads it with the internal default +behind it (`var(--Button-pad, var(--_pad))`); the Material UI layer **sets it in +a per-size `variants` block** to the sized-token routing (inline for custom +sizes). A designer tunes it through the sized token, not by setting it directly. +_Avoid_: literal CSS-property keys (`--Button-padding`), kebab keys, `--mui-`-prefixed component vars, "variable". + +**Sized token** (public, the design-system knob): +A size-scoped override, shape `--Component--` +(for example `--Button-small-pad`). Reflows only that one size. The Material UI +layer routes it over the internal default; when set at any scope it wins. +**Resolution is sized-only** — there is no all-sizes base token. +_Avoid_: "size variant token", a base/all-sizes token. + +**Internal default**: +A private variable, shape `--_` (leading underscore, **no component +prefix**), **set in `variants`** per `(variant, size)` cell (medium defaults +reuse the `{ variant }` blocks), over a **universal default on the root** so a +custom variant/size still renders. It holds the Material default — today's exact +px for that cell. No prefix is needed because the reader (the agnostic var's +fallback / the routing) is on the same element, so an ancestor's value never +bleeds into a descendant. Lowest priority, so any sized token or plain `styleOverrides` property still wins. _Avoid_: exposing it as API, prefixing with the component name, "private token". **Token fallback**: -The literal px at the end of the chain — today's exact value for that +The literal px the internal default carries — today's exact value for that `(variant, size)` cell. Makes the default render pixel-identical and bundle-light, at the cost that the single `--mui-spacing` dial no longer reflows the component. _Avoid_: "default value", "initial". @@ -53,21 +72,29 @@ _Avoid_: "spacing scale" (that is `theme.spacing`), "grid". A single post-`createTheme` function (mirroring `enhanceHighContrast`) that does **both**: (a) emits the **density scale** as `--mui-density-*` and populates `theme.vars.density`, and (b) injects per-component `styleOverrides.root` mapping -**component spacing tokens** to density steps -(`--Button-paddingInline: theme.vars.density.md`). `createTheme` is untouched. +**sized tokens** to density steps +(`--Button-medium-pad: theme.vars.density.md`). `createTheme` is untouched. Opt-in: without it, components render their literal-px defaults; with it, tuning the density scale (or scoping `--mui-density-*`) reflows every wired component. _Avoid_: "density preset" (that is the resulting effect, not the function). ## Relationships -- The styled root reads **one** internal resolution var per property; **no JavaScript +- The styled root reads **one** agnostic var per property; **no JavaScript conditional** lives in the styles implementation. The `(variant, size)` → px - matrix is a **lookup table** in the component body, applied via inline style. + matrix and the built-in-size routing are both **`variants` cells**, not a body + lookup table; only custom-size routing is inline. +- **Two vars, not one** (`--Button-pad` over `--_pad`): the cells write the + *value* (`--_pad`), the routing writes a *reference* (`--Button-pad`). One var + fails three ways — a self-referencing fallback in the inline bridge (invalid + CSS, forcing the literal back into runtime style), the `(variant×size)` and + size-only write-axes clobbering on one element, and losing the agnostic seam. + Full reasoning in `docs/adr/0001` → *Why two vars*. - Override priority (high → low): plain `styleOverrides` property → **sized - token** → **base token** → internal resolution var (literal fallback). -- Custom (user-defined) sizes work for free: the sized-token name is built from - the runtime size string; no static per-size CSS is emitted. + token** → internal default (literal fallback). +- Custom (user-defined) sizes work for free: when the size isn't built-in, the + inline routing builds the sized-token name from the runtime size string; the + design system supplies the value via that token. - **enhanceDensity** (opt-in) connects tier-2 component tokens to the tier-1 **density scale**; un-enhanced, the literal fallbacks reproduce today's pixels. - This experiment does **not** ride `--mui-spacing`; holistic density comes from @@ -75,21 +102,26 @@ _Avoid_: "density preset" (that is the resulting effect, not the function). ## Example dialogue -> **Dev:** "If I set `--Button-paddingInline` on the theme, what happens to a -> small outlined button?" -> **Domain expert:** "It reflows to your value — base token covers every -> variant and size. Unless you also set `--Button-small-paddingInline`; the -> **sized token** is more specific and wins for small." +> **Dev:** "How do I shrink the padding of small buttons?" +> **Domain expert:** "Set the **sized token** `--Button-small-pad` at any scope; +> the Material UI layer routes it over its default, so every small button +> reflows. Resolution is sized-only — there's no all-sizes base token, so do it +> per size." > **Dev:** "And with nothing set?" -> **Domain expert:** "The **internal resolution var**, set inline from the -> `(variant, size)` cell, falls through to the literal px — pixel-identical to -> today. The `--mui-spacing` dial does nothing here; for holistic density you -> run **enhanceDensity** and tune the **density scale**." +> **Domain expert:** "The agnostic `--Button-pad` falls back to the **internal +> default** `--_pad` — the literal px set in the `(variant, size)` `variants` +> cell, pixel-identical to today. The `--mui-spacing` dial does nothing here; for +> holistic density you run **enhanceDensity** and tune the **density scale**." ## Flagged ambiguities - "spacing token" meant both a `theme.spacing` key and a per-component value — - resolved: `theme.spacing` is untouched; per-component vars are **component - spacing tokens** (base) and **sized tokens**. + resolved: `theme.spacing` is untouched; per-component vars are the **agnostic + var** (layer-1 surface) and **sized tokens** (the design-system knob). - "spacing scale" (earlier draft, tier-1) — renamed **density scale** and moved to `theme.density`, to disambiguate from `theme.spacing`. +- Base (all-sizes) token — dropped; resolution is **sized-only**, so a designer + tunes per size. +- Var key — `pad` shorthand (single `padding`), not logical `paddingInline`/ + `paddingBlock`. Button padding is horizontally symmetric, so the physical + shorthand stays RTL-safe. diff --git a/docs/adr/0001-css-var-density-adapter.md b/docs/adr/0001-css-var-density-adapter.md index 848e004cc37bf6..2916ec879fee8a 100644 --- a/docs/adr/0001-css-var-density-adapter.md +++ b/docs/adr/0001-css-var-density-adapter.md @@ -1,9 +1,10 @@ -# Component density via a CSS-var adapter, resolved inline +# Component density via a CSS-var adapter, resolved in variants Component dimensions are exposed as public CSS variables with **literal-px -fallbacks**, resolved through an **internal var set by inline style**, instead of -riding the single `--mui-spacing` dial (`feat/components-theme-spacing`) or -emitting a static per-(variant, size) token matrix (`poc/css-vars-map`). +fallbacks**, resolved through an **internal var whose value lives in `variants`** +(inline style bridges only the custom-size case), instead of riding the single +`--mui-spacing` dial (`feat/components-theme-spacing`) or emitting a static +per-(variant, size) token matrix (`poc/css-vars-map`). ## Context @@ -15,10 +16,12 @@ Constraints that shaped the design: - **Pixel-identical default.** The un-configured theme must render today's exact px for every `(variant, size)` cell (Argos zero-diff). -- **No token bloat at build time.** Don't emit a static CSS rule (or named - token) per variant×size×property cell. +- **Literal defaults in `variants`, not the body.** The `(variant, size)` → + px matrix uses the idiomatic `variants` mechanism (Button already has these + cells for `fontSize`); no JS lookup table lives in the component body. - **Support user-provided sizes.** A custom `size` added via the theme must get - the same tunability as built-in sizes. + the same tunability as built-in sizes — built-in routing is per-size `variants` + blocks; a custom size falls back to inline routing (dynamic size string). - **No JavaScript conditionals in the styles implementation.** The `styled()` body must not branch on `ownerState.size`/`variant` to pick a value. - **Non-breaking.** Existing variant/size padding and existing @@ -26,41 +29,94 @@ Constraints that shaped the design: ## Decision -Three token layers per component property, for example inline padding on Button: - -- **Base token** `--Button-paddingInline` (public) — reflows all variants/sizes. -- **Sized token** `--Button--paddingInline` (public) — reflows one size; - **more specific than base** (size wins). -- **Internal resolution var** `--_paddingInline` (private, leading - underscore) — set via **inline style** from the rendered `(variant, size)`, - carrying the chain `var(--Button--paddingInline, var(--Button-paddingInline, ))`. - -The styled root has **one** consumption point per property and **no conditional**: +The component is read as **three layers of responsibility**, each owning a +distinct slice of the same cascade: + +1. **Agnostic** — the styled root with no design meaning (no size/variant/color). + Its whole spacing surface is one public var it consumes directly, falling back + to the internal default: `padding: var(--Button-pad, var(--_pad))`. +2. **Material UI** — Material Design's sizes/variants, all in `variants`: the + `(variant, size)` literal **defaults** (`--_pad`) and the **sized-token + routing** for the built-in sizes (`--Button-pad`). No JS lookup in the body. + Custom (non-built-in) sizes route inline instead — the one case that needs the + runtime size string. +3. **Design system** — overrides through the public **sized token** the Material + UI layer routes over its default (driven by `enhanceDensity`). + +Per property, the chain (inline padding on Button): + +- **Agnostic var** `--Button-pad` (public) — the styled root's only spacing + consumption point; **set by a built-in-size `variants` block** to the + sized-token routing (inline for custom sizes), falling back to `--_pad`. +- **Sized token** `--Button--pad` (public) — the design-system knob; + reflows one size. +- **Internal default** `--_pad` (private, leading underscore) — the Material + default, **set in `variants`** per `(variant, size)`, with a universal default + on the root so a custom variant/size still renders a sane value. + +Resolution is **sized-only** (no all-sizes base token): the sized token wins, +else the Material default. + +The styled root has **one** consumption point per property and **no conditional**; +the defaults and built-in-size routing are plain `variants` entries: ```js const ButtonRoot = styled(ButtonBase)({ - paddingInline: 'var(--_paddingInline)', - paddingBlock: 'var(--_paddingBlock)', + '--_pad': '6px 16px', // universal default (today's root padding) + padding: 'var(--Button-pad, var(--_pad))', // agnostic layer + variants: [ + // routing for built-in sizes (deduped CSS) + { props: { size: 'small' }, style: { '--Button-pad': 'var(--Button-small-pad, var(--_pad))' } }, + // literal default per (variant, size); medium lives in the { variant } blocks (DRY) + { props: { variant: 'text', size: 'small' }, style: { '--_pad': '4px 5px' } }, + ], }); ``` -The component body holds the `(variant, size)` → px values as a **lookup table** -(today's exact numbers) and applies them via inline style: +Only **custom sizes** route inline — the one case needing the runtime size +string (so custom sizes stay tunable without registering a variant): ```js -const [block, inline] = PADDING[variant][size]; -const sizingVars = { - '--_paddingBlock': `var(--Button-${size}-paddingBlock, var(--Button-paddingBlock, ${block}))`, - '--_paddingInline': `var(--Button-${size}-paddingInline, var(--Button-paddingInline, ${inline}))`, -}; -; +const buttonSizes = ['small', 'medium', 'large']; +const densityVars = buttonSizes.includes(size) + ? undefined // built-in: routed via variants above + : { '--Button-pad': `var(--Button-${size}-pad, var(--_pad))` }; +; ``` +### Why two vars (`--Button-pad` and `--_pad`), not one + +Three reasons, all pointing the same way: + +1. **Values belong in `variants`; the inline bridge must stay value-free.** The + literal px is a design decision — it must live in `variants`, co-located with + the rest of the variant's styling, statically deduped, smallest diff from + today. Inline style is only a **bridge** for the one dynamic case (a custom + size's token name) and must carry **no values**, only routing. Two vars allow + that: cells write the value (`--_pad`), the bridge writes a *reference* + (`--Button-pad: var(--Button--pad, var(--_pad))`). With a single + `--_pad`, the custom-size bridge would have to write + `--_pad: var(--Button--pad, var(--_pad))` — a property referencing + **itself**, which CSS treats as guaranteed-invalid. The only escape is + embedding the literal back into the inline string, dragging the value into + runtime style — exactly what we moved out. +2. **Two write-axes on one element clobber if they share a name.** The literal + varies by **(variant × size)** (the cells); the token interception varies by + **size only** (`--Button--pad`, the routing). A single `--_pad` keeps + exactly one: routing-wins loses the per-variant literal (and the size block + can't supply the right fallback — it doesn't know the variant); literal-wins + never consults the token, so no override. Two names let each axis write + independently; `--Button-pad` chains to `--_pad`, the root reads `--Button-pad`. +3. **Layer seam (naming).** `--Button-pad` is public-shaped because it's the + **agnostic-layer seam** — the var a no-design consumer of the bare root would + set; Material UI takes it over to inject token routing. `--_pad` is private: + Material UI's internal default, not a contract. + Holistic density is a separate, opt-in layer driven by a **single** `enhanceDensity(theme)` function (mirroring `enhanceHighContrast`) that does both jobs: it **emits** the density scale as `--mui-density-*` (and populates -`theme.vars.density`), and **maps** base tokens to density steps via injected -`styleOverrides.root` (`--Button-paddingInline: var(--mui-density-md)`). +`theme.vars.density`), and **maps** sized tokens to density steps via injected +`styleOverrides.root` (`--Button-medium-pad: var(--mui-density-md)`). `createTheme` is left untouched. Types for `theme.vars.density` ship built-in; the vars exist at runtime only after `enhanceDensity` runs. @@ -75,15 +131,28 @@ Scope: **Button only** for this experiment. ## Consequences -- **Pixel-identical default & non-breaking.** Literals come from the lookup - table; the internal var is the lowest-priority fallback, so public tokens, - `styleOverrides`, and `sx` all still win via the cascade. -- **Custom sizes work for free** — the sized-token name is built from the - runtime size string; nothing static is emitted per size. -- **Inline style is the price.** Every instance carries a `style` attr with the - resolution vars (larger HTML, no CSS dedup of those values) — accepted to kill - the static token matrix and support arbitrary sizes. +- **Pixel-identical default & non-breaking.** Literals come from the `variants` + cells (`--_pad`) over a universal root default, so a custom variant/size still + renders; public tokens, `styleOverrides`, and `sx` all still win via the cascade. +- **No inline for built-in sizes.** Routing for `small`/`medium`/`large` lives in + `variants` (deduped CSS), so the common case carries no per-instance `style` + attr. Only a **custom size** routes inline. +- **Custom sizes work for free** — the inline routing builds the sized-token name + from the runtime size string; the design system supplies the value via that + token, no variant registration needed. - **No `--mui-spacing` reflow.** Components opt out of the global dial; holistic density flows through the density scale + `enhanceDensity` instead. - **calc resolves only in a real browser** (jsdom does not), so density assertions belong in browser/visual tests, not jsdom unit tests. + +### Accepted trade-offs + +| Trade-off | Why we can live with it | +| --- | --- | +| `--Button-pad` is public-shaped but not a designer knob in the assembled Button (plumbing) | It's the agnostic seam; the real knob is `--Button--pad`, documented. The name marks the layer boundary. | +| Two vars per property instead of one | Mandatory (see *Why two vars*); the indirection is mechanical and documented. | +| Unprefixed `--_pad` could inherit a foreign value | Every built-in cell plus the root universal default set it on the element; revisit a prefix only if cross-component collisions surface as the pattern spreads. | +| `pad` shorthand is coarse (an override sets all sides) | Button padding is symmetric; tiny token surface; granular logical props can come later. | +| `var()` unresolved in jsdom (no computed-px assertions) | Argos covers default visuals; the chain is declarative and inspectable. | +| Inline still present for custom sizes | Rare; the built-in common case carries zero inline. | +| Per-property boilerplate grows with rollout | Acceptable for the payoff (runtime scoped theming); extract a helper before component #3. | diff --git a/docs/pages/experiments/density-tokens.tsx b/docs/pages/experiments/density-tokens.tsx index e67a73f8f22d16..190523bd568975 100644 --- a/docs/pages/experiments/density-tokens.tsx +++ b/docs/pages/experiments/density-tokens.tsx @@ -15,9 +15,11 @@ import Typography from '@mui/material/Typography'; import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; // Density experiment — CSS-var adapter (docs/adr/0001-css-var-density-adapter.md). -// Button consumes `var(--_padding*)`, resolved inline from a (variant, -// size) lookup through `var(--Button--prop, var(--Button-prop, ))`. -// `enhanceDensity` wires `--Button-*` to the `--mui-density-*` scale. +// Agnostic layer: Button consumes `var(--Button-pad, var(--_pad))`. Material UI +// layer sets the (variant, size) literal default `--_pad` and the built-in-size +// routing `--Button-pad: var(--Button--pad, var(--_pad))` in `variants` +// (custom sizes route inline). `enhanceDensity` wires the sized tokens to the +// `--mui-density-*` scale. const VARIANTS = ['text', 'outlined', 'contained'] as const; const SIZES = ['small', 'medium', 'large'] as const; @@ -32,7 +34,12 @@ function ButtonMatrix() { {variant} - + {SIZES.map((size) => ( + + + + ))} + + ), + OutlinedInput: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + @} + /> + + ))} + + ), + TextField: ( + + {(['medium', 'small'] as const).map((size) => ( + + {`outlined ${size}`} + + + ))} + + $, + endAdornment: kg, + }, + }} + /> + + + ), +}; + +// Per-component density-token overrides for the review levels. `default` is +// empty on purpose — that render is the pixel-identical regression gate. +const scopes: Record> = { + Button: { + dense: { + ['--Button-small-pad' as any]: '2px 6px', + ['--Button-medium-pad' as any]: '3px 10px', + ['--Button-large-pad' as any]: '4px 14px', + }, + loose: { + ['--Button-small-pad' as any]: '8px 14px', + ['--Button-medium-pad' as any]: '12px 22px', + ['--Button-large-pad' as any]: '16px 30px', + }, + }, + OutlinedInput: { + dense: { + ['--OutlinedInput-small-padBlock' as any]: '4px', + ['--OutlinedInput-medium-padBlock' as any]: '10px', + }, + loose: { + ['--OutlinedInput-small-padBlock' as any]: '14px', + ['--OutlinedInput-medium-padBlock' as any]: '28px', + }, + }, +}; +// TextField rides the same OutlinedInput tokens; OutlinedInput's `:has` rule +// drives the label's --InputLabel-y, so input box + label move together. +scopes.TextField = scopes.OutlinedInput; + +export default function DensityFixture() { + const router = useRouter(); + const component = (router.query.c as string) || 'Button'; + const level = (router.query.level as string) || 'default'; + const demo = demos[component] ??
No demo registered for "{component}".
; + const tokens = scopes[component]?.[level] ?? {}; + return ( + + + {demo} + + + ); +} diff --git a/package.json b/package.json index bb3cb74af4faf7..dc0eda295fb615 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,8 @@ "test:coverage": "pnpm test:unit run --coverage", "vitest": "vitest", "test:coverage:html": "pnpm test:unit run --coverage --coverage.reporter html", + "density:shot": "playwright test -c scripts/density-screenshots/playwright.config.mjs", + "density:shot:update": "playwright test -c scripts/density-screenshots/playwright.config.mjs --update-snapshots", "test:e2e": "pnpm -F ./test/e2e start", "test:e2e:dev": "pnpm -F ./test/e2e dev", "test:e2e-website": "playwright test test/e2e-website --config test/e2e-website/playwright.config.ts", diff --git a/scripts/density-screenshots/README.md b/scripts/density-screenshots/README.md new file mode 100644 index 00000000000000..8a97040139991b --- /dev/null +++ b/scripts/density-screenshots/README.md @@ -0,0 +1,49 @@ +# Density-adapter screenshot harness + +Local visual verification for the CSS-var density adapter — no Argos. +Decision/spec: [`docs/adr/0001-css-var-density-adapter.md`](../../docs/adr/0001-css-var-density-adapter.md). + +Asserts the default render is **pixel-identical** before/after the change +(Playwright `toHaveScreenshot`, `maxDiffPixels: 0`) and captures density +screenshots (token `dense`/`loose` levels) for human review. + +Unlike the `--mui-spacing` sibling experiment, density here is driven by +per-component tokens (`--Button--pad`, `--OutlinedInput--padBlock`), +so the review `level` maps to those tokens — see `scopes` in the fixture. + +## Prerequisites + +- `pnpm docs:dev` running (serves the fixture at + `/experiments/density-fixture`). Override the URL with `DENSITY_BASE_URL`. +- Chromium for Playwright: `pnpm exec playwright install chromium` (once). + +## Steps (per component) + +1. Add the component's matrix to the `demos` map (and its token overrides to + `scopes`) in `docs/pages/experiments/density-fixture.tsx`. +2. **Baseline (before)** — on the _unconverted_ component (from `master`): + + ```bash + git stash # or check out the component file(s) from master + COMPONENT= pnpm density:shot:update + git stash pop + ``` + + Writes the baseline to `scripts/density-screenshots/__baselines__/`. + +3. Implement / keep the density-adapter in the component. +4. **Assert + density (after)**: + + ```bash + COMPONENT= pnpm density:shot + ``` + + - Fails if the default render differs from the baseline (⇒ not + pixel-identical); a diff image is written under `.playwright-output/`. + - Writes `density-screenshots//after-{default,dense,loose}.png`. + +5. Eyeball `after-dense.png` / `after-loose.png` for density reflow (and, for + TextField, that the floating label stays centered). + +Outputs (`density-screenshots/`, `__baselines__/`, `.playwright-output/`) are +gitignored. diff --git a/scripts/density-screenshots/density.spec.mjs b/scripts/density-screenshots/density.spec.mjs new file mode 100644 index 00000000000000..9025e626fc1b2b --- /dev/null +++ b/scripts/density-screenshots/density.spec.mjs @@ -0,0 +1,35 @@ +import path from 'node:path'; +import { test, expect } from '@playwright/test'; + +// Component under verification, e.g. `COMPONENT=OutlinedInput pnpm density:shot`. +const component = process.env.COMPONENT || 'Button'; +const outDir = path.resolve(process.cwd(), 'density-screenshots', component); +const scopeSelector = '#density-scope'; + +async function scopeAt(page, level) { + await page.goto(`/experiments/density-fixture?c=${component}&level=${level}`); + const scope = page.locator(scopeSelector); + await scope.waitFor(); + await page.evaluate(() => document.fonts.ready); + return scope; +} + +test.describe.configure({ mode: 'serial' }); + +// Regression gate: the default render (no density tokens) must match the +// "before" baseline exactly. Capture the baseline on the UNCONVERTED component +// (from master) with --update-snapshots. +test(`${component} — default is pixel-identical to baseline`, async ({ page }) => { + const scope = await scopeAt(page, 'default'); + await scope.screenshot({ path: path.join(outDir, 'after-default.png') }); + await expect(scope).toHaveScreenshot(`${component}-default.png`); +}); + +// Density review (human only — new behavior, not assertable). Each level sets +// the component's density tokens (see density-fixture.tsx `scopes`). +for (const level of ['dense', 'loose']) { + test(`${component} — density ${level} (review)`, async ({ page }) => { + const scope = await scopeAt(page, level); + await scope.screenshot({ path: path.join(outDir, `after-${level}.png`) }); + }); +} diff --git a/scripts/density-screenshots/playwright.config.mjs b/scripts/density-screenshots/playwright.config.mjs new file mode 100644 index 00000000000000..0d29202f75f1ea --- /dev/null +++ b/scripts/density-screenshots/playwright.config.mjs @@ -0,0 +1,19 @@ +import { defineConfig } from '@playwright/test'; + +// Local verification harness for the CSS-var density adapter. +// See docs/adr/0001-css-var-density-adapter.md and ./README.md. +export default defineConfig({ + testDir: '.', + testMatch: /density\.spec\.mjs/, + // Keep Playwright's artifacts out of the repo's tracked test-results/. + outputDir: './.playwright-output', + // "before" baselines (gitignored) — co-located with the harness. + snapshotPathTemplate: '{testDir}/__baselines__/{arg}{ext}', + reporter: 'list', + use: { + baseURL: process.env.DENSITY_BASE_URL || 'http://localhost:3000', + viewport: { width: 760, height: 720 }, + }, + // Strict: the default render must be pixel-identical to the baseline. + expect: { toHaveScreenshot: { maxDiffPixels: 0 } }, +}); From a89c0627146008d5c5a3f3cd8f16c81ff8fc110b Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Sun, 7 Jun 2026 18:26:05 +0700 Subject: [PATCH 08/28] [material-ui] Add OutlinedInput inline padding base token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tokenize the 14px inline gutter as --OutlinedInput-padInline (size-invariant base token) - Uniform consume shape var(--seam, var(--_internal)) across both axes: block sized (routed), inline base; --_padInline internal default - Docs: base-token shape in ADR/CONTEXT; rollout gotchas — split-only-if-forced, uniform consume shape, inline gutter != adornment gap --- CONTEXT.md | 36 +++++++++++----- docs/adr/0001-css-var-density-adapter.md | 24 ++++++++--- docs/adr/density-adapter-rollout.md | 43 ++++++++++++++++--- .../src/OutlinedInput/OutlinedInput.js | 19 ++++---- 4 files changed, 89 insertions(+), 33 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 468755265279c5..3c15ebf36c561f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -40,8 +40,17 @@ _Avoid_: literal CSS-property keys (`--Button-padding`), kebab keys, `--mui-`-pr A size-scoped override, shape `--Component--` (for example `--Button-small-pad`). Reflows only that one size. The Material UI layer routes it over the internal default; when set at any scope it wins. -**Resolution is sized-only** — there is no all-sizes base token. -_Avoid_: "size variant token", a base/all-sizes token. +For a **size-varying** axis, resolution is **sized-only** — no all-sizes base token. +_Avoid_: "size variant token". + +**Base token** (public, size-invariant axes only): +When an axis does **not** vary by size (e.g. OutlinedInput's `14px` inline +gutter), skip the **size layer** but keep the same shape: internal default +`--_` + seam, consumed `var(--Component-, var(--_))` +(`var(--OutlinedInput-padInline, var(--_padInline))`, `--_padInline: 14px`). The +seam *is* the knob — nothing routes it, so a designer sets it directly. Use only +when the value is genuinely constant across sizes; otherwise use a **sized token**. +_Avoid_: a base token for a size-varying axis; a bare literal default (use `--_`). **Internal default**: A private variable, shape `--_` (leading underscore, **no component @@ -96,10 +105,13 @@ _Avoid_: "density preset" (that is the resulting effect, not the function). - Custom (user-defined) sizes work for free: when the size isn't built-in, the inline routing builds the sized-token name from the runtime size string; the design system supplies the value via that token. -- **Token granularity follows the component's spacing structure.** Button tunes - all sides together → one `pad` shorthand var. OutlinedInput's density is - vertical only (the `14px` inline gutter is constant) → a single `padBlock` var; - and because its padding spans two elements, the root routes while the input +- **Token granularity follows the component's spacing structure; split only when + the impl forces it.** Button sets all sides together via one shorthand on one + element → one `pad` var (even though block 6 ≠ inline 8 — differing values alone + don't force a split). OutlinedInput *is* forced: block vs inline land on + different elements/states and zero per adornment, and block is sized while the + `14px` inline gutter is a size-invariant **base token** (`--OutlinedInput-padInline`). + Its padding spans two elements, so the root routes block while the input consumes by inheritance. - **Cross-component coordination respects dependency direction.** The outlined floating label must track the input's `padBlock`, but `InputLabel` is generic @@ -132,8 +144,10 @@ _Avoid_: "density preset" (that is the resulting effect, not the function). var** (layer-1 surface) and **sized tokens** (the design-system knob). - "spacing scale" (earlier draft, tier-1) — renamed **density scale** and moved to `theme.density`, to disambiguate from `theme.spacing`. -- Base (all-sizes) token — dropped; resolution is **sized-only**, so a designer - tunes per size. -- Var key — `pad` shorthand (single `padding`), not logical `paddingInline`/ - `paddingBlock`. Button padding is horizontally symmetric, so the physical - shorthand stays RTL-safe. +- Base (all-sizes-over-sized) token — dropped for **size-varying** axes; + resolution is sized-only, tune per size. A **size-invariant** axis (e.g. + OutlinedInput inline gutter) is the one place a plain base token applies. +- Var key — single `pad` shorthand only when the impl sets all sides together on + one element (Button); split per axis (`padBlock`/`padInline`) when forced — + axes on different elements/states or different shapes (OutlinedInput). Sides are + symmetric within an axis, so `padding: ` stays RTL-safe. diff --git a/docs/adr/0001-css-var-density-adapter.md b/docs/adr/0001-css-var-density-adapter.md index 68199238e5ad98..fce1727af4697a 100644 --- a/docs/adr/0001-css-var-density-adapter.md +++ b/docs/adr/0001-css-var-density-adapter.md @@ -54,8 +54,12 @@ Per property, the chain (inline padding on Button): default, **set in `variants`** per `(variant, size)`, with a universal default on the root so a custom variant/size still renders a sane value. -Resolution is **sized-only** (no all-sizes base token): the sized token wins, -else the Material default. +Resolution for a **size-varying** axis is **sized-only** (no all-sizes base +token): the sized token wins, else the Material default. A **size-invariant** +axis (e.g. OutlinedInput's inline gutter) skips the size layer entirely and uses +a plain **base token** `--Component-` over an internal default `--_`, +consumed `var(--Component-, var(--_))` — same consume shape as a sized +axis, just with no size layer/routing. The styled root has **one** consumption point per property and **no conditional**; the defaults and built-in-size routing are plain `variants` entries: @@ -134,11 +138,17 @@ InputBase/TextField to follow) for this experiment. Same three-tier model, with two component-driven differences: -- **Block-only.** Input "density" is vertical: Material's own `small` changes - only the block padding (`16.5px` → `8.5px`); the `14px` inline gutter is - constant, so only `--OutlinedInput-padBlock` is tokenized. The gutter stays a - literal. (Filled/Standard have asymmetric block padding — `4/5`, `25/8` — so a - shared InputBase block seam would need a richer, per-side shape; deferred.) +- **Block is sized; inline is a base token.** Block (`16.5px`→`8.5px`) is the + density axis → sized token `--OutlinedInput--padBlock`. The `14px` inline + gutter is constant across sizes → a **base token** `--OutlinedInput-padInline` + (the experiment's first): same `var(--seam, var(--_padInline))` consume shape, + just **no size layer/routing** — the seam is the public knob, `--_padInline` the + internal default. Consumed on each spot (input sides, root adornment side, + multiline). Block and inline are split because the impl + applies them separately — different elements/states, per-adornment side-zeroing, + and different token shapes (sized vs base). (Filled/Standard have asymmetric + block padding — `4/5`, `25/8` — so a shared InputBase block seam would need a + richer, per-side shape; deferred.) - **Two consuming elements via inheritance.** Padding lives on the input (non-multiline) *and* the root (multiline). The root owns the size routing and `--_padBlock`; the **input (a child) consumes the resolved diff --git a/docs/adr/density-adapter-rollout.md b/docs/adr/density-adapter-rollout.md index 15960dcca036f7..df6a6a3473d89a 100644 --- a/docs/adr/density-adapter-rollout.md +++ b/docs/adr/density-adapter-rollout.md @@ -19,8 +19,13 @@ Root consumes seam, seam falls to internal default: padding: 'var(--Button-pad, var(--_pad))' ``` -Resolution = **sized-only**. Sized token wins -> else internal default. No -all-sizes base token. +Resolution = **sized-only** for a size-varying axis. Sized token wins -> else +internal default. No all-sizes-over-sized base token. + +**Size-invariant axis** (value same every size, e.g. OutlinedInput `14px` inline +gutter) -> skip the **size layer** only; keep internal default `--_`. Base +token `--Component-`, consumed `var(--Component-, var(--_))` (same +shape as sized, no routing). Seam = knob (nothing routes it). ## Recipe A — small component (Button) @@ -53,9 +58,11 @@ One element. `pad` shorthand (all sides move together). Padding spans 2 elements (root when multiline, input otherwise) + paired sibling (InputLabel). More dimensions but token model is *simpler*. -**Pick real axis.** Input density = vertical only. Material's own `small` only -changes block (`16.5 -> 8.5`); `14px` inline gutter constant -> stays literal. -Tokenize `padBlock`, not `pad`. +**Pick real axis + shape.** Block (`16.5 -> 8.5`) varies by size -> **sized** +`padBlock`. Inline `14px` constant across sizes -> **base** `padInline` +(`var(--OutlinedInput-padInline, var(--_padInline))`, `--_padInline: 14px`, no +size layer). Split forced: +axes land on different elements/states, zero per adornment, different shapes. **Two elements, one source via inheritance.** Root owns routing + `--_padBlock`. Input is child -> consumes resolved `--OutlinedInput-padBlock` by inheritance. No @@ -95,10 +102,26 @@ One knob (`--OutlinedInput--padBlock`) -> input box + label move together. ## Gotchas +- **Split axes only when the impl forces it.** Differing values per side is NOT + enough. If all sides are set together via one shorthand on one element, keep one + key — Button uses `pad` even though block 6 ≠ inline 8. Split per axis + (`padBlock`/`padInline`) only when the impl applies them separately: different + elements/states (OutlinedInput block on input vs root-multiline), independent + side-zeroing (adornments), or different token shapes (sized block + base inline). + OutlinedInput is forced; Button is not. Don't over-tokenize. - **Two vars, not one.** Cells write value (`--_pad`), routing writes reference (`--Button-pad`). One var fails 3 ways: inline bridge self-references (invalid CSS) -> literal leaks to runtime; `(variant×size)` vs size-only writes clobber on one element; lose the seam. +- **Uniform consume shape — every axis.** Always `var(--Component-, + var(--_))`, including a size-invariant **base** axis. Two real mistakes to + avoid: (a) **bare literal default** for a base axis (`var(--seam, 14px)`) — + instead define `--_` (e.g. `--_padInline: 14px`) so the default lives in + one place and the shape matches sized axes; (b) **dropping a fallback because + the seam "is always set"** — keep it; the uniform shape is the contract (Button + `var(--Button-pad, var(--_pad))`; a sized axis carries `--_` in *both* the + routing and the consume — that double-reference is intentional). Consistency + over minimalism. - **Unprefixed `--_` safe only if every instance sets own.** Custom prop inherits. Co-located setter (Button) or every root re-sets (OutlinedInput) -> ancestor value never wins. Else prefix it. @@ -108,6 +131,11 @@ One knob (`--OutlinedInput--padBlock`) -> input box + label move together. - **One element can't see another's internal var.** Label can't read input-root `--_padBlock`. Reference **public** token (visible at `:root`) + literal fallback. Never the internal var across elements. +- **Inline padding = outer gutter, not the adornment↔input gap.** The inline + token (`padInline`) is the border→first/last content inset (border→adornment + when adorned). The adornment↔text gap is the adornment's own margin + (`InputAdornment` marginRight/Left, ~8px), separate and untouched. Don't read + the gutter as the gap, and don't expect tokenizing it to move that gap. - **Check if component defaults `size`.** Most components destructure a default (Button: `size = 'medium'`) -> `ownerState.size` always valid -> `{ size: medium }` variant matches, fine. But context-driven ones (InputBase/OutlinedInput read @@ -139,8 +167,9 @@ Screenshot harness `scripts/density-screenshots/` (`maxDiffPixels: 0`): - Public seam/token: `--Component-` / `--Component--`. PascalCase component, short semantic key (`pad`, `gap`, `padBlock`). Matches `--AppBar-background`. - Internal: `--_` (leading underscore, no prefix). -- Key granularity = component's real spacing structure. Tune-all-sides -> - shorthand. One axis -> that axis only. +- Key granularity = component's real spacing structure. One shorthand key + (`pad`) when sides set together on one element; split per axis only when forced + (see gotcha). Per axis: sized if size-varying, base if constant. ## Order to roll out diff --git a/packages/mui-material/src/OutlinedInput/OutlinedInput.js b/packages/mui-material/src/OutlinedInput/OutlinedInput.js index 9f8b758e53c8c3..2a3af962acfc39 100644 --- a/packages/mui-material/src/OutlinedInput/OutlinedInput.js +++ b/packages/mui-material/src/OutlinedInput/OutlinedInput.js @@ -50,9 +50,11 @@ const OutlinedInputRoot = styled(InputBaseRoot, { // Agnostic seam: the input (child) inherits the size-resolved // --OutlinedInput-padBlock; the root consumes it only when multiline. // --_padBlock is the medium default, specialized by the size variant. - // See docs/adr/0001. Block (vertical) is the density axis; the 14px inline - // gutter is constant, so it stays a literal. + // See docs/adr/0001. Each axis has an internal default `--_` consumed + // as `var(--seam, var(--_))`. Block (vertical) is sized — its seam is + // routed per size; inline is a base token — its seam is just the public knob. '--_padBlock': '16.5px', + '--_padInline': '14px', '--OutlinedInput-padBlock': 'var(--OutlinedInput-medium-padBlock, var(--_padBlock))', // The outlined label centers on the input's block padding. It's a preceding // sibling, so reach it via :has and feed it the public token + the label's @@ -114,20 +116,20 @@ const OutlinedInputRoot = styled(InputBaseRoot, { { props: ({ ownerState }) => ownerState.startAdornment, style: { - paddingLeft: 14, + paddingLeft: 'var(--OutlinedInput-padInline, var(--_padInline))', }, }, { props: ({ ownerState }) => ownerState.endAdornment, style: { - paddingRight: 14, + paddingRight: 'var(--OutlinedInput-padInline, var(--_padInline))', }, }, { props: ({ ownerState }) => ownerState.multiline, style: { // Block from the size-resolved var (small + multiline → 8.5px). - padding: 'var(--OutlinedInput-padBlock) 14px', + padding: 'var(--OutlinedInput-padBlock, var(--_padBlock)) var(--OutlinedInput-padInline, var(--_padInline))', }, }, ], @@ -156,9 +158,10 @@ const OutlinedInputInput = styled(InputBaseInput, { overridesResolver: inputBaseInputOverridesResolver, })( memoTheme(({ theme }) => ({ - // Inherits the size-resolved --OutlinedInput-padBlock from the root; the 14px - // inline gutter is constant (not a density axis). - padding: 'var(--OutlinedInput-padBlock, var(--_padBlock)) 14px', + // Both axes: `var(--seam, var(--_internal))`. padBlock is size-resolved on the + // root (routed) and inherited; padInline is a base token (no routing). The + // internal defaults `--_padBlock`/`--_padInline` are inherited from the root. + padding: 'var(--OutlinedInput-padBlock, var(--_padBlock)) var(--OutlinedInput-padInline, var(--_padInline))', '&:-webkit-autofill': { ...(!theme.vars && { WebkitBoxShadow: theme.palette.mode === 'light' ? null : '0 0 0 100px #266798 inset', From 5bb9e6e802abad0fc3d8b5e2d6f65ee72eb7c110 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Sun, 7 Jun 2026 18:26:19 +0700 Subject: [PATCH 09/28] [docs] Add OutlinedInput padInline to density-screenshot fixture scopes --- docs/pages/experiments/density-fixture.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/pages/experiments/density-fixture.tsx b/docs/pages/experiments/density-fixture.tsx index 47d428c05d9018..7f8487c38e51bc 100644 --- a/docs/pages/experiments/density-fixture.tsx +++ b/docs/pages/experiments/density-fixture.tsx @@ -94,10 +94,12 @@ const scopes: Record> = { dense: { ['--OutlinedInput-small-padBlock' as any]: '4px', ['--OutlinedInput-medium-padBlock' as any]: '10px', + ['--OutlinedInput-padInline' as any]: '8px', }, loose: { ['--OutlinedInput-small-padBlock' as any]: '14px', ['--OutlinedInput-medium-padBlock' as any]: '28px', + ['--OutlinedInput-padInline' as any]: '24px', }, }, }; From cce0ad0c0b4c5d9e0587931764b29fc71986ed9c Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Sun, 7 Jun 2026 23:33:50 +0700 Subject: [PATCH 10/28] [material-ui] OutlinedInput: tokenize padding in place, size both axes Revert the lift of block padding to the root + inheritance; tokenize each literal where master has it (input owns inline/non-multiline, root owns multiline/adornment gutters) for the smallest diff. Promote padInline from a base token to a sized axis: default 14px both sizes, but expose --OutlinedInput--padInline so a design system can tune inline density per size. Both axes now routed per size in place; label :has derives --InputLabel-y straight from the public sized token. Docs: base token reserved for axes where per-size override is meaningless; a size-invariant default alone no longer justifies it. --- CONTEXT.md | 43 ++++++---- docs/adr/0001-css-var-density-adapter.md | 62 +++++++------- docs/adr/density-adapter-rollout.md | 59 ++++++++----- .../src/OutlinedInput/OutlinedInput.js | 82 +++++++++++++------ 4 files changed, 153 insertions(+), 93 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 3c15ebf36c561f..e485999c968e3a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -43,24 +43,26 @@ layer routes it over the internal default; when set at any scope it wins. For a **size-varying** axis, resolution is **sized-only** — no all-sizes base token. _Avoid_: "size variant token". -**Base token** (public, size-invariant axes only): -When an axis does **not** vary by size (e.g. OutlinedInput's `14px` inline -gutter), skip the **size layer** but keep the same shape: internal default -`--_` + seam, consumed `var(--Component-, var(--_))` -(`var(--OutlinedInput-padInline, var(--_padInline))`, `--_padInline: 14px`). The -seam *is* the knob — nothing routes it, so a designer sets it directly. Use only -when the value is genuinely constant across sizes; otherwise use a **sized token**. -_Avoid_: a base token for a size-varying axis; a bare literal default (use `--_`). +**Base token** (public, only when per-size override is meaningless): +An axis can skip the **size layer** — internal default `--_` + seam, +consumed `var(--Component-, var(--_))`, nothing routes it, so a designer +sets the seam directly. Use this **only when tuning the axis per size makes no +sense**, because a base token can't be size-scoped from the theme. A +size-invariant *default* is **not** enough: OutlinedInput's inline gutter is +`14px` for both sizes, yet it's a **sized token** (`--OutlinedInput--padInline`, +default `14px` each) so a design system can make small inputs denser inline. +Reach for a base token rarely; default to a **sized token**. +_Avoid_: a base token just because the default is size-invariant; a bare literal +default (use `--_`). **Internal default**: A private variable, shape `--_` (leading underscore, **no component prefix**), **set in `variants`** per `(variant, size)` cell (medium defaults reuse the `{ variant }` blocks), over a **universal default on the root** so a custom variant/size still renders. It holds the Material default — today's exact -px for that cell. No prefix is needed because the consumer either reads it on the -same element (Button) or is a descendant that re-sets its own (OutlinedInput's -input inherits from the root, but every root re-declares it) — so an ancestor's -value never wins over a component's own. Lowest priority, so any sized token or +px for that cell. No prefix is needed because every cell that reads it also +sets it on the same element (Button; OutlinedInput's input and root each declare +their own) — so an ancestor's value never wins over a component's own. Lowest priority, so any sized token or plain `styleOverrides` property still wins. _Avoid_: exposing it as API, prefixing with the component name, "private token". @@ -109,10 +111,13 @@ _Avoid_: "density preset" (that is the resulting effect, not the function). the impl forces it.** Button sets all sides together via one shorthand on one element → one `pad` var (even though block 6 ≠ inline 8 — differing values alone don't force a split). OutlinedInput *is* forced: block vs inline land on - different elements/states and zero per adornment, and block is sized while the - `14px` inline gutter is a size-invariant **base token** (`--OutlinedInput-padInline`). - Its padding spans two elements, so the root routes block while the input - consumes by inheritance. + different elements/states and zero per adornment. **Both axes are sized** + (`--OutlinedInput--padBlock`/`-padInline`) — block defaults vary by size + (16.5/8.5), inline defaults don't (14 both) but it's sized anyway so density can + tune it per size. Its padding spans two elements (input when inline, root when + multiline/adorned — never both on a side at once), so each site tokenizes its + own literal in place rather than lifting size resolution to one owner; smallest + diff from master. - **Cross-component coordination respects dependency direction.** The outlined floating label must track the input's `padBlock`, but `InputLabel` is generic (shared by all input variants) so it only exposes a seam (`--InputLabel-y`, @@ -145,8 +150,10 @@ _Avoid_: "density preset" (that is the resulting effect, not the function). - "spacing scale" (earlier draft, tier-1) — renamed **density scale** and moved to `theme.density`, to disambiguate from `theme.spacing`. - Base (all-sizes-over-sized) token — dropped for **size-varying** axes; - resolution is sized-only, tune per size. A **size-invariant** axis (e.g. - OutlinedInput inline gutter) is the one place a plain base token applies. + resolution is sized-only, tune per size. A base token applies only when per-size + override is meaningless — *not* merely when the default is size-invariant + (OutlinedInput's `14px` inline gutter is still a **sized** token so density can + tune it per size). - Var key — single `pad` shorthand only when the impl sets all sides together on one element (Button); split per axis (`padBlock`/`padInline`) when forced — axes on different elements/states or different shapes (OutlinedInput). Sides are diff --git a/docs/adr/0001-css-var-density-adapter.md b/docs/adr/0001-css-var-density-adapter.md index fce1727af4697a..0483255e4a5d23 100644 --- a/docs/adr/0001-css-var-density-adapter.md +++ b/docs/adr/0001-css-var-density-adapter.md @@ -55,11 +55,12 @@ Per property, the chain (inline padding on Button): on the root so a custom variant/size still renders a sane value. Resolution for a **size-varying** axis is **sized-only** (no all-sizes base -token): the sized token wins, else the Material default. A **size-invariant** -axis (e.g. OutlinedInput's inline gutter) skips the size layer entirely and uses -a plain **base token** `--Component-` over an internal default `--_`, -consumed `var(--Component-, var(--_))` — same consume shape as a sized -axis, just with no size layer/routing. +token): the sized token wins, else the Material default. An axis whose default is +the same every size can still be **sized** — and usually should be, so a design +system can tune it per size (density). A plain **base token** `--Component-` +over an internal default `--_` (consumed `var(--Component-, var(--_))`, +no size layer/routing) is reserved for the rare axis where per-size override is +genuinely meaningless. The styled root has **one** consumption point per property and **no conditional**; the defaults and built-in-size routing are plain `variants` entries: @@ -138,28 +139,31 @@ InputBase/TextField to follow) for this experiment. Same three-tier model, with two component-driven differences: -- **Block is sized; inline is a base token.** Block (`16.5px`→`8.5px`) is the - density axis → sized token `--OutlinedInput--padBlock`. The `14px` inline - gutter is constant across sizes → a **base token** `--OutlinedInput-padInline` - (the experiment's first): same `var(--seam, var(--_padInline))` consume shape, - just **no size layer/routing** — the seam is the public knob, `--_padInline` the - internal default. Consumed on each spot (input sides, root adornment side, - multiline). Block and inline are split because the impl - applies them separately — different elements/states, per-adornment side-zeroing, - and different token shapes (sized vs base). (Filled/Standard have asymmetric +- **Both axes are sized.** Block (`16.5px`→`8.5px`) varies by size → sized token + `--OutlinedInput--padBlock`. The `14px` inline gutter is *constant* across + sizes, but it's **sized too** → `--OutlinedInput--padInline` (default + `14px` each size) so a design system can make small inputs denser inline. (We + first modeled inline as a single size-invariant **base token**, but that can't + be size-scoped from the theme — a flaw for density — so we promoted it; a base + token is now reserved for axes where per-size override is meaningless.) Block and + inline are still split because the impl applies them separately — different + elements/states, per-adornment side-zeroing. Each axis is routed per size **in + place** on the element/variant that consumes it (input + root cells), so sizing + inline adds a `&& size === 'small'` re-route beside each size-agnostic adornment + variant — no lift, both axes wired identically. (Filled/Standard have asymmetric block padding — `4/5`, `25/8` — so a shared InputBase block seam would need a richer, per-side shape; deferred.) -- **Two consuming elements via inheritance.** Padding lives on the input - (non-multiline) *and* the root (multiline). The root owns the size routing and - `--_padBlock`; the **input (a child) consumes the resolved - `--OutlinedInput-padBlock` by inheritance** — single source, no duplicated size - logic. This diverges from Button's "reader co-located with setter": here the - reader is a descendant. Unprefixed `--_padBlock` stays safe because every - `OutlinedInputRoot` re-sets its own value, shadowing any inherited one. - -Because the block var is size-resolved before the multiline/input rules read it, -the previous `multiline && size==='small'` and input `size: 'small'` variants -become redundant and are dropped (identical pixels, fewer rules). +- **Two consuming elements, tokenized in place.** Padding lives on the input + (non-multiline) *and* the root (multiline) — and the two never both apply block + padding at once (multiline zeroes the input's). Rather than lift size resolution + to a single owner, **each site keeps master's literal-bearing cell and + tokenizes in place**: input base + input `{ size: small }`, root `multiline` + + root `{ multiline && small }`, each declaring its own `--_padBlock` and routing + the size token. This keeps the smallest diff from master (no restructuring, no + inheritance reliance, no dropped variants); the minor cost is the size routing + written twice (input vs root-multiline), which is honest — they are genuinely + separate code paths. Unprefixed `--_padBlock` stays safe because every cell that + reads it also sets it on the same element. **Closing the loop — the floating label.** In a `TextField`, `InputLabel` is a *preceding sibling* of the input. The resting label must track the block padding @@ -172,8 +176,9 @@ The bridge must respect the **dependency direction**: `InputLabel` is generic So `InputLabel` only exposes a seam — its outlined resting transform reads `var(--InputLabel-y, )` — and **OutlinedInput owns the bridge**. Because the label precedes the input, OutlinedInput reaches it with `:has` (sibling -combinators only match *following* siblings) and, per size, routes its public -token into the label scope and derives the seam: +combinators only match *following* siblings) and, per size, derives the label +seam straight from its public sized token (a cross-element rule must reference the +public token, not the input's internal `--_padBlock`): ```js // InputLabel — generic seam, literal default @@ -181,8 +186,7 @@ transform: 'translate(14px, var(--InputLabel-y, 16px)) scale(1)' // small: 9px // OutlinedInputRoot — base (medium) + size:small variant [`.${inputLabelClasses.root}:has(~ &)`]: { - '--OutlinedInput-padBlock': 'var(--OutlinedInput-medium-padBlock, 16.5px)', - '--InputLabel-y': 'calc(var(--OutlinedInput-padBlock) - 0.5px)', // small: + 0.5px, 8.5px + '--InputLabel-y': 'calc(var(--OutlinedInput-medium-padBlock, 16.5px) - 0.5px)', // small: small token + 0.5px = 9px }, ``` diff --git a/docs/adr/density-adapter-rollout.md b/docs/adr/density-adapter-rollout.md index df6a6a3473d89a..89293c169bf5e0 100644 --- a/docs/adr/density-adapter-rollout.md +++ b/docs/adr/density-adapter-rollout.md @@ -22,10 +22,14 @@ padding: 'var(--Button-pad, var(--_pad))' Resolution = **sized-only** for a size-varying axis. Sized token wins -> else internal default. No all-sizes-over-sized base token. -**Size-invariant axis** (value same every size, e.g. OutlinedInput `14px` inline -gutter) -> skip the **size layer** only; keep internal default `--_`. Base -token `--Component-`, consumed `var(--Component-, var(--_))` (same -shape as sized, no routing). Seam = knob (nothing routes it). +**Size-invariant default ≠ base token.** If an axis's default is the same every +size (e.g. OutlinedInput inline `14px`) you *can* skip the size layer — base +token `--Component-`, consumed `var(--Component-, var(--_))`, +nothing routes it. But only when per-size override is genuinely meaningless, +because a **base token can't be tuned per size from the theme**. If a design +system might want that axis denser on small (density!), **size it anyway**: same +default both sizes, but expose `--Component--`. OutlinedInput sizes +*both* padBlock and padInline for this reason (inline default `14px` each size). ## Recipe A — small component (Button) @@ -59,26 +63,34 @@ Padding spans 2 elements (root when multiline, input otherwise) + paired sibling (InputLabel). More dimensions but token model is *simpler*. **Pick real axis + shape.** Block (`16.5 -> 8.5`) varies by size -> **sized** -`padBlock`. Inline `14px` constant across sizes -> **base** `padInline` -(`var(--OutlinedInput-padInline, var(--_padInline))`, `--_padInline: 14px`, no -size layer). Split forced: -axes land on different elements/states, zero per adornment, different shapes. - -**Two elements, one source via inheritance.** Root owns routing + `--_padBlock`. -Input is child -> consumes resolved `--OutlinedInput-padBlock` by inheritance. No -duplicated size logic. +`padBlock`. Inline default is `14px` both sizes, but a design system may want +per-size inline density -> **size it too** (`padInline`, same `14px` default each +size). Both axes sized, routed per size. Split block/inline forced: they land on +different elements/states + zero per adornment. + +**Two elements, tokenize in place.** Padding lives on the input (non-multiline, +inline gutters) *and* the root (multiline, adornment gutters) — never both on the +same side at once (multiline zeroes input padding; an adorned side zeroes the +input and gutters from the root). Keep master's split: each site declares its own +`--_` + routes the size token, right where the literal was. No lift to a +single owner, no inheritance, no dropped variants — smallest diff from master. ```js -// root base +// input base + root multiline cell: '--_padBlock': '16.5px', +'--_padInline': '14px', '--OutlinedInput-padBlock': 'var(--OutlinedInput-medium-padBlock, var(--_padBlock))', -// root { size: small } -> --_padBlock 8.5px + route to small token -// input: padding: 'var(--OutlinedInput-padBlock, var(--_padBlock)) 14px' -// root multiline: padding: 'var(--OutlinedInput-padBlock) 14px' +'--OutlinedInput-padInline': 'var(--OutlinedInput-medium-padInline, var(--_padInline))', +padding: 'var(--OutlinedInput-padBlock, var(--_padBlock)) var(--OutlinedInput-padInline, var(--_padInline))', +// each size-small cell re-routes its axis to the small token: +// input { size: small } -> padBlock + padInline small (input owns both) +// root { multiline && small } -> padBlock + padInline small +// root { startAdornment/endAdornment && small } -> padInline small (gutter) ``` -Var carries size -> redundant `multiline && small` + input `size: small` -variants gone. Same pixels, fewer rules. +Cost of sizing inline in place: the size-agnostic adornment gutters need a small +re-route, so each adornment variant gains a `&& size === 'small'` sibling. Cheap +(one line each), and keeps both axes wired identically (no lift). **Paired sibling component (the label).** Generic component must not name specific component token. Label exposes own seam: @@ -88,13 +100,14 @@ specific component token. Label exposes own seam: transform: 'translate(14px, var(--InputLabel-y, 16px)) scale(1)' // small: 9px ``` -Specific component owns bridge. Label = *preceding* sibling -> reach via `:has`: +Specific component owns bridge. Label = *preceding* sibling -> reach via `:has`. +Cross-element rule -> derive the label seam straight from the **public sized +token** + literal fallback (can't read the input's internal `--_padBlock`): ```js // OutlinedInputRoot, per size [`.${inputLabelClasses.root}:has(~ &)`]: { - '--OutlinedInput-padBlock': 'var(--OutlinedInput-medium-padBlock, 16.5px)', - '--InputLabel-y': 'calc(var(--OutlinedInput-padBlock) - 0.5px)', // small: + 0.5px + '--InputLabel-y': 'calc(var(--OutlinedInput-medium-padBlock, 16.5px) - 0.5px)', // small: small token + 0.5px } ``` @@ -169,7 +182,9 @@ Screenshot harness `scripts/density-screenshots/` (`maxDiffPixels: 0`): - Internal: `--_` (leading underscore, no prefix). - Key granularity = component's real spacing structure. One shorthand key (`pad`) when sides set together on one element; split per axis only when forced - (see gotcha). Per axis: sized if size-varying, base if constant. + (see gotcha). Per axis: **sized by default** (per-size tunable); base token only + if per-size override is genuinely meaningless — a size-invariant *default* alone + doesn't justify base (size it so density can tune it per size). ## Order to roll out diff --git a/packages/mui-material/src/OutlinedInput/OutlinedInput.js b/packages/mui-material/src/OutlinedInput/OutlinedInput.js index 2a3af962acfc39..4a0a76b8c1c5bd 100644 --- a/packages/mui-material/src/OutlinedInput/OutlinedInput.js +++ b/packages/mui-material/src/OutlinedInput/OutlinedInput.js @@ -47,21 +47,17 @@ const OutlinedInputRoot = styled(InputBaseRoot, { const borderColor = theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)'; return { - // Agnostic seam: the input (child) inherits the size-resolved - // --OutlinedInput-padBlock; the root consumes it only when multiline. - // --_padBlock is the medium default, specialized by the size variant. - // See docs/adr/0001. Each axis has an internal default `--_` consumed - // as `var(--seam, var(--_))`. Block (vertical) is sized — its seam is - // routed per size; inline is a base token — its seam is just the public knob. - '--_padBlock': '16.5px', - '--_padInline': '14px', - '--OutlinedInput-padBlock': 'var(--OutlinedInput-medium-padBlock, var(--_padBlock))', + // Density adapter (docs/adr/0001): each padding literal becomes + // `var(--seam, var(--_))`, tokenized in place. Both axes are sized — + // each seam routes the per-size public token (block + inline). The internal + // defaults live in the variants that consume them (below), like Button's + // `--_pad`. Inline default is 14px for both sizes; the per-size inline + // tokens let a design system tune it per size anyway. // The outlined label centers on the input's block padding. It's a preceding - // sibling, so reach it via :has and feed it the public token + the label's - // resting-Y seam. Medium default resolves to 16px (16.5 - 0.5 rounding). + // sibling, so reach it via :has and derive its resting-Y seam straight from + // the public sized token. Medium resolves to 16px (16.5 - 0.5 rounding). [`.${inputLabelClasses.root}:has(~ &)`]: { - '--OutlinedInput-padBlock': 'var(--OutlinedInput-medium-padBlock, 16.5px)', - '--InputLabel-y': 'calc(var(--OutlinedInput-padBlock) - 0.5px)', + '--InputLabel-y': 'calc(var(--OutlinedInput-medium-padBlock, 16.5px) - 0.5px)', }, position: 'relative', borderRadius: (theme.vars || theme).shape.borderRadius, @@ -104,32 +100,57 @@ const OutlinedInputRoot = styled(InputBaseRoot, { { props: { size: 'small' }, style: { - '--_padBlock': '8.5px', - '--OutlinedInput-padBlock': 'var(--OutlinedInput-small-padBlock, var(--_padBlock))', - // Small default resolves to 9px (8.5 + 0.5). + // Small label resolves to 9px (8.5 + 0.5). [`.${inputLabelClasses.root}:has(~ &)`]: { - '--OutlinedInput-padBlock': 'var(--OutlinedInput-small-padBlock, 8.5px)', - '--InputLabel-y': 'calc(var(--OutlinedInput-padBlock) + 0.5px)', + '--InputLabel-y': 'calc(var(--OutlinedInput-small-padBlock, 8.5px) + 0.5px)', }, }, }, { props: ({ ownerState }) => ownerState.startAdornment, style: { + '--_padInline': '14px', + '--OutlinedInput-padInline': 'var(--OutlinedInput-medium-padInline, var(--_padInline))', paddingLeft: 'var(--OutlinedInput-padInline, var(--_padInline))', }, }, + { + props: ({ ownerState, size }) => ownerState.startAdornment && size === 'small', + style: { + '--OutlinedInput-padInline': 'var(--OutlinedInput-small-padInline, var(--_padInline))', + }, + }, { props: ({ ownerState }) => ownerState.endAdornment, style: { + '--_padInline': '14px', + '--OutlinedInput-padInline': 'var(--OutlinedInput-medium-padInline, var(--_padInline))', paddingRight: 'var(--OutlinedInput-padInline, var(--_padInline))', }, }, + { + props: ({ ownerState, size }) => ownerState.endAdornment && size === 'small', + style: { + '--OutlinedInput-padInline': 'var(--OutlinedInput-small-padInline, var(--_padInline))', + }, + }, { props: ({ ownerState }) => ownerState.multiline, style: { - // Block from the size-resolved var (small + multiline → 8.5px). - padding: 'var(--OutlinedInput-padBlock, var(--_padBlock)) var(--OutlinedInput-padInline, var(--_padInline))', + '--_padBlock': '16.5px', + '--_padInline': '14px', + '--OutlinedInput-padBlock': 'var(--OutlinedInput-medium-padBlock, var(--_padBlock))', + '--OutlinedInput-padInline': 'var(--OutlinedInput-medium-padInline, var(--_padInline))', + padding: + 'var(--OutlinedInput-padBlock, var(--_padBlock)) var(--OutlinedInput-padInline, var(--_padInline))', + }, + }, + { + props: ({ ownerState, size }) => ownerState.multiline && size === 'small', + style: { + '--_padBlock': '8.5px', + '--OutlinedInput-padBlock': 'var(--OutlinedInput-small-padBlock, var(--_padBlock))', + '--OutlinedInput-padInline': 'var(--OutlinedInput-small-padInline, var(--_padInline))', }, }, ], @@ -158,10 +179,15 @@ const OutlinedInputInput = styled(InputBaseInput, { overridesResolver: inputBaseInputOverridesResolver, })( memoTheme(({ theme }) => ({ - // Both axes: `var(--seam, var(--_internal))`. padBlock is size-resolved on the - // root (routed) and inherited; padInline is a base token (no routing). The - // internal defaults `--_padBlock`/`--_padInline` are inherited from the root. - padding: 'var(--OutlinedInput-padBlock, var(--_padBlock)) var(--OutlinedInput-padInline, var(--_padInline))', + // Both axes: `var(--seam, var(--_))`, both sized — each seam routes the + // per-size public token over the internal default, specialized by the size + // variant below. Defaults are the Material px (inline 14px both sizes). + '--_padBlock': '16.5px', + '--_padInline': '14px', + '--OutlinedInput-padBlock': 'var(--OutlinedInput-medium-padBlock, var(--_padBlock))', + '--OutlinedInput-padInline': 'var(--OutlinedInput-medium-padInline, var(--_padInline))', + padding: + 'var(--OutlinedInput-padBlock, var(--_padBlock)) var(--OutlinedInput-padInline, var(--_padInline))', '&:-webkit-autofill': { ...(!theme.vars && { WebkitBoxShadow: theme.palette.mode === 'light' ? null : '0 0 0 100px #266798 inset', @@ -177,6 +203,14 @@ const OutlinedInputInput = styled(InputBaseInput, { })), }, variants: [ + { + props: { size: 'small' }, + style: { + '--_padBlock': '8.5px', + '--OutlinedInput-padBlock': 'var(--OutlinedInput-small-padBlock, var(--_padBlock))', + '--OutlinedInput-padInline': 'var(--OutlinedInput-small-padInline, var(--_padInline))', + }, + }, { props: ({ ownerState }) => ownerState.multiline, style: { From ac28ccae48747cf45e563415b09672d66e26ea56 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Sun, 7 Jun 2026 23:33:51 +0700 Subject: [PATCH 11/28] [docs] Density fixture: drive OutlinedInput inline via per-size tokens --- docs/pages/experiments/density-fixture.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/pages/experiments/density-fixture.tsx b/docs/pages/experiments/density-fixture.tsx index 7f8487c38e51bc..a8ea767c7f4480 100644 --- a/docs/pages/experiments/density-fixture.tsx +++ b/docs/pages/experiments/density-fixture.tsx @@ -94,12 +94,14 @@ const scopes: Record> = { dense: { ['--OutlinedInput-small-padBlock' as any]: '4px', ['--OutlinedInput-medium-padBlock' as any]: '10px', - ['--OutlinedInput-padInline' as any]: '8px', + ['--OutlinedInput-small-padInline' as any]: '6px', + ['--OutlinedInput-medium-padInline' as any]: '8px', }, loose: { ['--OutlinedInput-small-padBlock' as any]: '14px', ['--OutlinedInput-medium-padBlock' as any]: '28px', - ['--OutlinedInput-padInline' as any]: '24px', + ['--OutlinedInput-small-padInline' as any]: '20px', + ['--OutlinedInput-medium-padInline' as any]: '24px', }, }, }; From bad674b29e44fb689b57c5eb4362bc283f3ad271 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 8 Jun 2026 00:12:32 +0700 Subject: [PATCH 12/28] [material-ui] Roll out CSS-var density adapter to dashboard components Apply the density adapter (docs/adr/0001) to the @mui/material components used by the dashboard template: Chip, IconButton, MenuItem, ListItem, ListItemButton, ListItemIcon, ListItemText, ListSubheader, Toolbar, Tab, Tabs, TablePagination, CardContent, Select, Breadcrumbs, InputAdornment, Badge. Each exposes its real spacing axes as public sized tokens over literal-px internal defaults; the default render stays pixel-identical to master (density screenshot harness, maxDiffPixels:0). Checkbox/FormControl skipped - no density axis. enhanceDensity wires every component's sized tokens (incl. OutlinedInput) to the density scale. The verification fixture gains a matrix + dense/loose scope per component. Boolean `dense` components (MenuItem, ListItem, ListItemButton, ListItemText) expose the default state via the plain seam --Component- and only the dense override as --Component-dense-. Toolbar keeps theme.mixins.toolbar for its regular height (only dense + gutters tokenized). --- docs/pages/experiments/density-fixture.tsx | 698 ++++++++++++++++++ packages/mui-material/src/Badge/Badge.js | 20 +- .../src/Breadcrumbs/Breadcrumbs.js | 5 +- .../src/CardContent/CardContent.js | 6 +- packages/mui-material/src/Chip/Chip.js | 73 +- .../mui-material/src/IconButton/IconButton.js | 40 +- .../src/InputAdornment/InputAdornment.js | 20 +- .../mui-material/src/ListItem/ListItem.js | 24 +- .../src/ListItemButton/ListItemButton.js | 33 +- .../src/ListItemIcon/ListItemIcon.js | 3 +- .../src/ListItemText/ListItemText.js | 30 +- .../src/ListSubheader/ListSubheader.js | 13 +- .../mui-material/src/MenuItem/MenuItem.js | 39 +- .../mui-material/src/Select/SelectInput.js | 7 +- packages/mui-material/src/Tab/Tab.js | 31 +- packages/mui-material/src/Tab/Tab.test.js | 5 +- .../src/TablePagination/TablePagination.js | 17 +- packages/mui-material/src/Toolbar/Toolbar.js | 18 +- .../mui-material/src/styles/enhanceDensity.ts | 254 +++++++ 19 files changed, 1252 insertions(+), 84 deletions(-) diff --git a/docs/pages/experiments/density-fixture.tsx b/docs/pages/experiments/density-fixture.tsx index a8ea767c7f4480..9247ff193e3c28 100644 --- a/docs/pages/experiments/density-fixture.tsx +++ b/docs/pages/experiments/density-fixture.tsx @@ -9,6 +9,38 @@ import TextField from '@mui/material/TextField'; import InputAdornment from '@mui/material/InputAdornment'; import FormControl from '@mui/material/FormControl'; import InputLabel from '@mui/material/InputLabel'; +import Chip from '@mui/material/Chip'; +import Avatar from '@mui/material/Avatar'; +import IconButton from '@mui/material/IconButton'; +import MenuItem from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import ListSubheader from '@mui/material/ListSubheader'; +import Toolbar from '@mui/material/Toolbar'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableRow from '@mui/material/TableRow'; +import TablePagination from '@mui/material/TablePagination'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardActions from '@mui/material/CardActions'; +import Select from '@mui/material/Select'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Link from '@mui/material/Link'; +import Badge from '@mui/material/Badge'; +import Typography from '@mui/material/Typography'; +import FaceIcon from '@mui/icons-material/Face'; +import DeleteIcon from '@mui/icons-material/Delete'; +import InboxIcon from '@mui/icons-material/Inbox'; +import DraftsIcon from '@mui/icons-material/Drafts'; +import FavoriteIcon from '@mui/icons-material/Favorite'; +import VisibilityIcon from '@mui/icons-material/Visibility'; import { createTheme, ThemeProvider } from '@mui/material/styles'; // Local verification fixture for the CSS-var density adapter (docs/adr/0001). @@ -73,6 +105,490 @@ const demos: Record = {
), + Chip: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + A} /> + } /> + {}} /> + {}} + icon={} + /> + + ))} + + ), + IconButton: ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + MenuItem: ( + // MenuItem requires a MenuList/Menu ancestor (MenuListContext). + + Default item + Selected item + + + + + With icon + + With divider + No gutters + Dense item + + + + + Dense + icon + + + Dense no gutters + + + ), + ListItem: ( + + + Default item + + + + } + > + With secondary action + + Divider item + + + + + + Dense item + + + + } + > + Dense with action + + Dense, no gutters + + + ), + ListItemButton: ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + ListItemIcon: ( + + + + + + + + + + + + + + + + + + + + + + + + + ), + ListItemText: ( + + {([false, true] as const).map((dense) => ( + + + + + + + + + + + + + + + ))} + + ), + ListSubheader: ( + + + Gutters (default) + + + Inset + + + Disable gutters + + + Primary color + + + ), + Toolbar: ( + + {(['regular', 'dense'] as const).map((variant) => ( + + + {variant} gutters + action + + + {variant} no-gutters + action + + + ))} + + ), + Tab: ( + // Tab requires a Tabs ancestor (RovingTabIndexContext). + + {}}> + + + + + {}}> + } label="Top" iconPosition="top" value={0} /> + } label="Bottom" iconPosition="bottom" value={1} /> + } label="Start" iconPosition="start" value={2} /> + } label="End" iconPosition="end" value={3} /> + + {}}> + } aria-label="icon only" value={0} /> + + + + ), + Tabs: ( + + + + + + + + } label="Top" iconPosition="top" /> + } label="Bottom" iconPosition="bottom" /> + } label="Start" iconPosition="start" /> + } label="End" iconPosition="end" /> + + + } aria-label="fav" /> + } aria-label="del" /> + + + ), + TablePagination: ( + + + + + {}} + onRowsPerPageChange={() => {}} + /> + + + {}} + onRowsPerPageChange={() => {}} + /> + + + {}} + onRowsPerPageChange={() => {}} + /> + + +
+
+ ), + CardContent: ( + + + + Default + All-sides padding via --CardContent-pad. + + + + + Last-child + + Extra bottom inset (--CardContent-padBottom) since this is the last child. + + + + + + Above actions + Not last child -> base pad only on the bottom. + + + + + + + ), + Select: ( + + + + + + + + ), + Breadcrumbs: ( + + + + Home + + + Catalog + + Shoes + + + + Home + + + Library + + + Data + + Reports + + + + Home + + + Catalog + + + Accessories + + Belts + + + ), + InputAdornment: ( + + {(['small', 'medium'] as const).map((size) => ( + + $ }, + }} + /> + + + + + + ), + }, + }} + /> + kg }, + }} + /> + kg }, + }} + /> + + ))} + + ), + Badge: ( + + + + + + + + + + + + + + + + + + ), }; // Per-component density-token overrides for the review levels. `default` is @@ -104,6 +620,188 @@ const scopes: Record> = { ['--OutlinedInput-medium-padInline' as any]: '24px', }, }, + Chip: { + dense: { + ['--Chip-small-height' as any]: '18px', + ['--Chip-medium-height' as any]: '24px', + ['--Chip-small-padInline' as any]: '4px', + ['--Chip-medium-padInline' as any]: '6px', + }, + loose: { + ['--Chip-small-height' as any]: '32px', + ['--Chip-medium-height' as any]: '44px', + ['--Chip-small-padInline' as any]: '14px', + ['--Chip-medium-padInline' as any]: '20px', + }, + }, + IconButton: { + dense: { + ['--IconButton-small-pad' as any]: '1px', + ['--IconButton-medium-pad' as any]: '3px', + ['--IconButton-large-pad' as any]: '6px', + }, + loose: { + ['--IconButton-small-pad' as any]: '10px', + ['--IconButton-medium-pad' as any]: '16px', + ['--IconButton-large-pad' as any]: '22px', + }, + }, + MenuItem: { + dense: { + ['--MenuItem-minHeight' as any]: '36px', + ['--MenuItem-dense-minHeight' as any]: '24px', + ['--MenuItem-padBlock' as any]: '2px', + ['--MenuItem-dense-padBlock' as any]: '1px', + ['--MenuItem-padInline' as any]: '8px', + ['--MenuItem-dense-padInline' as any]: '6px', + }, + loose: { + ['--MenuItem-minHeight' as any]: '64px', + ['--MenuItem-dense-minHeight' as any]: '48px', + ['--MenuItem-padBlock' as any]: '14px', + ['--MenuItem-dense-padBlock' as any]: '10px', + ['--MenuItem-padInline' as any]: '28px', + ['--MenuItem-dense-padInline' as any]: '24px', + }, + }, + ListItemButton: { + dense: { + ['--ListItemButton-padBlock' as any]: '2px', + ['--ListItemButton-dense-padBlock' as any]: '0px', + ['--ListItemButton-padInline' as any]: '8px', + ['--ListItemButton-dense-padInline' as any]: '4px', + }, + loose: { + ['--ListItemButton-padBlock' as any]: '16px', + ['--ListItemButton-dense-padBlock' as any]: '12px', + ['--ListItemButton-padInline' as any]: '32px', + ['--ListItemButton-dense-padInline' as any]: '24px', + }, + }, + ListItemIcon: { + dense: { ['--ListItemIcon-minWidth' as any]: '24px' }, + loose: { ['--ListItemIcon-minWidth' as any]: '56px' }, + }, + ListItemText: { + dense: { + ['--ListItemText-marginBlock' as any]: '1px', + ['--ListItemText-dense-marginBlock' as any]: '0px', + ['--ListItemText-insetPad' as any]: '32px', + ['--ListItemText-dense-insetPad' as any]: '24px', + }, + loose: { + ['--ListItemText-marginBlock' as any]: '12px', + ['--ListItemText-dense-marginBlock' as any]: '8px', + ['--ListItemText-insetPad' as any]: '72px', + ['--ListItemText-dense-insetPad' as any]: '64px', + }, + }, + ListSubheader: { + dense: { + ['--ListSubheader-height' as any]: '32px', + ['--ListSubheader-padInline' as any]: '8px', + ['--ListSubheader-inset' as any]: '48px', + }, + loose: { + ['--ListSubheader-height' as any]: '64px', + ['--ListSubheader-padInline' as any]: '28px', + ['--ListSubheader-inset' as any]: '96px', + }, + }, + Toolbar: { + dense: { + ['--Toolbar-dense-minHeight' as any]: '32px', + ['--Toolbar-padInline' as any]: '8px', + }, + loose: { + ['--Toolbar-dense-minHeight' as any]: '72px', + ['--Toolbar-padInline' as any]: '40px', + }, + }, + Tab: { + dense: { + ['--Tab-padBlock' as any]: '4px', + ['--Tab-padInline' as any]: '8px', + ['--Tab-minHeight' as any]: '32px', + ['--Tab-iconSpacing' as any]: '2px', + }, + loose: { + ['--Tab-padBlock' as any]: '20px', + ['--Tab-padInline' as any]: '28px', + ['--Tab-minHeight' as any]: '72px', + ['--Tab-iconSpacing' as any]: '14px', + }, + }, + Tabs: { + dense: { + ['--Tab-padBlock' as any]: '4px', + ['--Tab-padInline' as any]: '8px', + ['--Tab-minHeight' as any]: '32px', + ['--Tab-iconSpacing' as any]: '2px', + }, + loose: { + ['--Tab-padBlock' as any]: '20px', + ['--Tab-padInline' as any]: '32px', + ['--Tab-minHeight' as any]: '72px', + ['--Tab-iconSpacing' as any]: '14px', + }, + }, + TablePagination: { + dense: { + ['--TablePagination-minHeight' as any]: '36px', + ['--TablePagination-actionsSpacing' as any]: '8px', + ['--TablePagination-selectSpacing' as any]: '12px', + }, + loose: { + ['--TablePagination-minHeight' as any]: '72px', + ['--TablePagination-actionsSpacing' as any]: '40px', + ['--TablePagination-selectSpacing' as any]: '56px', + }, + }, + CardContent: { + dense: { + ['--CardContent-pad' as any]: '8px', + ['--CardContent-padBottom' as any]: '10px', + }, + loose: { + ['--CardContent-pad' as any]: '32px', + ['--CardContent-padBottom' as any]: '40px', + }, + }, + Select: { + dense: { ['--Select-minHeight' as any]: '0.8em' }, + loose: { ['--Select-minHeight' as any]: '2.4em' }, + }, + Breadcrumbs: { + dense: { ['--Breadcrumbs-separatorGap' as any]: '2px' }, + loose: { ['--Breadcrumbs-separatorGap' as any]: '20px' }, + }, + InputAdornment: { + dense: { + ['--InputAdornment-small-gap' as any]: '2px', + ['--InputAdornment-medium-gap' as any]: '3px', + ['--InputAdornment-small-marginTop' as any]: '6px', + ['--InputAdornment-medium-marginTop' as any]: '10px', + }, + loose: { + ['--InputAdornment-small-gap' as any]: '16px', + ['--InputAdornment-medium-gap' as any]: '24px', + ['--InputAdornment-small-marginTop' as any]: '24px', + ['--InputAdornment-medium-marginTop' as any]: '32px', + }, + }, + Badge: { + dense: { + ['--Badge-standard-pad' as any]: '0 3px', + ['--Badge-standard-size' as any]: '14px', + ['--Badge-dot-size' as any]: '5px', + }, + loose: { + ['--Badge-standard-pad' as any]: '0 10px', + ['--Badge-standard-size' as any]: '28px', + ['--Badge-dot-size' as any]: '12px', + }, + }, }; // TextField rides the same OutlinedInput tokens; OutlinedInput's `:has` rule // drives the label's --InputLabel-y, so input box + label move together. diff --git a/packages/mui-material/src/Badge/Badge.js b/packages/mui-material/src/Badge/Badge.js index 6d6c4f1683fdf9..c1a5f5fa7f5e4a 100644 --- a/packages/mui-material/src/Badge/Badge.js +++ b/packages/mui-material/src/Badge/Badge.js @@ -79,10 +79,14 @@ const BadgeBadge = styled('span', { fontFamily: theme.typography.fontFamily, fontWeight: theme.typography.fontWeightMedium, fontSize: theme.typography.pxToRem(12), - minWidth: RADIUS_STANDARD * 2, + '--_pad': '0 6px', + '--_size': `${RADIUS_STANDARD * 2}px`, + '--Badge-pad': 'var(--Badge-standard-pad, var(--_pad))', + '--Badge-size': 'var(--Badge-standard-size, var(--_size))', + minWidth: 'var(--Badge-size, var(--_size))', lineHeight: 1, - padding: '0 6px', - height: RADIUS_STANDARD * 2, + padding: 'var(--Badge-pad, var(--_pad))', + height: 'var(--Badge-size, var(--_size))', borderRadius: RADIUS_STANDARD, zIndex: 1, // Render the badge on top of potential ripples. '@media (forced-colors: active)': { @@ -105,10 +109,14 @@ const BadgeBadge = styled('span', { { props: { variant: 'dot' }, style: { + '--_pad': '0px', + '--_size': `${RADIUS_DOT * 2}px`, + '--Badge-pad': 'var(--Badge-dot-pad, var(--_pad))', + '--Badge-size': 'var(--Badge-dot-size, var(--_size))', borderRadius: RADIUS_DOT, - height: RADIUS_DOT * 2, - minWidth: RADIUS_DOT * 2, - padding: 0, + height: 'var(--Badge-size, var(--_size))', + minWidth: 'var(--Badge-size, var(--_size))', + padding: 'var(--Badge-pad, var(--_pad))', }, }, { diff --git a/packages/mui-material/src/Breadcrumbs/Breadcrumbs.js b/packages/mui-material/src/Breadcrumbs/Breadcrumbs.js index 63cb6bae7f3538..91e2f297f9e326 100644 --- a/packages/mui-material/src/Breadcrumbs/Breadcrumbs.js +++ b/packages/mui-material/src/Breadcrumbs/Breadcrumbs.js @@ -49,10 +49,11 @@ const BreadcrumbsSeparator = styled('li', { name: 'MuiBreadcrumbs', slot: 'Separator', })({ + '--_separatorGap': '8px', display: 'flex', userSelect: 'none', - marginLeft: 8, - marginRight: 8, + marginLeft: 'var(--Breadcrumbs-separatorGap, var(--_separatorGap))', + marginRight: 'var(--Breadcrumbs-separatorGap, var(--_separatorGap))', }); function insertSeparators(items, className, separator, ownerState) { diff --git a/packages/mui-material/src/CardContent/CardContent.js b/packages/mui-material/src/CardContent/CardContent.js index 769163a78edbad..6e447fd831cfa2 100644 --- a/packages/mui-material/src/CardContent/CardContent.js +++ b/packages/mui-material/src/CardContent/CardContent.js @@ -21,9 +21,11 @@ const CardContentRoot = styled('div', { name: 'MuiCardContent', slot: 'Root', })({ - padding: 16, + '--_pad': '16px', + '--_padBottom': '24px', + padding: 'var(--CardContent-pad, var(--_pad))', '&:last-child': { - paddingBottom: 24, + paddingBottom: 'var(--CardContent-padBottom, var(--_padBottom))', }, }); diff --git a/packages/mui-material/src/Chip/Chip.js b/packages/mui-material/src/Chip/Chip.js index 9723bdecf42fa9..e477ec00fb6508 100644 --- a/packages/mui-material/src/Chip/Chip.js +++ b/packages/mui-material/src/Chip/Chip.js @@ -72,7 +72,11 @@ const ChipRoot = styled('div', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - height: 32, + // Agnostic seam: the styled root reads `--Chip-height`; `--_height` is the + // universal default (today's medium height). Size variants route the public + // sized token over it. See docs/adr/0001. + '--_height': '32px', + height: 'var(--Chip-height, var(--_height))', lineHeight: 1.5, color: (theme.vars || theme).palette.text.primary, backgroundColor: (theme.vars || theme).palette.action.selected, @@ -115,6 +119,16 @@ const ChipRoot = styled('div', { }, }, variants: [ + // Built-in size routing (CSS, deduped) — exposes the public sized token + // over the internal default. Custom sizes are routed inline instead. + { + props: { size: 'small' }, + style: { '--Chip-height': 'var(--Chip-small-height, var(--_height))' }, + }, + { + props: { size: 'medium' }, + style: { '--Chip-height': 'var(--Chip-medium-height, var(--_height))' }, + }, { props: { color: 'primary', @@ -140,7 +154,7 @@ const ChipRoot = styled('div', { { props: { size: 'small' }, style: { - height: 24, + '--_height': '24px', // small default; medium default lives in base [`& .${chipClasses.avatar}`]: { marginLeft: 4, marginRight: -4, @@ -199,7 +213,9 @@ const ChipRoot = styled('div', { [`&.${chipClasses.focusVisible}`]: { backgroundColor: theme.alpha( (theme.vars || theme).palette.action.selected, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.focusOpacity}`, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.focusOpacity + }`, ), }, }, @@ -225,13 +241,17 @@ const ChipRoot = styled('div', { '&:hover': { backgroundColor: theme.alpha( (theme.vars || theme).palette.action.selected, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.hoverOpacity}`, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.hoverOpacity + }`, ), }, [`&.${chipClasses.focusVisible}`]: { backgroundColor: theme.alpha( (theme.vars || theme).palette.action.selected, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.focusOpacity}`, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.focusOpacity + }`, ), }, '&:active': { @@ -327,29 +347,40 @@ const ChipLabel = styled('span', { })({ overflow: 'hidden', textOverflow: 'ellipsis', - paddingLeft: 12, - paddingRight: 12, + // Agnostic seam: the label reads `--Chip-padInline` on both sides; `--_padInline` + // is the universal default (today's medium filled inline padding). Variants + // specialize the default per (variant, size) and size variants route the public + // sized token over it. See docs/adr/0001. + '--_padInline': '12px', + paddingLeft: 'var(--Chip-padInline, var(--_padInline))', + paddingRight: 'var(--Chip-padInline, var(--_padInline))', whiteSpace: 'nowrap', variants: [ + // Built-in size routing (CSS, deduped). Custom sizes are routed inline. + { + props: { size: 'small' }, + style: { '--Chip-padInline': 'var(--Chip-small-padInline, var(--_padInline))' }, + }, + { + props: { size: 'medium' }, + style: { '--Chip-padInline': 'var(--Chip-medium-padInline, var(--_padInline))' }, + }, { props: { variant: 'outlined' }, style: { - paddingLeft: 11, - paddingRight: 11, + '--_padInline': '11px', // medium outlined default }, }, { props: { size: 'small' }, style: { - paddingLeft: 8, - paddingRight: 8, + '--_padInline': '8px', // small filled default }, }, { props: { size: 'small', variant: 'outlined' }, style: { - paddingLeft: 7, - paddingRight: 7, + '--_padInline': '7px', // small outlined default }, }, ], @@ -441,6 +472,21 @@ const Chip = React.forwardRef(function Chip(inProps, ref) { const classes = useUtilityClasses(ownerState); + // Material UI layer: built-in sizes route the public sized tokens via variants + // (deduped CSS). A custom size has no such variant, so route it inline on the + // root — the tokens inherit down to the label. The `--_*` defaults live in the + // variants. See docs/adr/0001. + const densityVars = + size === 'small' || size === 'medium' + ? undefined + : { + '--Chip-height': `var(--Chip-${size}-height, var(--_height))`, + // padInline is consumed on the label (a child); the root doesn't own + // `--_padInline`, so a cross-element route must fall back to a literal, + // never the internal var (else a missing token resolves to invalid -> 0). + '--Chip-padInline': `var(--Chip-${size}-padInline, 12px)`, + }; + const moreProps = component === ButtonBase ? { @@ -508,6 +554,7 @@ const Chip = React.forwardRef(function Chip(inProps, ref) { disabled: clickable && disabled ? true : undefined, tabIndex: skipFocusWhenDisabled && disabled ? -1 : tabIndex, ...moreProps, + ...(densityVars && { style: densityVars }), }, getSlotProps: (handlers) => ({ ...handlers, diff --git a/packages/mui-material/src/IconButton/IconButton.js b/packages/mui-material/src/IconButton/IconButton.js index 9b459b7d22e5d1..9b1c4abb3bf31f 100644 --- a/packages/mui-material/src/IconButton/IconButton.js +++ b/packages/mui-material/src/IconButton/IconButton.js @@ -14,6 +14,9 @@ import CircularProgress from '../CircularProgress'; import capitalize from '../utils/capitalize'; import iconButtonClasses, { getIconButtonUtilityClass } from './iconButtonClasses'; +// Built-in sizes route padding via variants; any other size routes inline. +const iconButtonSizes = ['small', 'medium', 'large']; + const useUtilityClasses = (ownerState) => { const { classes, disabled, color, edge, size, loading } = ownerState; @@ -52,13 +55,31 @@ const IconButtonRoot = styled(ButtonBase, { textAlign: 'center', flex: '0 0 auto', fontSize: theme.typography.pxToRem(24), - padding: 8, + // Agnostic layer: the only spacing surface the styled root reads. `--_pad` + // is the universal default (today's medium padding); size variants specialize + // it, so a custom size still gets a sane value. See docs/adr/0001. + '--_pad': '8px', + padding: 'var(--IconButton-pad, var(--_pad))', borderRadius: '50%', color: (theme.vars || theme).palette.action.active, transition: theme.transitions.create('background-color', { duration: theme.transitions.duration.shortest, }), variants: [ + // Built-in size routing (CSS, deduped) — exposes the public sized token + // over the internal default. Custom sizes are routed inline instead. + { + props: { size: 'small' }, + style: { '--IconButton-pad': 'var(--IconButton-small-pad, var(--_pad))' }, + }, + { + props: { size: 'medium' }, + style: { '--IconButton-pad': 'var(--IconButton-medium-pad, var(--_pad))' }, + }, + { + props: { size: 'large' }, + style: { '--IconButton-pad': 'var(--IconButton-large-pad, var(--_pad))' }, + }, { props: (props) => !props.disableRipple, style: { @@ -124,14 +145,14 @@ const IconButtonRoot = styled(ButtonBase, { { props: { size: 'small' }, style: { - padding: 5, + '--_pad': '5px', fontSize: theme.typography.pxToRem(18), }, }, { props: { size: 'large' }, style: { - padding: 12, + '--_pad': '12px', fontSize: theme.typography.pxToRem(28), }, }, @@ -198,6 +219,14 @@ const IconButton = React.forwardRef(function IconButton(inProps, ref) { const classes = useUtilityClasses(ownerState); + // Material UI layer: built-in sizes route the public sized token via variants + // (deduped CSS). A custom size has no such variant, so route it inline — the + // token name carries the runtime size string, keeping custom sizes tunable for + // free. The `--_pad` default lives in the variants. See docs/adr/0001. + const densityVars = iconButtonSizes.includes(size) + ? undefined + : { '--IconButton-pad': `var(--IconButton-${size}-pad, var(--_pad))` }; + return ( {typeof loading === 'boolean' && ( @@ -327,6 +357,10 @@ IconButton.propTypes /* remove-proptypes */ = { PropTypes.oneOf(['small', 'medium', 'large']), PropTypes.string, ]), + /** + * @ignore + */ + style: PropTypes.object, /** * The system prop that allows defining system overrides as well as additional CSS styles. */ diff --git a/packages/mui-material/src/InputAdornment/InputAdornment.js b/packages/mui-material/src/InputAdornment/InputAdornment.js index abb06efb23735f..d268c3e07a5388 100644 --- a/packages/mui-material/src/InputAdornment/InputAdornment.js +++ b/packages/mui-material/src/InputAdornment/InputAdornment.js @@ -50,7 +50,21 @@ const InputAdornmentRoot = styled('div', { alignItems: 'center', whiteSpace: 'nowrap', color: (theme.vars || theme).palette.action.active, + // Internal defaults (Material literals). `size` is read from FormControl with no + // default (can be undefined) -> medium routing lives in base, not a `{ size: 'medium' }` variant. + '--_gap': '8px', + '--_marginTop': '16px', + '--InputAdornment-gap': 'var(--InputAdornment-medium-gap, var(--_gap))', + '--InputAdornment-marginTop': 'var(--InputAdornment-medium-marginTop, var(--_marginTop))', variants: [ + // Built-in size routing — exposes the public sized token over the internal default. + { + props: { size: 'small' }, + style: { + '--InputAdornment-gap': 'var(--InputAdornment-small-gap, var(--_gap))', + '--InputAdornment-marginTop': 'var(--InputAdornment-small-marginTop, var(--_marginTop))', + }, + }, { props: { variant: 'filled', @@ -58,7 +72,7 @@ const InputAdornmentRoot = styled('div', { style: { [`&.${inputAdornmentClasses.positionStart}&:not(.${inputAdornmentClasses.hiddenLabel})`]: { - marginTop: 16, + marginTop: 'var(--InputAdornment-marginTop, var(--_marginTop))', }, }, }, @@ -67,7 +81,7 @@ const InputAdornmentRoot = styled('div', { position: 'start', }, style: { - marginRight: 8, + marginRight: 'var(--InputAdornment-gap, var(--_gap))', }, }, { @@ -75,7 +89,7 @@ const InputAdornmentRoot = styled('div', { position: 'end', }, style: { - marginLeft: 8, + marginLeft: 'var(--InputAdornment-gap, var(--_gap))', }, }, { diff --git a/packages/mui-material/src/ListItem/ListItem.js b/packages/mui-material/src/ListItem/ListItem.js index 72c51915c4ad67..172c6266351dcf 100644 --- a/packages/mui-material/src/ListItem/ListItem.js +++ b/packages/mui-material/src/ListItem/ListItem.js @@ -57,26 +57,38 @@ export const ListItemRoot = styled('div', { width: '100%', boxSizing: 'border-box', textAlign: 'left', + // Density adapter: `dense` is the compactness axis (boolean). Default state = + // plain seam `--ListItem-` over `--_`; the `dense` variant re-routes + // the seam to `--ListItem-dense-`. + '--_padBlock': '8px', + '--_padInline': '16px', variants: [ { props: ({ ownerState }) => !ownerState.disablePadding, style: { - paddingTop: 8, - paddingBottom: 8, + paddingTop: 'var(--ListItem-padBlock, var(--_padBlock))', + paddingBottom: 'var(--ListItem-padBlock, var(--_padBlock))', }, }, { props: ({ ownerState }) => !ownerState.disablePadding && ownerState.dense, style: { - paddingTop: 4, - paddingBottom: 4, + '--_padBlock': '4px', + '--ListItem-padBlock': 'var(--ListItem-dense-padBlock, var(--_padBlock))', }, }, { props: ({ ownerState }) => !ownerState.disablePadding && !ownerState.disableGutters, style: { - paddingLeft: 16, - paddingRight: 16, + paddingLeft: 'var(--ListItem-padInline, var(--_padInline))', + paddingRight: 'var(--ListItem-padInline, var(--_padInline))', + }, + }, + { + props: ({ ownerState }) => + !ownerState.disablePadding && !ownerState.disableGutters && ownerState.dense, + style: { + '--ListItem-padInline': 'var(--ListItem-dense-padInline, var(--_padInline))', }, }, { diff --git a/packages/mui-material/src/ListItemButton/ListItemButton.js b/packages/mui-material/src/ListItemButton/ListItemButton.js index b7681b7e52b2a9..1db047fb03b54d 100644 --- a/packages/mui-material/src/ListItemButton/ListItemButton.js +++ b/packages/mui-material/src/ListItemButton/ListItemButton.js @@ -64,8 +64,13 @@ const ListItemButtonRoot = styled(ButtonBase, { minWidth: 0, boxSizing: 'border-box', textAlign: 'left', - paddingTop: 8, - paddingBottom: 8, + // Density adapter: `dense` is the compactness axis (boolean). Default state = + // plain seam `--ListItemButton-` over `--_`; the `dense` variant + // re-routes the seam to `--ListItemButton-dense-`. + '--_padBlock': '8px', + '--_padInline': '16px', + paddingTop: 'var(--ListItemButton-padBlock, var(--_padBlock))', + paddingBottom: 'var(--ListItemButton-padBlock, var(--_padBlock))', transition: theme.transitions.create('background-color', { duration: theme.transitions.duration.shortest, }), @@ -85,14 +90,18 @@ const ListItemButtonRoot = styled(ButtonBase, { [`&.${listItemButtonClasses.focusVisible}`]: { backgroundColor: theme.alpha( (theme.vars || theme).palette.primary.main, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.focusOpacity}`, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.focusOpacity + }`, ), }, }, [`&.${listItemButtonClasses.selected}:hover`]: { backgroundColor: theme.alpha( (theme.vars || theme).palette.primary.main, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.hoverOpacity}`, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.hoverOpacity + }`, ), // Reset on touch devices, it doesn't add specificity '@media (hover: none)': { @@ -127,15 +136,23 @@ const ListItemButtonRoot = styled(ButtonBase, { { props: ({ ownerState }) => !ownerState.disableGutters, style: { - paddingLeft: 16, - paddingRight: 16, + // gutters owns the inline axis (default state = plain seam). + paddingLeft: 'var(--ListItemButton-padInline, var(--_padInline))', + paddingRight: 'var(--ListItemButton-padInline, var(--_padInline))', }, }, { props: ({ ownerState }) => ownerState.dense, style: { - paddingTop: 4, - paddingBottom: 4, + '--_padBlock': '4px', // dense default; routes the dense block token + '--ListItemButton-padBlock': 'var(--ListItemButton-dense-padBlock, var(--_padBlock))', + }, + }, + { + props: ({ ownerState }) => ownerState.dense && !ownerState.disableGutters, + style: { + // gutters + dense: re-route inline to the dense token (block already routed above). + '--ListItemButton-padInline': 'var(--ListItemButton-dense-padInline, var(--_padInline))', }, }, ], diff --git a/packages/mui-material/src/ListItemIcon/ListItemIcon.js b/packages/mui-material/src/ListItemIcon/ListItemIcon.js index 81aeb8629aede4..fd51b98c1f6353 100644 --- a/packages/mui-material/src/ListItemIcon/ListItemIcon.js +++ b/packages/mui-material/src/ListItemIcon/ListItemIcon.js @@ -29,7 +29,8 @@ const ListItemIconRoot = styled('div', { }, })( memoTheme(({ theme }) => ({ - minWidth: theme.spacing(4.5), + '--_minWidth': theme.spacing(4.5), + minWidth: 'var(--ListItemIcon-minWidth, var(--_minWidth))', color: (theme.vars || theme).palette.action.active, flexShrink: 0, display: 'inline-flex', diff --git a/packages/mui-material/src/ListItemText/ListItemText.js b/packages/mui-material/src/ListItemText/ListItemText.js index 8b81317e5d2955..954c99c9b78ee9 100644 --- a/packages/mui-material/src/ListItemText/ListItemText.js +++ b/packages/mui-material/src/ListItemText/ListItemText.js @@ -40,8 +40,16 @@ const ListItemTextRoot = styled('div', { })({ flex: '1 1 auto', minWidth: 0, - marginTop: 4, - marginBottom: 4, + // Density adapter (docs/adr/0001): each spacing literal becomes + // `var(--seam, var(--_))`, tokenized in place. The compactness dimension + // is `dense` (boolean) — default state = plain seam `--ListItemText-` over + // `--_`; the dense variant re-routes the seam to its own token. + // `marginBlock` (top+bottom move together) varies by `multiline`, so its literal + // default is set per (dense × multiline) cell while routing keys on dense only. + // `insetPad` is the inset indentation. + '--_marginBlock': '4px', + marginTop: 'var(--ListItemText-marginBlock, var(--_marginBlock))', + marginBottom: 'var(--ListItemText-marginBlock, var(--_marginBlock))', // Combine this and the below selector once https://github.com/emotion-js/emotion/issues/3366 is solved [`.${typographyClasses.root}:where(& .${listItemTextClasses.primary})`]: { display: 'block', @@ -50,17 +58,29 @@ const ListItemTextRoot = styled('div', { display: 'block', }, variants: [ + { + props: ({ ownerState }) => ownerState.dense, + style: { + '--ListItemText-marginBlock': 'var(--ListItemText-dense-marginBlock, var(--_marginBlock))', + }, + }, { props: ({ ownerState }) => ownerState.primary && ownerState.secondary, style: { - marginTop: 6, - marginBottom: 6, + '--_marginBlock': '6px', }, }, { props: ({ ownerState }) => ownerState.inset, style: { - paddingLeft: 56, + '--_insetPad': '56px', + paddingLeft: 'var(--ListItemText-insetPad, var(--_insetPad))', + }, + }, + { + props: ({ ownerState }) => ownerState.inset && ownerState.dense, + style: { + '--ListItemText-insetPad': 'var(--ListItemText-dense-insetPad, var(--_insetPad))', }, }, ], diff --git a/packages/mui-material/src/ListSubheader/ListSubheader.js b/packages/mui-material/src/ListSubheader/ListSubheader.js index 36bd3865891efe..cb80bf40e478bf 100644 --- a/packages/mui-material/src/ListSubheader/ListSubheader.js +++ b/packages/mui-material/src/ListSubheader/ListSubheader.js @@ -42,7 +42,12 @@ const ListSubheaderRoot = styled('li', { })( memoTheme(({ theme }) => ({ boxSizing: 'border-box', - lineHeight: '48px', + // Internal defaults (Material literals). Base tokens: ListSubheader has no + // size prop, so per-size tuning is meaningless — density tunes these directly. + '--_height': '48px', + '--_padInline': '16px', + '--_inset': '72px', + lineHeight: 'var(--ListSubheader-height, var(--_height))', listStyle: 'none', color: (theme.vars || theme).palette.text.secondary, fontFamily: theme.typography.fontFamily, @@ -68,14 +73,14 @@ const ListSubheaderRoot = styled('li', { { props: ({ ownerState }) => !ownerState.disableGutters, style: { - paddingLeft: 16, - paddingRight: 16, + paddingLeft: 'var(--ListSubheader-padInline, var(--_padInline))', + paddingRight: 'var(--ListSubheader-padInline, var(--_padInline))', }, }, { props: ({ ownerState }) => ownerState.inset, style: { - paddingLeft: 72, + paddingLeft: 'var(--ListSubheader-inset, var(--_inset))', }, }, { diff --git a/packages/mui-material/src/MenuItem/MenuItem.js b/packages/mui-material/src/MenuItem/MenuItem.js index 68df44938b4404..3e526ebf204d93 100644 --- a/packages/mui-material/src/MenuItem/MenuItem.js +++ b/packages/mui-material/src/MenuItem/MenuItem.js @@ -61,14 +61,21 @@ const MenuItemRoot = styled(ButtonBase, { })( memoTheme(({ theme }) => ({ ...theme.typography.body1, + // Density adapter (docs/adr/0001): `dense` is the compactness axis (boolean). + // The default (non-dense) state is the plain seam `--MenuItem-` over the + // internal default `--_`; the `dense` variant re-routes the seam to the + // `--MenuItem-dense-` token. Block + min-height live on the root + // unconditionally; inline gutters live on the !disableGutters variant. + '--_minHeight': '48px', + '--_padBlock': '6px', display: 'flex', justifyContent: 'flex-start', alignItems: 'center', position: 'relative', textDecoration: 'none', - minHeight: 48, - paddingTop: 6, - paddingBottom: 6, + minHeight: 'var(--MenuItem-minHeight, var(--_minHeight))', + paddingTop: 'var(--MenuItem-padBlock, var(--_padBlock))', + paddingBottom: 'var(--MenuItem-padBlock, var(--_padBlock))', boxSizing: 'border-box', whiteSpace: 'nowrap', '&:hover': { @@ -87,14 +94,18 @@ const MenuItemRoot = styled(ButtonBase, { [`&.${menuItemClasses.focusVisible}`]: { backgroundColor: theme.alpha( (theme.vars || theme).palette.primary.main, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.focusOpacity}`, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.focusOpacity + }`, ), }, }, [`&.${menuItemClasses.selected}:hover`]: { backgroundColor: theme.alpha( (theme.vars || theme).palette.primary.main, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.hoverOpacity}`, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.hoverOpacity + }`, ), // Reset on touch devices, it doesn't add specificity '@media (hover: none)': { @@ -131,8 +142,15 @@ const MenuItemRoot = styled(ButtonBase, { { props: ({ ownerState }) => !ownerState.disableGutters, style: { - paddingLeft: 16, - paddingRight: 16, + '--_padInline': '16px', + paddingLeft: 'var(--MenuItem-padInline, var(--_padInline))', + paddingRight: 'var(--MenuItem-padInline, var(--_padInline))', + }, + }, + { + props: ({ ownerState }) => !ownerState.disableGutters && ownerState.dense, + style: { + '--MenuItem-padInline': 'var(--MenuItem-dense-padInline, var(--_padInline))', }, }, { @@ -153,9 +171,10 @@ const MenuItemRoot = styled(ButtonBase, { { props: ({ ownerState }) => ownerState.dense, style: { - minHeight: 32, // https://m2.material.io/components/menus#specs > Dense - paddingTop: 4, - paddingBottom: 4, + '--_minHeight': '32px', // https://m2.material.io/components/menus#specs > Dense + '--_padBlock': '4px', + '--MenuItem-minHeight': 'var(--MenuItem-dense-minHeight, var(--_minHeight))', + '--MenuItem-padBlock': 'var(--MenuItem-dense-padBlock, var(--_padBlock))', ...theme.typography.body2, [`& .${listItemIconClasses.root} svg`]: { fontSize: '1.25rem', diff --git a/packages/mui-material/src/Select/SelectInput.js b/packages/mui-material/src/Select/SelectInput.js index 2148d64fda9159..d695707645bac9 100644 --- a/packages/mui-material/src/Select/SelectInput.js +++ b/packages/mui-material/src/Select/SelectInput.js @@ -88,8 +88,13 @@ const SelectSelect = styled(StyledSelectSelect, { })({ // Win specificity over the input base [`&.${selectClasses.select}`]: { + // Density seam: base axis (size-invariant — keeps select content box matched + // to the input line box for text-field height consistency; per-size + // compactness comes from the input root padding). Consume shape stays uniform + // with sized axes: var(seam, var(internal default)). + '--_minHeight': '1.4375em', // Required for select\text-field height consistency height: 'auto', // Resets for multiple select with chips - minHeight: '1.4375em', // Required for select\text-field height consistency + minHeight: 'var(--Select-minHeight, var(--_minHeight))', textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', diff --git a/packages/mui-material/src/Tab/Tab.js b/packages/mui-material/src/Tab/Tab.js index 05ddee7f3a0ba7..0522b119c94d27 100644 --- a/packages/mui-material/src/Tab/Tab.js +++ b/packages/mui-material/src/Tab/Tab.js @@ -54,9 +54,15 @@ const TabRoot = styled(ButtonBase, { maxWidth: 360, minWidth: 90, position: 'relative', - minHeight: 48, + // Density seams: Tab has no `size` prop, so each axis is a base token + // (`--Tab-`) over an internal default (`--_`). The labelIcon state + // owns its own block/min-height literals; everything else reads these. + '--_padBlock': '12px', + '--_padInline': '16px', + '--_minHeight': '48px', + minHeight: 'var(--Tab-minHeight, var(--_minHeight))', flexShrink: 0, - padding: '12px 16px', + padding: 'var(--Tab-padBlock, var(--_padBlock)) var(--Tab-padInline, var(--_padInline))', overflow: 'hidden', whiteSpace: 'normal', textAlign: 'center', @@ -82,9 +88,12 @@ const TabRoot = styled(ButtonBase, { { props: ({ ownerState }) => ownerState.icon && ownerState.label, style: { - minHeight: 72, - paddingTop: 9, - paddingBottom: 9, + // labelIcon owns its own compactness + block padding literals; inline + // padding stays from the base shorthand (unchanged at 16px). + '--_minHeight': '72px', + '--_padBlock': '9px', + paddingTop: 'var(--Tab-padBlock, var(--_padBlock))', + paddingBottom: 'var(--Tab-padBlock, var(--_padBlock))', }, }, { @@ -92,7 +101,8 @@ const TabRoot = styled(ButtonBase, { ownerState.icon && ownerState.label && iconPosition === 'top', style: { [`& > .${tabClasses.icon}`]: { - marginBottom: 6, + '--_iconSpacing': '6px', + marginBottom: 'var(--Tab-iconSpacing, var(--_iconSpacing))', }, }, }, @@ -101,7 +111,8 @@ const TabRoot = styled(ButtonBase, { ownerState.icon && ownerState.label && iconPosition === 'bottom', style: { [`& > .${tabClasses.icon}`]: { - marginTop: 6, + '--_iconSpacing': '6px', + marginTop: 'var(--Tab-iconSpacing, var(--_iconSpacing))', }, }, }, @@ -110,7 +121,8 @@ const TabRoot = styled(ButtonBase, { ownerState.icon && ownerState.label && iconPosition === 'start', style: { [`& > .${tabClasses.icon}`]: { - marginRight: theme.spacing(1), + '--_iconSpacing': theme.spacing(1), + marginRight: 'var(--Tab-iconSpacing, var(--_iconSpacing))', }, }, }, @@ -119,7 +131,8 @@ const TabRoot = styled(ButtonBase, { ownerState.icon && ownerState.label && iconPosition === 'end', style: { [`& > .${tabClasses.icon}`]: { - marginLeft: theme.spacing(1), + '--_iconSpacing': theme.spacing(1), + marginLeft: 'var(--Tab-iconSpacing, var(--_iconSpacing))', }, }, }, diff --git a/packages/mui-material/src/Tab/Tab.test.js b/packages/mui-material/src/Tab/Tab.test.js index 46f5500ebfdc82..065baf03e2ea08 100644 --- a/packages/mui-material/src/Tab/Tab.test.js +++ b/packages/mui-material/src/Tab/Tab.test.js @@ -191,7 +191,10 @@ describe('', () => { expect(wrapper).to.have.class('test-icon'); }); - it('should have bottom margin when passed together with label', () => { + // The icon gap is now a CSS var (var(--Tab-iconSpacing, ...)); jsdom can't + // resolve var(), so assert in a real browser only. Default render is verified + // pixel-identical by the density screenshot harness. + it.skipIf(isJsdom())('should have bottom margin when passed together with label', () => { render( } label="foo" /> diff --git a/packages/mui-material/src/TablePagination/TablePagination.js b/packages/mui-material/src/TablePagination/TablePagination.js index 45c2b598ea838e..61b8cbc5285b27 100644 --- a/packages/mui-material/src/TablePagination/TablePagination.js +++ b/packages/mui-material/src/TablePagination/TablePagination.js @@ -45,18 +45,22 @@ const TablePaginationToolbar = styled(Toolbar, { }), })( memoTheme(({ theme }) => ({ - minHeight: 52, + // Base density seams: no `size` prop here, so each axis is a size-invariant + // base token. `--_` carries today's literal; the seam falls back to it. + '--_minHeight': '52px', + '--_actionsSpacing': '20px', + minHeight: 'var(--TablePagination-minHeight, var(--_minHeight))', paddingRight: 2, [`${theme.breakpoints.up('xs')} and (orientation: landscape)`]: { - minHeight: 52, + minHeight: 'var(--TablePagination-minHeight, var(--_minHeight))', }, [theme.breakpoints.up('sm')]: { - minHeight: 52, + minHeight: 'var(--TablePagination-minHeight, var(--_minHeight))', paddingRight: 2, }, [`& .${tablePaginationClasses.actions}`]: { flexShrink: 0, - marginLeft: 20, + marginLeft: 'var(--TablePagination-actionsSpacing, var(--_actionsSpacing))', }, })), ); @@ -91,7 +95,10 @@ const TablePaginationSelect = styled(Select, { color: 'inherit', fontSize: 'inherit', flexShrink: 0, - marginRight: 32, + // Base density seam for the select-to-rows gap. Co-located default keeps the + // unprefixed `--_selectSpacing` from inheriting a foreign value. + '--_selectSpacing': '32px', + marginRight: 'var(--TablePagination-selectSpacing, var(--_selectSpacing))', marginLeft: 8, [`& .${tablePaginationClasses.select}`]: { paddingLeft: 8, diff --git a/packages/mui-material/src/Toolbar/Toolbar.js b/packages/mui-material/src/Toolbar/Toolbar.js index dbfde2fb54eb23..46939ac77a5362 100644 --- a/packages/mui-material/src/Toolbar/Toolbar.js +++ b/packages/mui-material/src/Toolbar/Toolbar.js @@ -31,15 +31,21 @@ const ToolbarRoot = styled('div', { position: 'relative', display: 'flex', alignItems: 'center', + // Gutter default (the responsive sm bump is set in the gutters variant). + // Only the `dense` minHeight is tokenized; the `regular` height stays driven + // by the public `theme.mixins.toolbar` so existing customization keeps working. + '--_minHeight': '48px', + '--_padInline': theme.spacing(2), variants: [ { props: ({ ownerState }) => !ownerState.disableGutters, style: { - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), + // Gutters are shared across variants -> base token (no size layer), + // consumed directly with the internal default as fallback. + paddingLeft: 'var(--Toolbar-padInline, var(--_padInline))', + paddingRight: 'var(--Toolbar-padInline, var(--_padInline))', [theme.breakpoints.up('sm')]: { - paddingLeft: theme.spacing(3), - paddingRight: theme.spacing(3), + '--_padInline': theme.spacing(3), }, }, }, @@ -48,7 +54,9 @@ const ToolbarRoot = styled('div', { variant: 'dense', }, style: { - minHeight: 48, + '--_minHeight': '48px', + '--Toolbar-minHeight': 'var(--Toolbar-dense-minHeight, var(--_minHeight))', + minHeight: 'var(--Toolbar-minHeight, var(--_minHeight))', }, }, { diff --git a/packages/mui-material/src/styles/enhanceDensity.ts b/packages/mui-material/src/styles/enhanceDensity.ts index 0a7e0ce1973003..79e48639d5b3c9 100644 --- a/packages/mui-material/src/styles/enhanceDensity.ts +++ b/packages/mui-material/src/styles/enhanceDensity.ts @@ -118,6 +118,260 @@ export default function enhanceDensity< ], }, }, + MuiChip: { + ...c?.MuiChip, + styleOverrides: { + ...c?.MuiChip?.styleOverrides, + root: [ + c?.MuiChip?.styleOverrides?.root, + { + '--Chip-small-height': varRefs.lg, + '--Chip-medium-height': varRefs.xl, + '--Chip-small-padInline': varRefs.sm, + '--Chip-medium-padInline': varRefs.md, + }, + ], + }, + }, + MuiIconButton: { + ...c?.MuiIconButton, + styleOverrides: { + ...c?.MuiIconButton?.styleOverrides, + root: [ + c?.MuiIconButton?.styleOverrides?.root, + { + '--IconButton-small-pad': varRefs.xs, + '--IconButton-medium-pad': varRefs.sm, + '--IconButton-large-pad': varRefs.lg, + }, + ], + }, + }, + MuiMenuItem: { + ...c?.MuiMenuItem, + styleOverrides: { + ...c?.MuiMenuItem?.styleOverrides, + root: [ + c?.MuiMenuItem?.styleOverrides?.root, + { + '--MenuItem-minHeight': varRefs.xl, + '--MenuItem-dense-minHeight': varRefs.lg, + '--MenuItem-padBlock': varRefs.xs, + '--MenuItem-dense-padBlock': varRefs.xxs, + '--MenuItem-padInline': varRefs.lg, + '--MenuItem-dense-padInline': varRefs.md, + }, + ], + }, + }, + MuiListItem: { + ...c?.MuiListItem, + styleOverrides: { + ...c?.MuiListItem?.styleOverrides, + root: [ + c?.MuiListItem?.styleOverrides?.root, + { + '--ListItem-padBlock': varRefs.sm, + '--ListItem-dense-padBlock': varRefs.xxs, + '--ListItem-padInline': varRefs.lg, + '--ListItem-dense-padInline': varRefs.md, + }, + ], + }, + }, + MuiListItemButton: { + ...c?.MuiListItemButton, + styleOverrides: { + ...c?.MuiListItemButton?.styleOverrides, + root: [ + c?.MuiListItemButton?.styleOverrides?.root, + { + '--ListItemButton-padBlock': varRefs.sm, + '--ListItemButton-dense-padBlock': varRefs.xs, + '--ListItemButton-padInline': varRefs.lg, + '--ListItemButton-dense-padInline': varRefs.md, + }, + ], + }, + }, + MuiListItemIcon: { + ...c?.MuiListItemIcon, + styleOverrides: { + ...c?.MuiListItemIcon?.styleOverrides, + root: [ + c?.MuiListItemIcon?.styleOverrides?.root, + { + '--ListItemIcon-minWidth': `calc(36px + ${varRefs.md})`, + }, + ], + }, + }, + MuiListItemText: { + ...c?.MuiListItemText, + styleOverrides: { + ...c?.MuiListItemText?.styleOverrides, + root: [ + c?.MuiListItemText?.styleOverrides?.root, + { + // Sized-only: regular vs dense compactness each maps to its own step. + // marginBlock = vertical row spacing (smaller = denser); insetPad = + // indentation. + '--ListItemText-marginBlock': varRefs.xs, + '--ListItemText-dense-marginBlock': varRefs.xxs, + '--ListItemText-insetPad': `calc(${varRefs.xl} + ${varRefs.lg})`, + '--ListItemText-dense-insetPad': varRefs.xl, + }, + ], + }, + }, + MuiListSubheader: { + ...c?.MuiListSubheader, + styleOverrides: { + ...c?.MuiListSubheader?.styleOverrides, + root: [ + c?.MuiListSubheader?.styleOverrides?.root, + { + // Base tokens (no size layer): map the agnostic seams directly. + '--ListSubheader-height': varRefs.xl, + '--ListSubheader-padInline': varRefs.md, + '--ListSubheader-inset': `calc(${varRefs.xl} + ${varRefs.lg})`, + }, + ], + }, + }, + MuiToolbar: { + ...c?.MuiToolbar, + styleOverrides: { + ...c?.MuiToolbar?.styleOverrides, + root: [ + c?.MuiToolbar?.styleOverrides?.root, + { + // Only `dense` minHeight is tokenized (regular stays mixins.toolbar); + // gutter padInline is a base token. + '--Toolbar-dense-minHeight': varRefs.lg, + '--Toolbar-padInline': varRefs.md, + }, + ], + }, + }, + MuiTab: { + ...c?.MuiTab, + styleOverrides: { + ...c?.MuiTab?.styleOverrides, + root: [ + c?.MuiTab?.styleOverrides?.root, + { + // Base tokens: Tab has no size prop, so map the agnostic seams + // directly to density steps (no per-size tokens to route). + '--Tab-padBlock': varRefs.sm, + '--Tab-padInline': varRefs.lg, + '--Tab-minHeight': `calc(${varRefs.xl} + ${varRefs.lg})`, + '--Tab-iconSpacing': varRefs.xs, + }, + ], + }, + }, + MuiTablePagination: { + ...c?.MuiTablePagination, + styleOverrides: { + ...c?.MuiTablePagination?.styleOverrides, + root: [ + c?.MuiTablePagination?.styleOverrides?.root, + { + '--TablePagination-minHeight': `calc(${varRefs.xl} + ${varRefs.md})`, + '--TablePagination-actionsSpacing': varRefs.lg, + '--TablePagination-selectSpacing': varRefs.xl, + }, + ], + }, + }, + MuiCardContent: { + ...c?.MuiCardContent, + styleOverrides: { + ...c?.MuiCardContent?.styleOverrides, + root: [ + c?.MuiCardContent?.styleOverrides?.root, + { + // CardContent has no size prop -> base tokens (no per-size layer). + '--CardContent-pad': varRefs.lg, + '--CardContent-padBottom': varRefs.xl, + }, + ], + }, + }, + MuiSelect: { + ...c?.MuiSelect, + styleOverrides: { + ...c?.MuiSelect?.styleOverrides, + root: [ + c?.MuiSelect?.styleOverrides?.root, + { + // Base axis (no size layer) — single agnostic seam, mapped to a + // mid-step so density nudges the select content-box floor uniformly. + '--Select-minHeight': varRefs.lg, + }, + ], + }, + }, + MuiBreadcrumbs: { + ...c?.MuiBreadcrumbs, + styleOverrides: { + ...c?.MuiBreadcrumbs?.styleOverrides, + root: [ + c?.MuiBreadcrumbs?.styleOverrides?.root, + { + '--Breadcrumbs-separatorGap': varRefs.sm, + }, + ], + }, + }, + MuiInputAdornment: { + ...c?.MuiInputAdornment, + styleOverrides: { + ...c?.MuiInputAdornment?.styleOverrides, + root: [ + c?.MuiInputAdornment?.styleOverrides?.root, + { + '--InputAdornment-small-gap': varRefs.xxs, + '--InputAdornment-medium-gap': varRefs.sm, + '--InputAdornment-small-marginTop': varRefs.md, + '--InputAdornment-medium-marginTop': varRefs.lg, + }, + ], + }, + }, + MuiBadge: { + ...c?.MuiBadge, + styleOverrides: { + ...c?.MuiBadge?.styleOverrides, + root: [ + c?.MuiBadge?.styleOverrides?.root, + { + '--Badge-standard-pad': `0 ${varRefs.sm}`, + '--Badge-standard-size': varRefs.lg, + '--Badge-dot-pad': '0px', + '--Badge-dot-size': varRefs.xs, + }, + ], + }, + }, + MuiOutlinedInput: { + ...c?.MuiOutlinedInput, + styleOverrides: { + ...c?.MuiOutlinedInput?.styleOverrides, + root: [ + c?.MuiOutlinedInput?.styleOverrides?.root, + { + // Sized block/inline padding per size; block < inline to keep the + // input's 16.5/14 feel. + '--OutlinedInput-medium-padBlock': varRefs.md, + '--OutlinedInput-small-padBlock': varRefs.sm, + '--OutlinedInput-medium-padInline': varRefs.lg, + '--OutlinedInput-small-padInline': varRefs.md, + }, + ], + }, + }, }; return theme; From 40a893df4a333d51a71ed99083ce62b557ecde7a Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 8 Jun 2026 08:01:48 +0700 Subject: [PATCH 13/28] [docs] Document the dense state-token pattern Boolean compactness toggles (dense) use a state token: default state is the plain seam --Component- (base-token-shaped, no base routing), only the on state is qualified --Component-dense-. No --Component-normal/regular/default- qualifier - a boolean has no name for off. Added to CONTEXT language, ADR 0001 resolution, and the rollout recipe + naming. --- CONTEXT.md | 24 +++++++++--- docs/adr/0001-css-var-density-adapter.md | 42 +++++++++++++-------- docs/adr/density-adapter-rollout.md | 47 +++++++++++++++++------- 3 files changed, 80 insertions(+), 33 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index e485999c968e3a..f1312c44701628 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -48,13 +48,27 @@ An axis can skip the **size layer** — internal default `--_` + seam, consumed `var(--Component-, var(--_))`, nothing routes it, so a designer sets the seam directly. Use this **only when tuning the axis per size makes no sense**, because a base token can't be size-scoped from the theme. A -size-invariant *default* is **not** enough: OutlinedInput's inline gutter is +size-invariant _default_ is **not** enough: OutlinedInput's inline gutter is `14px` for both sizes, yet it's a **sized token** (`--OutlinedInput--padInline`, default `14px` each) so a design system can make small inputs denser inline. Reach for a base token rarely; default to a **sized token**. _Avoid_: a base token just because the default is size-invariant; a bare literal default (use `--_`). +**State token** (public, for a boolean compactness toggle like `dense`): +When the compactness axis is a **boolean** prop (`dense`) rather than a `size` +enum, the **default (off) state** is exposed through the plain seam +`--Component-` — consumed `var(--Component-, var(--_))`, **nothing +routes it in the base** (the seam _is_ the default knob, base-token-shaped) — and +**only the on state** gets a qualified token `--Component-dense-`, routed in +the `dense` variant over its own `--_` literal. A boolean has no name for +"off", so there is **no** `--Component-normal/regular/default-`: the absence +of the toggle is the plain seam, not an arbitrarily-named size. Contrast a +**sized token**, where every value (including `medium`) is qualified because each +is a real named size. Used by MenuItem, ListItem, ListItemButton, ListItemText. +_Avoid_: naming the off state (`-normal-`, `-regular-`, `-default-`); routing the +seam in the base; treating `dense` as a 2-value size enum. + **Internal default**: A private variable, shape `--_` (leading underscore, **no component prefix**), **set in `variants`** per `(variant, size)` cell (medium defaults @@ -97,11 +111,11 @@ _Avoid_: "density preset" (that is the resulting effect, not the function). matrix and the built-in-size routing are both **`variants` cells**, not a body lookup table; only custom-size routing is inline. - **Two vars, not one** (`--Button-pad` over `--_pad`): the cells write the - *value* (`--_pad`), the routing writes a *reference* (`--Button-pad`). One var + _value_ (`--_pad`), the routing writes a _reference_ (`--Button-pad`). One var fails three ways — a self-referencing fallback in the inline bridge (invalid CSS, forcing the literal back into runtime style), the `(variant×size)` and size-only write-axes clobbering on one element, and losing the agnostic seam. - Full reasoning in `docs/adr/0001` → *Why two vars*. + Full reasoning in `docs/adr/0001` → _Why two vars_. - Override priority (high → low): plain `styleOverrides` property → **sized token** → internal default (literal fallback). - Custom (user-defined) sizes work for free: when the size isn't built-in, the @@ -110,7 +124,7 @@ _Avoid_: "density preset" (that is the resulting effect, not the function). - **Token granularity follows the component's spacing structure; split only when the impl forces it.** Button sets all sides together via one shorthand on one element → one `pad` var (even though block 6 ≠ inline 8 — differing values alone - don't force a split). OutlinedInput *is* forced: block vs inline land on + don't force a split). OutlinedInput _is_ forced: block vs inline land on different elements/states and zero per adornment. **Both axes are sized** (`--OutlinedInput--padBlock`/`-padInline`) — block defaults vary by size (16.5/8.5), inline defaults don't (14 both) but it's sized anyway so density can @@ -151,7 +165,7 @@ _Avoid_: "density preset" (that is the resulting effect, not the function). to `theme.density`, to disambiguate from `theme.spacing`. - Base (all-sizes-over-sized) token — dropped for **size-varying** axes; resolution is sized-only, tune per size. A base token applies only when per-size - override is meaningless — *not* merely when the default is size-invariant + override is meaningless — _not_ merely when the default is size-invariant (OutlinedInput's `14px` inline gutter is still a **sized** token so density can tune it per size). - Var key — single `pad` shorthand only when the impl sets all sides together on diff --git a/docs/adr/0001-css-var-density-adapter.md b/docs/adr/0001-css-var-density-adapter.md index 0483255e4a5d23..094d8f51bd3a58 100644 --- a/docs/adr/0001-css-var-density-adapter.md +++ b/docs/adr/0001-css-var-density-adapter.md @@ -62,6 +62,15 @@ over an internal default `--_` (consumed `var(--Component-, var(--_` (base-token-shaped — nothing routes it in the base), and only +the on state is qualified `--Component-dense-`, routed in the `dense` +variant. A boolean has no name for "off", so there is no +`--Component-normal/regular/default-` — unlike a size enum, where every value +(including `medium`) is qualified because each is a real named size. Used by +MenuItem, ListItem, ListItemButton, and ListItemText. + The styled root has **one** consumption point per property and **no conditional**; the defaults and built-in-size routing are plain `variants` entries: @@ -71,7 +80,10 @@ const ButtonRoot = styled(ButtonBase)({ padding: 'var(--Button-pad, var(--_pad))', // agnostic layer variants: [ // routing for built-in sizes (deduped CSS) - { props: { size: 'small' }, style: { '--Button-pad': 'var(--Button-small-pad, var(--_pad))' } }, + { + props: { size: 'small' }, + style: { '--Button-pad': 'var(--Button-small-pad, var(--_pad))' }, + }, // literal default per (variant, size); medium lives in the { variant } blocks (DRY) { props: { variant: 'text', size: 'small' }, style: { '--_pad': '4px 5px' } }, ], @@ -98,7 +110,7 @@ Three reasons, all pointing the same way: the rest of the variant's styling, statically deduped, smallest diff from today. Inline style is only a **bridge** for the one dynamic case (a custom size's token name) and must carry **no values**, only routing. Two vars allow - that: cells write the value (`--_pad`), the bridge writes a *reference* + that: cells write the value (`--_pad`), the bridge writes a _reference_ (`--Button-pad: var(--Button--pad, var(--_pad))`). With a single `--_pad`, the custom-size bridge would have to write `--_pad: var(--Button--pad, var(--_pad))` — a property referencing @@ -140,7 +152,7 @@ InputBase/TextField to follow) for this experiment. Same three-tier model, with two component-driven differences: - **Both axes are sized.** Block (`16.5px`→`8.5px`) varies by size → sized token - `--OutlinedInput--padBlock`. The `14px` inline gutter is *constant* across + `--OutlinedInput--padBlock`. The `14px` inline gutter is _constant_ across sizes, but it's **sized too** → `--OutlinedInput--padInline` (default `14px` each size) so a design system can make small inputs denser inline. (We first modeled inline as a single size-invariant **base token**, but that can't @@ -154,7 +166,7 @@ Same three-tier model, with two component-driven differences: block padding — `4/5`, `25/8` — so a shared InputBase block seam would need a richer, per-side shape; deferred.) - **Two consuming elements, tokenized in place.** Padding lives on the input - (non-multiline) *and* the root (multiline) — and the two never both apply block + (non-multiline) _and_ the root (multiline) — and the two never both apply block padding at once (multiline zeroes the input's). Rather than lift size resolution to a single owner, **each site keeps master's literal-bearing cell and tokenizes in place**: input base + input `{ size: small }`, root `multiline` + @@ -166,7 +178,7 @@ Same three-tier model, with two component-driven differences: reads it also sets it on the same element. **Closing the loop — the floating label.** In a `TextField`, `InputLabel` is a -*preceding sibling* of the input. The resting label must track the block padding +_preceding sibling_ of the input. The resting label must track the block padding or it decenters when density is tuned. True centering is `labelY = padBlock` exactly (`(lineHeight + 2·padBlock)/2 − lineHeight/2`); today's `16px`/`9px` are that with ±0.5px historical rounding. @@ -176,7 +188,7 @@ The bridge must respect the **dependency direction**: `InputLabel` is generic So `InputLabel` only exposes a seam — its outlined resting transform reads `var(--InputLabel-y, )` — and **OutlinedInput owns the bridge**. Because the label precedes the input, OutlinedInput reaches it with `:has` (sibling -combinators only match *following* siblings) and, per size, derives the label +combinators only match _following_ siblings) and, per size, derives the label seam straight from its public sized token (a cross-element rule must reference the public token, not the input's internal `--_padBlock`): @@ -221,12 +233,12 @@ keeps the coupling in the one component that legitimately owns it. Cost: needs ### Accepted trade-offs -| Trade-off | Why we can live with it | -| :-- | :-- | -| `--Button-pad` is public-shaped but not a designer knob in the assembled Button (plumbing) | It's the agnostic seam; the real knob is `--Button--pad`, documented. The name marks the layer boundary. | -| Two vars per property instead of one | Mandatory (see *Why two vars*); the indirection is mechanical and documented. | -| Unprefixed `--_pad` could inherit a foreign value | Every built-in cell plus the root universal default set it on the element; revisit a prefix only if cross-component collisions surface as the pattern spreads. | -| `pad` shorthand is coarse (an override sets all sides) | Button padding is symmetric; tiny token surface; granular logical props can come later. | -| `var()` unresolved in jsdom (no computed-px assertions) | Argos covers default visuals; the chain is declarative and inspectable. | -| Inline still present for custom sizes | Rare; the built-in common case carries zero inline. | -| Per-property boilerplate grows with rollout | Acceptable for the payoff (runtime scoped theming); extract a helper before component #3. | +| Trade-off | Why we can live with it | +| :----------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--Button-pad` is public-shaped but not a designer knob in the assembled Button (plumbing) | It's the agnostic seam; the real knob is `--Button--pad`, documented. The name marks the layer boundary. | +| Two vars per property instead of one | Mandatory (see _Why two vars_); the indirection is mechanical and documented. | +| Unprefixed `--_pad` could inherit a foreign value | Every built-in cell plus the root universal default set it on the element; revisit a prefix only if cross-component collisions surface as the pattern spreads. | +| `pad` shorthand is coarse (an override sets all sides) | Button padding is symmetric; tiny token surface; granular logical props can come later. | +| `var()` unresolved in jsdom (no computed-px assertions) | Argos covers default visuals; the chain is declarative and inspectable. | +| Inline still present for custom sizes | Rare; the built-in common case carries zero inline. | +| Per-property boilerplate grows with rollout | Acceptable for the payoff (runtime scoped theming); extract a helper before component #3. | diff --git a/docs/adr/density-adapter-rollout.md b/docs/adr/density-adapter-rollout.md index 89293c169bf5e0..e6f3d7d1cd34aa 100644 --- a/docs/adr/density-adapter-rollout.md +++ b/docs/adr/density-adapter-rollout.md @@ -16,20 +16,38 @@ pixel-identical to today. No `calc` math for users, no `--mui-spacing` dial. Root consumes seam, seam falls to internal default: ```js -padding: 'var(--Button-pad, var(--_pad))' +padding: 'var(--Button-pad, var(--_pad))'; ``` Resolution = **sized-only** for a size-varying axis. Sized token wins -> else internal default. No all-sizes-over-sized base token. **Size-invariant default ≠ base token.** If an axis's default is the same every -size (e.g. OutlinedInput inline `14px`) you *can* skip the size layer — base +size (e.g. OutlinedInput inline `14px`) you _can_ skip the size layer — base token `--Component-`, consumed `var(--Component-, var(--_))`, nothing routes it. But only when per-size override is genuinely meaningless, because a **base token can't be tuned per size from the theme**. If a design system might want that axis denser on small (density!), **size it anyway**: same default both sizes, but expose `--Component--`. OutlinedInput sizes -*both* padBlock and padInline for this reason (inline default `14px` each size). +_both_ padBlock and padInline for this reason (inline default `14px` each size). + +**Boolean `dense` axis (state token).** When compactness is a **boolean** prop +(`dense`) not a `size` enum, don't name the off-state. The **default state is the +plain seam** `--Component-` (base-token-shaped: nothing routes it in the +base; designer sets it directly); **only `dense` is qualified** +`--Component-dense-`, routed in the `dense` variant: + +```js +// base: default state = plain seam, falls to the internal default literal +'--_padBlock': '8px', +paddingTop: 'var(--ListItem-padBlock, var(--_padBlock))', +// { dense } variant: own literal + route the dense token +'--_padBlock': '4px', +'--ListItem-padBlock': 'var(--ListItem-dense-padBlock, var(--_padBlock))', +``` + +No `--Component-normal/regular/default-` — a boolean has no name for "off". +(MenuItem, ListItem, ListItemButton, ListItemText.) ## Recipe A — small component (Button) @@ -51,7 +69,7 @@ One element. `pad` shorthand (all sides move together). ``` 4. Custom size -> route inline (only non-built-in size emits a `style` attr). ```js - const densityVars = ['small','medium','large'].includes(size) + const densityVars = ['small', 'medium', 'large'].includes(size) ? undefined : { '--Button-pad': `var(--Button-${size}-pad, var(--_pad))` }; ``` @@ -60,7 +78,7 @@ One element. `pad` shorthand (all sides move together). ## Recipe B — big component (OutlinedInput) Padding spans 2 elements (root when multiline, input otherwise) + paired sibling -(InputLabel). More dimensions but token model is *simpler*. +(InputLabel). More dimensions but token model is _simpler_. **Pick real axis + shape.** Block (`16.5 -> 8.5`) varies by size -> **sized** `padBlock`. Inline default is `14px` both sizes, but a design system may want @@ -69,7 +87,7 @@ size). Both axes sized, routed per size. Split block/inline forced: they land on different elements/states + zero per adornment. **Two elements, tokenize in place.** Padding lives on the input (non-multiline, -inline gutters) *and* the root (multiline, adornment gutters) — never both on the +inline gutters) _and_ the root (multiline, adornment gutters) — never both on the same side at once (multiline zeroes input padding; an adorned side zeroes the input and gutters from the root). Keep master's split: each site declares its own `--_` + routes the size token, right where the literal was. No lift to a @@ -97,10 +115,10 @@ specific component token. Label exposes own seam: ```js // InputLabel — generic, literal default -transform: 'translate(14px, var(--InputLabel-y, 16px)) scale(1)' // small: 9px +transform: 'translate(14px, var(--InputLabel-y, 16px)) scale(1)'; // small: 9px ``` -Specific component owns bridge. Label = *preceding* sibling -> reach via `:has`. +Specific component owns bridge. Label = _preceding_ sibling -> reach via `:has`. Cross-element rule -> derive the label seam straight from the **public sized token** + literal fallback (can't read the input's internal `--_padBlock`): @@ -127,20 +145,20 @@ One knob (`--OutlinedInput--padBlock`) -> input box + label move together. CSS) -> literal leaks to runtime; `(variant×size)` vs size-only writes clobber on one element; lose the seam. - **Uniform consume shape — every axis.** Always `var(--Component-, - var(--_))`, including a size-invariant **base** axis. Two real mistakes to +var(--_))`, including a size-invariant **base** axis. Two real mistakes to avoid: (a) **bare literal default** for a base axis (`var(--seam, 14px)`) — instead define `--_` (e.g. `--_padInline: 14px`) so the default lives in one place and the shape matches sized axes; (b) **dropping a fallback because the seam "is always set"** — keep it; the uniform shape is the contract (Button - `var(--Button-pad, var(--_pad))`; a sized axis carries `--_` in *both* the + `var(--Button-pad, var(--_pad))`; a sized axis carries `--_` in _both_ the routing and the consume — that double-reference is intentional). Consistency over minimalism. - **Unprefixed `--_` safe only if every instance sets own.** Custom prop inherits. Co-located setter (Button) or every root re-sets (OutlinedInput) -> ancestor value never wins. Else prefix it. - **Sibling can't inherit.** Sibling vars need common ancestor. Specific - component reaches sibling via `:has(~ &)`. Note: `+`/`~` match *following* - siblings only -> `:has` makes the *earlier* element the subject. + component reaches sibling via `:has(~ &)`. Note: `+`/`~` match _following_ + siblings only -> `:has` makes the _earlier_ element the subject. - **One element can't see another's internal var.** Label can't read input-root `--_padBlock`. Reference **public** token (visible at `:root`) + literal fallback. Never the internal var across elements. @@ -183,8 +201,11 @@ Screenshot harness `scripts/density-screenshots/` (`maxDiffPixels: 0`): - Key granularity = component's real spacing structure. One shorthand key (`pad`) when sides set together on one element; split per axis only when forced (see gotcha). Per axis: **sized by default** (per-size tunable); base token only - if per-size override is genuinely meaningless — a size-invariant *default* alone + if per-size override is genuinely meaningless — a size-invariant _default_ alone doesn't justify base (size it so density can tune it per size). +- **Boolean toggle (`dense`)** = **state token**: off-state is the plain seam + `--Component-` (don't qualify it); only the on-state is qualified + `--Component-dense-`. Never `--Component-normal/regular/default-`. ## Order to roll out From e7342e4802938706eab26bed90bed5730bdb4536 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 8 Jun 2026 08:10:59 +0700 Subject: [PATCH 14/28] [material-ui] Density adapter: Checkbox, Radio, Switch via SwitchBase SwitchBase (shared agnostic base) consumes one seam: padding var(--SwitchBase-pad, var(--_pad)), --_pad 9px. Checkbox/Radio (styled(SwitchBase)) route per-size public tokens --Checkbox/Radio--pad into the seam; default 9px both sizes (pixel-identical). Switch routes its thumb (SwitchBase) padding via --Switch--pad (9/4); box geometry stays literal (size-coupled). enhanceDensity + fixture wired. --- docs/pages/experiments/density-fixture.tsx | 73 +++++++++++++++++++ .../mui-material/src/Checkbox/Checkbox.js | 10 +++ packages/mui-material/src/Radio/Radio.js | 10 +++ packages/mui-material/src/Switch/Switch.js | 11 ++- .../mui-material/src/internal/SwitchBase.js | 6 +- .../mui-material/src/styles/enhanceDensity.ts | 41 +++++++++++ 6 files changed, 149 insertions(+), 2 deletions(-) diff --git a/docs/pages/experiments/density-fixture.tsx b/docs/pages/experiments/density-fixture.tsx index 9247ff193e3c28..d83c64591ad47c 100644 --- a/docs/pages/experiments/density-fixture.tsx +++ b/docs/pages/experiments/density-fixture.tsx @@ -34,6 +34,9 @@ import Select from '@mui/material/Select'; import Breadcrumbs from '@mui/material/Breadcrumbs'; import Link from '@mui/material/Link'; import Badge from '@mui/material/Badge'; +import Checkbox from '@mui/material/Checkbox'; +import Radio from '@mui/material/Radio'; +import Switch from '@mui/material/Switch'; import Typography from '@mui/material/Typography'; import FaceIcon from '@mui/icons-material/Face'; import DeleteIcon from '@mui/icons-material/Delete'; @@ -589,6 +592,46 @@ const demos: Record = {
), + Checkbox: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + + + + + + ))} + + ), + Radio: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + + + + + ))} + + ), + Switch: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + + + + + ))} + + ), }; // Per-component density-token overrides for the review levels. `default` is @@ -802,6 +845,36 @@ const scopes: Record> = { ['--Badge-dot-size' as any]: '12px', }, }, + Checkbox: { + dense: { + ['--Checkbox-small-pad' as any]: '3px', + ['--Checkbox-medium-pad' as any]: '5px', + }, + loose: { + ['--Checkbox-small-pad' as any]: '7px', + ['--Checkbox-medium-pad' as any]: '13px', + }, + }, + Radio: { + dense: { + ['--Radio-small-pad' as any]: '3px', + ['--Radio-medium-pad' as any]: '5px', + }, + loose: { + ['--Radio-small-pad' as any]: '7px', + ['--Radio-medium-pad' as any]: '13px', + }, + }, + Switch: { + dense: { + ['--Switch-small-pad' as any]: '2px', + ['--Switch-medium-pad' as any]: '6px', + }, + loose: { + ['--Switch-small-pad' as any]: '7px', + ['--Switch-medium-pad' as any]: '12px', + }, + }, }; // TextField rides the same OutlinedInput tokens; OutlinedInput's `:has` rule // drives the label's --InputLabel-y, so input box + label move together. diff --git a/packages/mui-material/src/Checkbox/Checkbox.js b/packages/mui-material/src/Checkbox/Checkbox.js index ef4cbfc2c328f7..b13141cbe2527e 100644 --- a/packages/mui-material/src/Checkbox/Checkbox.js +++ b/packages/mui-material/src/Checkbox/Checkbox.js @@ -55,6 +55,16 @@ const CheckboxRoot = styled(SwitchBase, { memoTheme(({ theme }) => ({ color: (theme.vars || theme).palette.text.secondary, variants: [ + // Density: route the per-size public token into SwitchBase's seam. Default + // 9px both sizes (pixel-identical); size enables per-size density tuning. + { + props: { size: 'small' }, + style: { '--SwitchBase-pad': 'var(--Checkbox-small-pad, var(--_pad))' }, + }, + { + props: { size: 'medium' }, + style: { '--SwitchBase-pad': 'var(--Checkbox-medium-pad, var(--_pad))' }, + }, { props: { color: 'default', disableRipple: false }, style: { diff --git a/packages/mui-material/src/Radio/Radio.js b/packages/mui-material/src/Radio/Radio.js index 48e1d7fd57df59..217d972d1449a6 100644 --- a/packages/mui-material/src/Radio/Radio.js +++ b/packages/mui-material/src/Radio/Radio.js @@ -50,6 +50,16 @@ const RadioRoot = styled(SwitchBase, { color: (theme.vars || theme).palette.action.disabled, }, variants: [ + // Density: route the per-size public token into SwitchBase's seam. Default + // 9px both sizes (pixel-identical); size enables per-size density tuning. + { + props: { size: 'small' }, + style: { '--SwitchBase-pad': 'var(--Radio-small-pad, var(--_pad))' }, + }, + { + props: { size: 'medium' }, + style: { '--SwitchBase-pad': 'var(--Radio-medium-pad, var(--_pad))' }, + }, { props: { color: 'default', disabled: false, disableRipple: false }, style: { diff --git a/packages/mui-material/src/Switch/Switch.js b/packages/mui-material/src/Switch/Switch.js index 6c8dd8b059738f..7643610b407a2c 100644 --- a/packages/mui-material/src/Switch/Switch.js +++ b/packages/mui-material/src/Switch/Switch.js @@ -64,6 +64,12 @@ const SwitchRoot = styled('span', { '@media print': { colorAdjust: 'exact', }, + // Density: the thumb (SwitchBase) inherits the seam from here — no descendant + // selector needed (custom props inherit; the thumb doesn't redeclare the seam). + // `--_pad` is the thumb's default fallback (the root's own padding stays + // literal — box geometry is size-coupled). + '--_pad': '9px', + '--SwitchBase-pad': 'var(--Switch-medium-pad, var(--_pad))', variants: [ { props: { edge: 'start' }, @@ -79,12 +85,15 @@ const SwitchRoot = styled('span', { width: 40, height: 24, padding: 7, + // Small thumb default is 4 (≠ the thumb's own --_pad 9), so feed it via + // the seam: set --_pad here (inherited as the seam's fallback). + '--_pad': '4px', + '--SwitchBase-pad': 'var(--Switch-small-pad, var(--_pad))', [`& .${switchClasses.thumb}`]: { width: 16, height: 16, }, [`& .${switchClasses.switchBase}`]: { - padding: 4, [`&.${switchClasses.checked}`]: { transform: 'translateX(16px)', }, diff --git a/packages/mui-material/src/internal/SwitchBase.js b/packages/mui-material/src/internal/SwitchBase.js index 5257bfe688e49e..e837e251205cc8 100644 --- a/packages/mui-material/src/internal/SwitchBase.js +++ b/packages/mui-material/src/internal/SwitchBase.js @@ -25,7 +25,11 @@ const useUtilityClasses = (ownerState) => { const SwitchBaseRoot = styled(ButtonBase, { name: 'MuiSwitchBase', })({ - padding: 9, + // Density adapter (docs/adr/0001): SwitchBase is the agnostic layer shared by + // Checkbox/Radio (and the Switch thumb). It consumes one seam; the Material + // layer (Checkbox/Radio) routes its per-size public token into --SwitchBase-pad. + '--_pad': '9px', + padding: 'var(--SwitchBase-pad, var(--_pad))', borderRadius: '50%', variants: [ { diff --git a/packages/mui-material/src/styles/enhanceDensity.ts b/packages/mui-material/src/styles/enhanceDensity.ts index 79e48639d5b3c9..b67ce779994368 100644 --- a/packages/mui-material/src/styles/enhanceDensity.ts +++ b/packages/mui-material/src/styles/enhanceDensity.ts @@ -372,6 +372,47 @@ export default function enhanceDensity< ], }, }, + MuiCheckbox: { + ...c?.MuiCheckbox, + styleOverrides: { + ...c?.MuiCheckbox?.styleOverrides, + root: [ + c?.MuiCheckbox?.styleOverrides?.root, + { + // Touch-target padding (9px default both sizes), via SwitchBase. + '--Checkbox-medium-pad': varRefs.sm, + '--Checkbox-small-pad': varRefs.xs, + }, + ], + }, + }, + MuiRadio: { + ...c?.MuiRadio, + styleOverrides: { + ...c?.MuiRadio?.styleOverrides, + root: [ + c?.MuiRadio?.styleOverrides?.root, + { + '--Radio-medium-pad': varRefs.sm, + '--Radio-small-pad': varRefs.xs, + }, + ], + }, + }, + MuiSwitch: { + ...c?.MuiSwitch, + styleOverrides: { + ...c?.MuiSwitch?.styleOverrides, + root: [ + c?.MuiSwitch?.styleOverrides?.root, + { + // Thumb (SwitchBase) padding; box geometry stays literal. + '--Switch-medium-pad': varRefs.sm, + '--Switch-small-pad': varRefs.xxs, + }, + ], + }, + }, }; return theme; From 28d6b6b716010e4af8fd25598ab5fc07166c89e8 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 8 Jun 2026 08:25:19 +0700 Subject: [PATCH 15/28] [docs] Document the shared internal base pattern (Recipe C) SwitchBase owns the agnostic seam consumed once; Checkbox/Radio/Switch route per-component sized tokens into it. Covers the two reader topologies (consumer is the base vs wraps it as a descendant), delivery via custom-property inheritance (no descendant selector), and the --_-shadowing caveat. Added to CONTEXT relationships, ADR 0001 specifics, rollout Recipe C + Done list. --- CONTEXT.md | 13 +++++++ docs/adr/0001-css-var-density-adapter.md | 30 +++++++++++++++ docs/adr/density-adapter-rollout.md | 49 ++++++++++++++++++++++-- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index f1312c44701628..bc39ab667dcc32 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -138,6 +138,19 @@ _Avoid_: "density preset" (that is the resulting effect, not the function). literal default). The **specific** component, `OutlinedInput`, owns the bridge: it reaches its preceding-sibling label via `:has(~ &)` and sets `--InputLabel-y` from its public token. Generic never names specific; one knob still drives both. +- **A shared internal base owns the agnostic layer; consumers route their own + tokens into it.** `SwitchBase` consumes the seam once + (`var(--SwitchBase-pad, var(--_pad))`); `Checkbox`/`Radio`/`Switch` (the Material + layer) each route a per-component sized token (`--Checkbox--pad`, …) into + that shared seam, staying independently tunable. The seam keeps the base's name + (plumbing); the knob is the per-component token. Delivery rides **custom-property + inheritance**, no descendant selector: where the consumer _is_ the base + (`styled(SwitchBase)`) it sets the seam on its own root; where it _wraps_ the + base (the Switch thumb), the wrapper root sets the seam and the base inherits it + (the base doesn't redeclare the seam). Caveat: the base _does_ redeclare + `--_` (what makes it unprefixed-safe), so an inherited `--_` is + shadowed — a wrapper needing a different per-state default feeds it through the + seam (set `--_` on the wrapper), not by inheriting `--_`. - **enhanceDensity** (opt-in) connects tier-2 component tokens to the tier-1 **density scale**; un-enhanced, the literal fallbacks reproduce today's pixels. - This experiment does **not** ride `--mui-spacing`; holistic density comes from diff --git a/docs/adr/0001-css-var-density-adapter.md b/docs/adr/0001-css-var-density-adapter.md index 094d8f51bd3a58..619df2a5bb88cc 100644 --- a/docs/adr/0001-css-var-density-adapter.md +++ b/docs/adr/0001-css-var-density-adapter.md @@ -215,6 +215,36 @@ a generic component name a sibling's token (wrong direction); a flat-scope keeps the coupling in the one component that legitimately owns it. Cost: needs `:has()` (Chrome 105 / Safari 15.4 / Firefox 121). +### Shared internal base (SwitchBase → Checkbox, Radio, Switch) + +When several components share one styled base, the **agnostic layer lives on the +base**: `SwitchBase` consumes the seam once (`padding: var(--SwitchBase-pad, +var(--_pad))`, `--_pad: 9px`), and each consumer — the **Material layer** — routes +its own per-component public token into the shared seam. The seam keeps the +_base's_ name (`--SwitchBase-pad`, plumbing); the designer-facing knob is the +per-component sized token (`--Checkbox--pad`, `--Radio--pad`, +`--Switch--pad`). Each consumer stays independently tunable while the base +holds the one consumption point. + +Two reader topologies, both relying on **custom-property inheritance** (no +descendant selector, no added specificity): + +- **Consumer is the base.** `Checkbox`/`Radio` are `styled(SwitchBase)`, so their + size variants set `--SwitchBase-pad` on the very element that consumes it. +- **Consumer wraps the base.** The `Switch` thumb is a `SwitchBase` _inside_ + `SwitchRoot`; the root sets `--SwitchBase-pad` and the thumb **inherits** it. + This works precisely because `SwitchBase` does not redeclare the seam. + +The inheritance caveat is the mirror of why `--_` is safe unprefixed: the +base **redeclares `--_`** on itself, so an inherited `--_` is shadowed. +The seam inherits (not redeclared); the internal default does not. So when a +wrapper needs a per-state default that differs from the base's (the small Switch +thumb is `4px`, not the base's `9px`), it can't inherit `--_` — it feeds the +value **through the seam** by setting `--_` on the wrapper root, where the +seam's `var(..., var(--_))` fallback resolves. Box geometry that is coupled +to the value (Switch's `width = 34 + 12·2`) is left literal — only the +inheritance-safe padding axis is tokenized. + ## Consequences - **Pixel-identical default & non-breaking.** Literals come from the `variants` diff --git a/docs/adr/density-adapter-rollout.md b/docs/adr/density-adapter-rollout.md index e6f3d7d1cd34aa..163e3e9db60449 100644 --- a/docs/adr/density-adapter-rollout.md +++ b/docs/adr/density-adapter-rollout.md @@ -131,6 +131,45 @@ token** + literal fallback (can't read the input's internal `--_padBlock`): One knob (`--OutlinedInput--padBlock`) -> input box + label move together. +## Recipe C — shared internal base (SwitchBase -> Checkbox / Radio / Switch) + +Several components share one styled base. Put the **agnostic layer on the base**: +it consumes the seam once, with the internal default. Each consumer is the +**Material layer** -> routes its _own_ per-component public token into the shared +seam. + +```js +// SwitchBase (shared, agnostic): consume once +'--_pad': '9px', +padding: 'var(--SwitchBase-pad, var(--_pad))', +``` + +The seam keeps the **base's** name (`--SwitchBase-pad`) — it's plumbing; the +public knob is the per-component sized token (`--Checkbox--pad`). + +Two reader topologies: + +- **Consumer _is_ the base** (Checkbox/Radio = `styled(SwitchBase)`): route on + the consumer's own root, same element, no selector. + ```js + { props: { size: 'small' }, style: { '--SwitchBase-pad': 'var(--Checkbox-small-pad, var(--_pad))' } } + ``` +- **Consumer _wraps_ the base** as a descendant (the Switch thumb is a + `SwitchBase` inside `SwitchRoot`): set the seam on the wrapper root — the base + **inherits** it. No descendant selector (custom props inherit; the base doesn't + redeclare the seam). + ```js + // SwitchRoot: thumb inherits this + '--SwitchBase-pad': 'var(--Switch-medium-pad, var(--_pad))', + ``` + +**Inheritance caveat.** The _seam_ inherits because the base doesn't redeclare it. +But the base **does** redeclare `--_` (that's what keeps it unprefixed-safe), +so an inherited `--_` is _shadowed_ on the base. If a wrapper needs a +per-state default different from the base's (Switch small thumb `4` ≠ base `9`), +feed it **through the seam** — set `--_` on the wrapper so the seam's fallback +resolves there; don't expect the base to inherit your `--_`. + ## Gotchas - **Split axes only when the impl forces it.** Differing values per side is NOT @@ -210,6 +249,10 @@ Screenshot harness `scripts/density-screenshots/` (`maxDiffPixels: 0`): ## Order to roll out Small single-element first (prove pattern) -> bigger multi-element -> paired -sibling family. Done: Button, OutlinedInput (+ InputLabel, TextField outlined). -Next candidates: FilledInput, Input (standard) — note asymmetric block padding -(`4/5`, `25/8`) -> need per-side seam, not single `padBlock`. +sibling family. Done: Button, OutlinedInput (+ InputLabel, TextField outlined), +the dashboard set (Chip, IconButton, MenuItem, ListItem(+Button/Icon/Text), +ListSubheader, Toolbar, Tab/Tabs, TablePagination, CardContent, Select, +Breadcrumbs, InputAdornment, Badge), and the SwitchBase family (Checkbox, Radio, +Switch — Recipe C). Next candidates: FilledInput, Input (standard) — note +asymmetric block padding (`4/5`, `25/8`) -> need per-side seam, not single +`padBlock`. From ddfcd906a5e755e9a15cfcb3611a9aab3b473437 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 8 Jun 2026 09:10:34 +0700 Subject: [PATCH 16/28] [material-ui] Switch density: derive pad/travel from interlocked dims Tokenize Switch's four real dims per size (--Switch--width/height/thumbSize/ touchSize). Derive SwitchBase pad = (touchSize-thumbSize)/2, button top = (height-touchSize)/2, checked travel = width-touchSize, thumb size = thumbSize, so the thumb stays centered on the track (absolute + transform). Replaces the pad-only token that drifted the thumb. Switch dropped from enhanceDensity (geometry isn't spacing-scale-derived). Default pixel-identical. --- docs/pages/experiments/density-fixture.tsx | 22 +++++-- packages/mui-material/src/Switch/Switch.js | 63 +++++++++++-------- .../mui-material/src/styles/enhanceDensity.ts | 17 +---- 3 files changed, 57 insertions(+), 45 deletions(-) diff --git a/docs/pages/experiments/density-fixture.tsx b/docs/pages/experiments/density-fixture.tsx index d83c64591ad47c..4f00f846b4cf7f 100644 --- a/docs/pages/experiments/density-fixture.tsx +++ b/docs/pages/experiments/density-fixture.tsx @@ -866,13 +866,27 @@ const scopes: Record> = { }, }, Switch: { + // Tune the four interlocked dims; pad/top/travel re-derive (touchSize == height + // keeps the thumb centered). thumbSize < height; width > touchSize. dense: { - ['--Switch-small-pad' as any]: '2px', - ['--Switch-medium-pad' as any]: '6px', + ['--Switch-small-width' as any]: '32px', + ['--Switch-small-height' as any]: '18px', + ['--Switch-small-thumbSize' as any]: '12px', + ['--Switch-small-touchSize' as any]: '18px', + ['--Switch-medium-width' as any]: '44px', + ['--Switch-medium-height' as any]: '24px', + ['--Switch-medium-thumbSize' as any]: '16px', + ['--Switch-medium-touchSize' as any]: '24px', }, loose: { - ['--Switch-small-pad' as any]: '7px', - ['--Switch-medium-pad' as any]: '12px', + ['--Switch-small-width' as any]: '52px', + ['--Switch-small-height' as any]: '32px', + ['--Switch-small-thumbSize' as any]: '24px', + ['--Switch-small-touchSize' as any]: '32px', + ['--Switch-medium-width' as any]: '76px', + ['--Switch-medium-height' as any]: '48px', + ['--Switch-medium-thumbSize' as any]: '34px', + ['--Switch-medium-touchSize' as any]: '48px', }, }, }; diff --git a/packages/mui-material/src/Switch/Switch.js b/packages/mui-material/src/Switch/Switch.js index 7643610b407a2c..2fc56b9dd5fce7 100644 --- a/packages/mui-material/src/Switch/Switch.js +++ b/packages/mui-material/src/Switch/Switch.js @@ -51,9 +51,27 @@ const SwitchRoot = styled('span', { ]; }, })({ + // Density (docs/adr/0001): Switch geometry is interlocked, so the meaningful + // knobs are the four dims (width/height/thumbSize/touchSize); the thumb's touch + // padding and travel are *derived* so the thumb stays centered on the track. + // SwitchBase pad = (touchSize - thumbSize) / 2 (centers thumb in the button) + // button top = (height - touchSize) / 2 (centers button in the root) + // checked travel = width - touchSize + // Defaults: touchSize == height -> pad 9/4, top 0, travel 20/16 (pixel-identical). + // The thumb (SwitchBase) and Thumb/Track slots inherit these seams (custom props + // inherit; they don't redeclare them). Track gutter (padding) stays literal. + '--_width': '58px', // 34 (track) + 12 (gutter) * 2 + '--_height': '38px', // 14 (track) + 12 (gutter) * 2 + '--_thumbSize': '20px', + '--_touchSize': '38px', + '--Switch-width': 'var(--Switch-medium-width, var(--_width))', + '--Switch-height': 'var(--Switch-medium-height, var(--_height))', + '--Switch-thumbSize': 'var(--Switch-medium-thumbSize, var(--_thumbSize))', + '--Switch-touchSize': 'var(--Switch-medium-touchSize, var(--_touchSize))', + '--SwitchBase-pad': 'calc((var(--Switch-touchSize) - var(--Switch-thumbSize)) / 2)', display: 'inline-flex', - width: 34 + 12 * 2, - height: 14 + 12 * 2, + width: 'var(--Switch-width, var(--_width))', + height: 'var(--Switch-height, var(--_height))', overflow: 'hidden', padding: 12, boxSizing: 'border-box', @@ -64,12 +82,6 @@ const SwitchRoot = styled('span', { '@media print': { colorAdjust: 'exact', }, - // Density: the thumb (SwitchBase) inherits the seam from here — no descendant - // selector needed (custom props inherit; the thumb doesn't redeclare the seam). - // `--_pad` is the thumb's default fallback (the root's own padding stays - // literal — box geometry is size-coupled). - '--_pad': '9px', - '--SwitchBase-pad': 'var(--Switch-medium-pad, var(--_pad))', variants: [ { props: { edge: 'start' }, @@ -82,22 +94,16 @@ const SwitchRoot = styled('span', { { props: { size: 'small' }, style: { - width: 40, - height: 24, + // Re-route the four dims to the small tokens; pad/top/travel re-derive. + '--_width': '40px', + '--_height': '24px', + '--_thumbSize': '16px', + '--_touchSize': '24px', + '--Switch-width': 'var(--Switch-small-width, var(--_width))', + '--Switch-height': 'var(--Switch-small-height, var(--_height))', + '--Switch-thumbSize': 'var(--Switch-small-thumbSize, var(--_thumbSize))', + '--Switch-touchSize': 'var(--Switch-small-touchSize, var(--_touchSize))', padding: 7, - // Small thumb default is 4 (≠ the thumb's own --_pad 9), so feed it via - // the seam: set --_pad here (inherited as the seam's fallback). - '--_pad': '4px', - '--SwitchBase-pad': 'var(--Switch-small-pad, var(--_pad))', - [`& .${switchClasses.thumb}`]: { - width: 16, - height: 16, - }, - [`& .${switchClasses.switchBase}`]: { - [`&.${switchClasses.checked}`]: { - transform: 'translateX(16px)', - }, - }, }, }, ], @@ -118,7 +124,8 @@ const SwitchSwitchBase = styled(SwitchBase, { })( memoTheme(({ theme }) => ({ position: 'absolute', - top: 0, + // Center the touch target in the root (top 0 when touchSize == height). + top: 'calc((var(--Switch-height, var(--_height)) - var(--Switch-touchSize, var(--_touchSize))) / 2)', left: 0, zIndex: 1, // Render above the focus ripple. color: theme.vars @@ -128,7 +135,9 @@ const SwitchSwitchBase = styled(SwitchBase, { duration: theme.transitions.duration.shortest, }), [`&.${switchClasses.checked}`]: { - transform: 'translateX(20px)', + // Travel = root width - touch target (keeps the thumb symmetric on the track). + transform: + 'translateX(calc(var(--Switch-width, var(--_width)) - var(--Switch-touchSize, var(--_touchSize))))', }, [`&.${switchClasses.disabled}`]: { color: theme.vars @@ -229,8 +238,8 @@ const SwitchThumb = styled('span', { backgroundColor: 'currentColor', boxSizing: 'border-box', border: '1px solid transparent', - width: 20, - height: 20, + width: 'var(--Switch-thumbSize, var(--_thumbSize))', + height: 'var(--Switch-thumbSize, var(--_thumbSize))', borderRadius: '50%', })), ); diff --git a/packages/mui-material/src/styles/enhanceDensity.ts b/packages/mui-material/src/styles/enhanceDensity.ts index b67ce779994368..93af977d698f38 100644 --- a/packages/mui-material/src/styles/enhanceDensity.ts +++ b/packages/mui-material/src/styles/enhanceDensity.ts @@ -399,20 +399,9 @@ export default function enhanceDensity< ], }, }, - MuiSwitch: { - ...c?.MuiSwitch, - styleOverrides: { - ...c?.MuiSwitch?.styleOverrides, - root: [ - c?.MuiSwitch?.styleOverrides?.root, - { - // Thumb (SwitchBase) padding; box geometry stays literal. - '--Switch-medium-pad': varRefs.sm, - '--Switch-small-pad': varRefs.xxs, - }, - ], - }, - }, + // Switch is intentionally not wired here: its geometry (width/height/thumbSize/ + // touchSize) is interlocked, not spacing-scale-derived. Tune it per size via + // the public --Switch--* tokens directly. }; return theme; From dd57b714ecb5a35e98612a52bdc1635c5729547a Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 8 Jun 2026 09:13:29 +0700 Subject: [PATCH 17/28] [docs] Update Switch density: interlocked geometry, derive coupled values Switch tokenizes width/height/thumbSize/touchSize per size and derives pad/top/ travel via calc (thumb stays centered); not the pad-only approach. Corrects the shared-base sections in ADR 0001 + rollout Recipe C. --- docs/adr/0001-css-var-density-adapter.md | 23 +++++++++++++++------- docs/adr/density-adapter-rollout.md | 25 ++++++++++++++++++++---- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/docs/adr/0001-css-var-density-adapter.md b/docs/adr/0001-css-var-density-adapter.md index 619df2a5bb88cc..6fffb3c92ff1d5 100644 --- a/docs/adr/0001-css-var-density-adapter.md +++ b/docs/adr/0001-css-var-density-adapter.md @@ -237,13 +237,22 @@ descendant selector, no added specificity): The inheritance caveat is the mirror of why `--_` is safe unprefixed: the base **redeclares `--_`** on itself, so an inherited `--_` is shadowed. -The seam inherits (not redeclared); the internal default does not. So when a -wrapper needs a per-state default that differs from the base's (the small Switch -thumb is `4px`, not the base's `9px`), it can't inherit `--_` — it feeds the -value **through the seam** by setting `--_` on the wrapper root, where the -seam's `var(..., var(--_))` fallback resolves. Box geometry that is coupled -to the value (Switch's `width = 34 + 12·2`) is left literal — only the -inheritance-safe padding axis is tokenized. +The seam inherits (not redeclared); the internal default does not. So a wrapper +that needs a value different from the base's feeds it **through the seam** — set +the seam directly on the wrapper (preferred), not the shadowed `--_`. Switch +does exactly this: it sets `--SwitchBase-pad` to a derived `calc` (below). + +**Interlocked geometry — derive, don't tokenize one axis.** A `Switch`'s width, +height, thumb, touch target and travel all move together; tokenizing the thumb +pad alone drifts the thumb off the track. So Switch tokenizes the four real dims +per size (`--Switch--width/height/thumbSize/touchSize`) and **derives** the +coupled values with `calc`, feeding the shared seam: SwitchBase pad +`= (touchSize − thumbSize) / 2`; the absolutely-positioned button stays centered +via `top = (height − touchSize) / 2` and checked `transform: translateX(width − +touchSize)`; the thumb slot reads `thumbSize`. Defaults (`touchSize == height`) +compute to today's `9/4` pad, `0` top, `20/16` travel — pixel-identical. +`enhanceDensity` skips Switch (geometry isn't spacing-scale-derived); tune it per +size through the public dim tokens. ## Consequences diff --git a/docs/adr/density-adapter-rollout.md b/docs/adr/density-adapter-rollout.md index 163e3e9db60449..fc4ed9976d0324 100644 --- a/docs/adr/density-adapter-rollout.md +++ b/docs/adr/density-adapter-rollout.md @@ -165,10 +165,27 @@ Two reader topologies: **Inheritance caveat.** The _seam_ inherits because the base doesn't redeclare it. But the base **does** redeclare `--_` (that's what keeps it unprefixed-safe), -so an inherited `--_` is _shadowed_ on the base. If a wrapper needs a -per-state default different from the base's (Switch small thumb `4` ≠ base `9`), -feed it **through the seam** — set `--_` on the wrapper so the seam's fallback -resolves there; don't expect the base to inherit your `--_`. +so an inherited `--_` is _shadowed_ on the base. A wrapper that needs a value +different from the base's feeds it **through the seam** — set the seam directly on +the wrapper, not the shadowed `--_`. + +**Interlocked geometry -> derive, don't tokenize one axis.** When a component's +dims move together (a `Switch`: width/height/thumb/touch/travel), tokenizing one +(the thumb pad) alone drifts the thumb off the track. Tokenize the real dims per +size and **derive** the coupled values with `calc`, feeding the seam: + +```js +// SwitchRoot, per size: --Switch--{width,height,thumbSize,touchSize} +'--SwitchBase-pad': 'calc((var(--Switch-touchSize) - var(--Switch-thumbSize)) / 2)', +// thumb button (absolute): keep it centered +top: 'calc((var(--Switch-height) - var(--Switch-touchSize)) / 2)', +// checked: travel = width - touch +transform: 'translateX(calc(var(--Switch-width) - var(--Switch-touchSize)))', +``` + +`touchSize == height` by default -> pad `9/4`, top `0`, travel `20/16` +(pixel-identical). Skip such a component in `enhanceDensity` (its dims aren't +spacing-scale-derived) — tune per size via the public dim tokens. ## Gotchas From 7d9679b101c540b9e54acd7ff3878aa0583515c3 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 8 Jun 2026 09:17:10 +0700 Subject: [PATCH 18/28] [material-ui] Switch: tokenize track gutter (--Switch--pad) The root padding (12/7, track inset) is its own axis -> tokenize as --Switch--pad over --_pad, consumed padding: var(--Switch-pad, var(--_pad)). Distinct from the derived thumb SwitchBase pad. Fixture scope + docs updated. --- docs/adr/0001-css-var-density-adapter.md | 7 ++++--- docs/adr/density-adapter-rollout.md | 6 ++++-- docs/pages/experiments/density-fixture.tsx | 9 +++++++-- packages/mui-material/src/Switch/Switch.js | 18 +++++++++++------- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/docs/adr/0001-css-var-density-adapter.md b/docs/adr/0001-css-var-density-adapter.md index 6fffb3c92ff1d5..fc95f7b52d716d 100644 --- a/docs/adr/0001-css-var-density-adapter.md +++ b/docs/adr/0001-css-var-density-adapter.md @@ -244,9 +244,10 @@ does exactly this: it sets `--SwitchBase-pad` to a derived `calc` (below). **Interlocked geometry — derive, don't tokenize one axis.** A `Switch`'s width, height, thumb, touch target and travel all move together; tokenizing the thumb -pad alone drifts the thumb off the track. So Switch tokenizes the four real dims -per size (`--Switch--width/height/thumbSize/touchSize`) and **derives** the -coupled values with `calc`, feeding the shared seam: SwitchBase pad +pad alone drifts the thumb off the track. So Switch tokenizes its real dims per +size (`--Switch--width/height/thumbSize/touchSize` + the track gutter +`--Switch--pad`) and **derives** the coupled values with `calc`, feeding the +shared seam: SwitchBase pad `= (touchSize − thumbSize) / 2`; the absolutely-positioned button stays centered via `top = (height − touchSize) / 2` and checked `transform: translateX(width − touchSize)`; the thumb slot reads `thumbSize`. Defaults (`touchSize == height`) diff --git a/docs/adr/density-adapter-rollout.md b/docs/adr/density-adapter-rollout.md index fc4ed9976d0324..aba607c8e12373 100644 --- a/docs/adr/density-adapter-rollout.md +++ b/docs/adr/density-adapter-rollout.md @@ -172,10 +172,12 @@ the wrapper, not the shadowed `--_`. **Interlocked geometry -> derive, don't tokenize one axis.** When a component's dims move together (a `Switch`: width/height/thumb/touch/travel), tokenizing one (the thumb pad) alone drifts the thumb off the track. Tokenize the real dims per -size and **derive** the coupled values with `calc`, feeding the seam: +size (incl. the track gutter `pad`) and **derive** the coupled values with `calc`, +feeding the seam: ```js -// SwitchRoot, per size: --Switch--{width,height,thumbSize,touchSize} +// SwitchRoot, per size: --Switch--{width,height,thumbSize,touchSize,pad} +padding: 'var(--Switch-pad, var(--_pad))', // track gutter (own axis) '--SwitchBase-pad': 'calc((var(--Switch-touchSize) - var(--Switch-thumbSize)) / 2)', // thumb button (absolute): keep it centered top: 'calc((var(--Switch-height) - var(--Switch-touchSize)) / 2)', diff --git a/docs/pages/experiments/density-fixture.tsx b/docs/pages/experiments/density-fixture.tsx index 4f00f846b4cf7f..f56b6018e639d6 100644 --- a/docs/pages/experiments/density-fixture.tsx +++ b/docs/pages/experiments/density-fixture.tsx @@ -866,27 +866,32 @@ const scopes: Record> = { }, }, Switch: { - // Tune the four interlocked dims; pad/top/travel re-derive (touchSize == height - // keeps the thumb centered). thumbSize < height; width > touchSize. + // Tune the interlocked dims + track gutter (pad); thumb pad/top/travel re-derive + // (touchSize == height keeps the thumb centered). thumbSize < height; width > + // touchSize; pad < height/2. dense: { ['--Switch-small-width' as any]: '32px', ['--Switch-small-height' as any]: '18px', ['--Switch-small-thumbSize' as any]: '12px', ['--Switch-small-touchSize' as any]: '18px', + ['--Switch-small-pad' as any]: '5px', ['--Switch-medium-width' as any]: '44px', ['--Switch-medium-height' as any]: '24px', ['--Switch-medium-thumbSize' as any]: '16px', ['--Switch-medium-touchSize' as any]: '24px', + ['--Switch-medium-pad' as any]: '8px', }, loose: { ['--Switch-small-width' as any]: '52px', ['--Switch-small-height' as any]: '32px', ['--Switch-small-thumbSize' as any]: '24px', ['--Switch-small-touchSize' as any]: '32px', + ['--Switch-small-pad' as any]: '10px', ['--Switch-medium-width' as any]: '76px', ['--Switch-medium-height' as any]: '48px', ['--Switch-medium-thumbSize' as any]: '34px', ['--Switch-medium-touchSize' as any]: '48px', + ['--Switch-medium-pad' as any]: '16px', }, }, }; diff --git a/packages/mui-material/src/Switch/Switch.js b/packages/mui-material/src/Switch/Switch.js index 2fc56b9dd5fce7..0cf46ea9b2c608 100644 --- a/packages/mui-material/src/Switch/Switch.js +++ b/packages/mui-material/src/Switch/Switch.js @@ -51,29 +51,32 @@ const SwitchRoot = styled('span', { ]; }, })({ - // Density (docs/adr/0001): Switch geometry is interlocked, so the meaningful - // knobs are the four dims (width/height/thumbSize/touchSize); the thumb's touch - // padding and travel are *derived* so the thumb stays centered on the track. + // Density (docs/adr/0001): Switch geometry is interlocked, so the knobs are the + // dims (width/height/thumbSize/touchSize) + the track gutter (pad); the thumb's + // touch padding and travel are *derived* so the thumb stays centered on the track. // SwitchBase pad = (touchSize - thumbSize) / 2 (centers thumb in the button) // button top = (height - touchSize) / 2 (centers button in the root) // checked travel = width - touchSize // Defaults: touchSize == height -> pad 9/4, top 0, travel 20/16 (pixel-identical). // The thumb (SwitchBase) and Thumb/Track slots inherit these seams (custom props - // inherit; they don't redeclare them). Track gutter (padding) stays literal. + // inherit; they don't redeclare them). `--_pad` here is the root's gutter default + // (the track inset), distinct from the thumb's own SwitchBase `--_pad`. '--_width': '58px', // 34 (track) + 12 (gutter) * 2 '--_height': '38px', // 14 (track) + 12 (gutter) * 2 '--_thumbSize': '20px', '--_touchSize': '38px', + '--_pad': '12px', '--Switch-width': 'var(--Switch-medium-width, var(--_width))', '--Switch-height': 'var(--Switch-medium-height, var(--_height))', '--Switch-thumbSize': 'var(--Switch-medium-thumbSize, var(--_thumbSize))', '--Switch-touchSize': 'var(--Switch-medium-touchSize, var(--_touchSize))', + '--Switch-pad': 'var(--Switch-medium-pad, var(--_pad))', '--SwitchBase-pad': 'calc((var(--Switch-touchSize) - var(--Switch-thumbSize)) / 2)', display: 'inline-flex', width: 'var(--Switch-width, var(--_width))', height: 'var(--Switch-height, var(--_height))', overflow: 'hidden', - padding: 12, + padding: 'var(--Switch-pad, var(--_pad))', boxSizing: 'border-box', position: 'relative', flexShrink: 0, @@ -94,16 +97,17 @@ const SwitchRoot = styled('span', { { props: { size: 'small' }, style: { - // Re-route the four dims to the small tokens; pad/top/travel re-derive. + // Re-route the dims + gutter to the small tokens; pad/top/travel re-derive. '--_width': '40px', '--_height': '24px', '--_thumbSize': '16px', '--_touchSize': '24px', + '--_pad': '7px', '--Switch-width': 'var(--Switch-small-width, var(--_width))', '--Switch-height': 'var(--Switch-small-height, var(--_height))', '--Switch-thumbSize': 'var(--Switch-small-thumbSize, var(--_thumbSize))', '--Switch-touchSize': 'var(--Switch-small-touchSize, var(--_touchSize))', - padding: 7, + '--Switch-pad': 'var(--Switch-small-pad, var(--_pad))', }, }, ], From ada1d5e52e319d50b61c48ddd733fc1c44274550 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 8 Jun 2026 09:19:47 +0700 Subject: [PATCH 19/28] [material-ui] Switch: derive track borderRadius from height + gutter borderRadius = (height - 2*pad)/2 (full-pill track thickness) instead of literal 14/2, so the track stays rounded when the dims are tuned. Pixel-identical (medium 7px; small clamps to a pill). --- packages/mui-material/src/Switch/Switch.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/mui-material/src/Switch/Switch.js b/packages/mui-material/src/Switch/Switch.js index 0cf46ea9b2c608..03a195b2a1ce65 100644 --- a/packages/mui-material/src/Switch/Switch.js +++ b/packages/mui-material/src/Switch/Switch.js @@ -215,7 +215,10 @@ const SwitchTrack = styled('span', { memoTheme(({ theme }) => ({ height: '100%', width: '100%', - borderRadius: 14 / 2, + // Full pill: half the track thickness (height minus the two gutters). Inherits + // the seams from SwitchRoot. Medium -> 7px; small clamps to a pill either way. + borderRadius: + 'calc((var(--Switch-height, var(--_height)) - 2 * var(--Switch-pad, var(--_pad))) / 2)', zIndex: -1, transition: theme.transitions.create(['opacity', 'background-color'], { duration: theme.transitions.duration.shortest, From c8ca3cb44e3bfa56da289eee8084a711de77a8c6 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 8 Jun 2026 09:29:46 +0700 Subject: [PATCH 20/28] [material-ui] enhanceDensity: add xxl step, wire Switch dims Add an xxl density step (4x spacing unit). Wire MuiSwitch: map per-size width/height/touchSize/thumbSize/pad to scale steps (xxl for the wider track); pad/top/travel/radius re-derive so the geometry stays valid. Docs updated. --- docs/adr/0001-css-var-density-adapter.md | 6 ++-- docs/adr/density-adapter-rollout.md | 4 +-- .../mui-material/src/styles/enhanceDensity.ts | 32 ++++++++++++++++--- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/docs/adr/0001-css-var-density-adapter.md b/docs/adr/0001-css-var-density-adapter.md index fc95f7b52d716d..b9c6eba479d79a 100644 --- a/docs/adr/0001-css-var-density-adapter.md +++ b/docs/adr/0001-css-var-density-adapter.md @@ -252,8 +252,10 @@ shared seam: SwitchBase pad via `top = (height − touchSize) / 2` and checked `transform: translateX(width − touchSize)`; the thumb slot reads `thumbSize`. Defaults (`touchSize == height`) compute to today's `9/4` pad, `0` top, `20/16` travel — pixel-identical. -`enhanceDensity` skips Switch (geometry isn't spacing-scale-derived); tune it per -size through the public dim tokens. +`enhanceDensity` _can_ wire Switch precisely because it derives: it maps the input +dims to scale steps (the `xxl` step covers the wider track) and pad/top/travel/ +radius re-derive, so the geometry stays valid (`touchSize == height` keeps it +centered, `width > touchSize` keeps travel positive). ## Consequences diff --git a/docs/adr/density-adapter-rollout.md b/docs/adr/density-adapter-rollout.md index aba607c8e12373..9739eb25cb91df 100644 --- a/docs/adr/density-adapter-rollout.md +++ b/docs/adr/density-adapter-rollout.md @@ -186,8 +186,8 @@ transform: 'translateX(calc(var(--Switch-width) - var(--Switch-touchSize)))', ``` `touchSize == height` by default -> pad `9/4`, top `0`, travel `20/16` -(pixel-identical). Skip such a component in `enhanceDensity` (its dims aren't -spacing-scale-derived) — tune per size via the public dim tokens. +(pixel-identical). `enhanceDensity` can still wire it: map the input dims to scale +steps and the derived values stay valid (Switch uses `xxl` for the wider track). ## Gotchas diff --git a/packages/mui-material/src/styles/enhanceDensity.ts b/packages/mui-material/src/styles/enhanceDensity.ts index 93af977d698f38..1f50db894693a3 100644 --- a/packages/mui-material/src/styles/enhanceDensity.ts +++ b/packages/mui-material/src/styles/enhanceDensity.ts @@ -11,6 +11,7 @@ export interface DensityScale { md: string; lg: string; xl: string; + xxl: string; } export interface DensityOptions { @@ -22,7 +23,7 @@ export interface DensityOptions { type DensityKey = keyof DensityScale; -const densityKeys: DensityKey[] = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl']; +const densityKeys: DensityKey[] = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl']; // Default scale: t-shirt steps derived from the theme spacing unit. const defaultMultiplier: Record = { @@ -32,6 +33,7 @@ const defaultMultiplier: Record = { md: 1.5, lg: 2, xl: 3, + xxl: 4, }; const cssVar = (key: DensityKey) => `--mui-density-${key}`; @@ -399,9 +401,31 @@ export default function enhanceDensity< ], }, }, - // Switch is intentionally not wired here: its geometry (width/height/thumbSize/ - // touchSize) is interlocked, not spacing-scale-derived. Tune it per size via - // the public --Switch--* tokens directly. + MuiSwitch: { + ...c?.MuiSwitch, + styleOverrides: { + ...c?.MuiSwitch?.styleOverrides, + root: [ + c?.MuiSwitch?.styleOverrides?.root, + { + // Switch maps its input dims to scale steps; pad/top/travel/radius + // re-derive from them, so the geometry stays valid (touchSize == height + // -> centered; width > touchSize -> positive travel). `xxl` covers the + // wider track. + '--Switch-medium-width': varRefs.xxl, + '--Switch-medium-height': varRefs.xl, + '--Switch-medium-touchSize': varRefs.xl, + '--Switch-medium-thumbSize': varRefs.lg, + '--Switch-medium-pad': varRefs.sm, + '--Switch-small-width': varRefs.xl, + '--Switch-small-height': varRefs.lg, + '--Switch-small-touchSize': varRefs.lg, + '--Switch-small-thumbSize': varRefs.md, + '--Switch-small-pad': varRefs.xs, + }, + ], + }, + }, }; return theme; From 02835001aa7862f0c078e4c1570cf189adb3dcb7 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 8 Jun 2026 09:44:38 +0700 Subject: [PATCH 21/28] [material-ui] enhanceDensity: compose Switch dims to match default sizes Switch dims were mapped to single scale steps, shrinking it. Compose from steps so defaults land on today's px (medium 58/38/20/38/12, small 40/24/16/24/7) and still scale with density: width calc(xxl*2-6), height/touch calc(xxl+xs), thumb calc(lg+xxs), etc. touchSize == height keeps the thumb centered. --- .../mui-material/src/styles/enhanceDensity.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/mui-material/src/styles/enhanceDensity.ts b/packages/mui-material/src/styles/enhanceDensity.ts index 1f50db894693a3..8f23735b918655 100644 --- a/packages/mui-material/src/styles/enhanceDensity.ts +++ b/packages/mui-material/src/styles/enhanceDensity.ts @@ -408,20 +408,20 @@ export default function enhanceDensity< root: [ c?.MuiSwitch?.styleOverrides?.root, { - // Switch maps its input dims to scale steps; pad/top/travel/radius - // re-derive from them, so the geometry stays valid (touchSize == height - // -> centered; width > touchSize -> positive travel). `xxl` covers the - // wider track. - '--Switch-medium-width': varRefs.xxl, - '--Switch-medium-height': varRefs.xl, - '--Switch-medium-touchSize': varRefs.xl, - '--Switch-medium-thumbSize': varRefs.lg, - '--Switch-medium-pad': varRefs.sm, - '--Switch-small-width': varRefs.xl, - '--Switch-small-height': varRefs.lg, - '--Switch-small-touchSize': varRefs.lg, - '--Switch-small-thumbSize': varRefs.md, - '--Switch-small-pad': varRefs.xs, + // Switch dims are composed from scale steps to land on today's sizes + // at the default scale, then track density proportionally. pad/top/ + // travel/radius re-derive, so the geometry stays valid (touchSize == + // height -> centered; width > touchSize -> positive travel). + '--Switch-medium-width': `calc(${varRefs.xxl} * 2 - 6px)`, // 58 + '--Switch-medium-height': `calc(${varRefs.xxl} + ${varRefs.xs})`, // 38 + '--Switch-medium-touchSize': `calc(${varRefs.xxl} + ${varRefs.xs})`, // 38 (= height) + '--Switch-medium-thumbSize': `calc(${varRefs.lg} + ${varRefs.xxs})`, // 20 + '--Switch-medium-pad': varRefs.md, // 12 + '--Switch-small-width': `calc(${varRefs.xxl} + ${varRefs.sm})`, // 40 + '--Switch-small-height': varRefs.xl, // 24 + '--Switch-small-touchSize': varRefs.xl, // 24 (= height) + '--Switch-small-thumbSize': varRefs.lg, // 16 + '--Switch-small-pad': `calc(${varRefs.sm} - 1px)`, // 7 }, ], }, From d1f3d0d892148b6f4f5ba7144df86b76082973ee Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 8 Jun 2026 11:38:14 +0700 Subject: [PATCH 22/28] [material-ui] Fix density seams: OutlinedInput label, MenuItem icon, Tabs minHeight - enhanceDensity: derive OutlinedInput --InputLabel-y from density step (sibling label can't read the input's padBlock token); per-size via variants - MenuItem: consume --ListItemIcon-minWidth (was hardcoded 36) so density reaches the icon - Tabs: add --Tabs-minHeight base seam (parent can't read child --Tab-minHeight) + wire MuiTabs --- .../mui-material/src/MenuItem/MenuItem.js | 2 +- packages/mui-material/src/Tabs/Tabs.js | 5 ++- .../mui-material/src/styles/enhanceDensity.ts | 37 ++++++++++++++++++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/mui-material/src/MenuItem/MenuItem.js b/packages/mui-material/src/MenuItem/MenuItem.js index 3e526ebf204d93..285f37e48d79d6 100644 --- a/packages/mui-material/src/MenuItem/MenuItem.js +++ b/packages/mui-material/src/MenuItem/MenuItem.js @@ -136,7 +136,7 @@ const MenuItemRoot = styled(ButtonBase, { paddingLeft: 36, }, [`& .${listItemIconClasses.root}`]: { - minWidth: 36, + minWidth: 'var(--ListItemIcon-minWidth, 36px)', }, variants: [ { diff --git a/packages/mui-material/src/Tabs/Tabs.js b/packages/mui-material/src/Tabs/Tabs.js index 24ed5c387ebaf8..d897f445b512e3 100644 --- a/packages/mui-material/src/Tabs/Tabs.js +++ b/packages/mui-material/src/Tabs/Tabs.js @@ -75,7 +75,10 @@ const TabsRoot = styled('div', { })( memoTheme(({ theme }) => ({ overflow: 'hidden', - minHeight: 48, + // Density adapter (docs/adr/0001): base token, 48px literal fallback keeps the + // default pixel-identical. Tabs is the Tab's parent, so it can't read the + // child's `--Tab-minHeight` — it carries its own seam. + minHeight: 'var(--Tabs-minHeight, 48px)', // Add iOS momentum scrolling for iOS < 13.0 WebkitOverflowScrolling: 'touch', display: 'flex', diff --git a/packages/mui-material/src/styles/enhanceDensity.ts b/packages/mui-material/src/styles/enhanceDensity.ts index 8f23735b918655..e9d49c9ccbd690 100644 --- a/packages/mui-material/src/styles/enhanceDensity.ts +++ b/packages/mui-material/src/styles/enhanceDensity.ts @@ -1,4 +1,5 @@ import { Theme } from './createTheme'; +import inputLabelClasses from '../InputLabel/inputLabelClasses'; /** * Named density steps, surfaced as `--mui-density-*` CSS vars. Components wired @@ -127,8 +128,8 @@ export default function enhanceDensity< root: [ c?.MuiChip?.styleOverrides?.root, { - '--Chip-small-height': varRefs.lg, - '--Chip-medium-height': varRefs.xl, + '--Chip-small-height': varRefs.xl, + '--Chip-medium-height': varRefs.xxl, '--Chip-small-padInline': varRefs.sm, '--Chip-medium-padInline': varRefs.md, }, @@ -273,6 +274,19 @@ export default function enhanceDensity< ], }, }, + MuiTabs: { + ...c?.MuiTabs, + styleOverrides: { + ...c?.MuiTabs?.styleOverrides, + root: [ + c?.MuiTabs?.styleOverrides?.root, + { + // Match Tab's minHeight step so the bar tracks the tabs it contains. + '--Tabs-minHeight': `calc(${varRefs.xl} + ${varRefs.lg})`, + }, + ], + }, + }, MuiTablePagination: { ...c?.MuiTablePagination, styleOverrides: { @@ -370,6 +384,25 @@ export default function enhanceDensity< '--OutlinedInput-small-padBlock': varRefs.sm, '--OutlinedInput-medium-padInline': varRefs.lg, '--OutlinedInput-small-padInline': varRefs.md, + // The outlined label resting-Y tracks the input's block padding, but + // the label is a preceding sibling — it can't read the input's + // `--OutlinedInput-*-padBlock` (custom props don't inherit sibling -> + // sibling). So derive `--InputLabel-y` straight from the density step + // (which the label DOES inherit from `:root`), matching the + // component's -0.5/+0.5 rounding per size. + [`.${inputLabelClasses.root}:has(~ &)`]: { + '--InputLabel-y': `calc(${varRefs.md} - 0.5px)`, + }, + variants: [ + { + props: { size: 'small' }, + style: { + [`.${inputLabelClasses.root}:has(~ &)`]: { + '--InputLabel-y': `calc(${varRefs.sm} + 0.5px)`, + }, + }, + }, + ], }, ], }, From 7c4b598dee5dc48a88b5613e39c52ec8c68f276d Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 8 Jun 2026 11:38:23 +0700 Subject: [PATCH 23/28] [docs] Add density-showcase experiment; share demos with fixture - New /experiments/density-showcase: preset switcher (compact/normal/comfort), live scale readout + per-component token accordion, masonry gallery - Extract shared demos to densityDemos.tsx; fixture imports it - Fixture: --Tabs-minHeight scope, center row Stacks --- docs/pages/experiments/density-fixture.tsx | 633 +----------------- docs/pages/experiments/density-showcase.tsx | 258 ++++++++ docs/src/modules/components/densityDemos.tsx | 642 +++++++++++++++++++ 3 files changed, 907 insertions(+), 626 deletions(-) create mode 100644 docs/pages/experiments/density-showcase.tsx create mode 100644 docs/src/modules/components/densityDemos.tsx diff --git a/docs/pages/experiments/density-fixture.tsx b/docs/pages/experiments/density-fixture.tsx index f56b6018e639d6..3ccddaea7d40f8 100644 --- a/docs/pages/experiments/density-fixture.tsx +++ b/docs/pages/experiments/density-fixture.tsx @@ -2,638 +2,17 @@ import * as React from 'react'; import { useRouter } from 'next/router'; import Box from '@mui/material/Box'; -import Stack from '@mui/material/Stack'; -import Button from '@mui/material/Button'; -import OutlinedInput from '@mui/material/OutlinedInput'; -import TextField from '@mui/material/TextField'; -import InputAdornment from '@mui/material/InputAdornment'; -import FormControl from '@mui/material/FormControl'; -import InputLabel from '@mui/material/InputLabel'; -import Chip from '@mui/material/Chip'; -import Avatar from '@mui/material/Avatar'; -import IconButton from '@mui/material/IconButton'; -import MenuItem from '@mui/material/MenuItem'; -import MenuList from '@mui/material/MenuList'; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; -import ListItemButton from '@mui/material/ListItemButton'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; -import ListSubheader from '@mui/material/ListSubheader'; -import Toolbar from '@mui/material/Toolbar'; -import Tab from '@mui/material/Tab'; -import Tabs from '@mui/material/Tabs'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableRow from '@mui/material/TableRow'; -import TablePagination from '@mui/material/TablePagination'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import CardActions from '@mui/material/CardActions'; -import Select from '@mui/material/Select'; -import Breadcrumbs from '@mui/material/Breadcrumbs'; -import Link from '@mui/material/Link'; -import Badge from '@mui/material/Badge'; -import Checkbox from '@mui/material/Checkbox'; -import Radio from '@mui/material/Radio'; -import Switch from '@mui/material/Switch'; -import Typography from '@mui/material/Typography'; -import FaceIcon from '@mui/icons-material/Face'; -import DeleteIcon from '@mui/icons-material/Delete'; -import InboxIcon from '@mui/icons-material/Inbox'; -import DraftsIcon from '@mui/icons-material/Drafts'; -import FavoriteIcon from '@mui/icons-material/Favorite'; -import VisibilityIcon from '@mui/icons-material/Visibility'; import { createTheme, ThemeProvider } from '@mui/material/styles'; +import demos from 'docs/src/modules/components/densityDemos'; // Local verification fixture for the CSS-var density adapter (docs/adr/0001). // Used by scripts/density-screenshots. Renders one component's load-bearing -// matrix inside #density-scope; the harness sets `level` (default | dense | -// loose), which the scope translates into per-component density-token overrides. -// `level=default` sets no tokens, so the render must be pixel-identical to the -// pre-change baseline. Add a component's matrix to `demos` before verifying it. +// matrix (shared `demos`) inside #density-scope; the harness sets `level` +// (default | dense | loose), which the scope translates into per-component +// density-token overrides. `level=default` sets no tokens, so the render must be +// pixel-identical to the pre-change baseline. const theme = createTheme({ cssVariables: true }); -const demos: Record = { - Button: ( - - {(['small', 'medium', 'large'] as const).map((size) => ( - - - - - - ))} - - ), - OutlinedInput: ( - - {(['small', 'medium'] as const).map((size) => ( - - - - @} - /> - - ))} - - ), - TextField: ( - - {(['medium', 'small'] as const).map((size) => ( - - {`outlined ${size}`} - - - ))} - - $, - endAdornment: kg, - }, - }} - /> - - - ), - Chip: ( - - {(['small', 'medium'] as const).map((size) => ( - - - - A} /> - } /> - {}} /> - {}} - icon={} - /> - - ))} - - ), - IconButton: ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ), - MenuItem: ( - // MenuItem requires a MenuList/Menu ancestor (MenuListContext). - - Default item - Selected item - - - - - With icon - - With divider - No gutters - Dense item - - - - - Dense + icon - - - Dense no gutters - - - ), - ListItem: ( - - - Default item - - - - } - > - With secondary action - - Divider item - - - - - - Dense item - - - - } - > - Dense with action - - Dense, no gutters - - - ), - ListItemButton: ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ), - ListItemIcon: ( - - - - - - - - - - - - - - - - - - - - - - - - - ), - ListItemText: ( - - {([false, true] as const).map((dense) => ( - - - - - - - - - - - - - - - ))} - - ), - ListSubheader: ( - - - Gutters (default) - - - Inset - - - Disable gutters - - - Primary color - - - ), - Toolbar: ( - - {(['regular', 'dense'] as const).map((variant) => ( - - - {variant} gutters - action - - - {variant} no-gutters - action - - - ))} - - ), - Tab: ( - // Tab requires a Tabs ancestor (RovingTabIndexContext). - - {}}> - - - - - {}}> - } label="Top" iconPosition="top" value={0} /> - } label="Bottom" iconPosition="bottom" value={1} /> - } label="Start" iconPosition="start" value={2} /> - } label="End" iconPosition="end" value={3} /> - - {}}> - } aria-label="icon only" value={0} /> - - - - ), - Tabs: ( - - - - - - - - } label="Top" iconPosition="top" /> - } label="Bottom" iconPosition="bottom" /> - } label="Start" iconPosition="start" /> - } label="End" iconPosition="end" /> - - - } aria-label="fav" /> - } aria-label="del" /> - - - ), - TablePagination: ( - - - - - {}} - onRowsPerPageChange={() => {}} - /> - - - {}} - onRowsPerPageChange={() => {}} - /> - - - {}} - onRowsPerPageChange={() => {}} - /> - - -
-
- ), - CardContent: ( - - - - Default - All-sides padding via --CardContent-pad. - - - - - Last-child - - Extra bottom inset (--CardContent-padBottom) since this is the last child. - - - - - - Above actions - Not last child -> base pad only on the bottom. - - - - - - - ), - Select: ( - - - - - - - - ), - Breadcrumbs: ( - - - - Home - - - Catalog - - Shoes - - - - Home - - - Library - - - Data - - Reports - - - - Home - - - Catalog - - - Accessories - - Belts - - - ), - InputAdornment: ( - - {(['small', 'medium'] as const).map((size) => ( - - $ }, - }} - /> - - - - - - ), - }, - }} - /> - kg }, - }} - /> - kg }, - }} - /> - - ))} - - ), - Badge: ( - - - - - - - - - - - - - - - - - - ), - Checkbox: ( - - {(['small', 'medium'] as const).map((size) => ( - - - - - - - - - ))} - - ), - Radio: ( - - {(['small', 'medium'] as const).map((size) => ( - - - - - - - - ))} - - ), - Switch: ( - - {(['small', 'medium'] as const).map((size) => ( - - - - - - - - ))} - - ), -}; - // Per-component density-token overrides for the review levels. `default` is // empty on purpose — that render is the pixel-identical regression gate. const scopes: Record> = { @@ -777,12 +156,14 @@ const scopes: Record> = { }, Tabs: { dense: { + ['--Tabs-minHeight' as any]: '32px', ['--Tab-padBlock' as any]: '4px', ['--Tab-padInline' as any]: '8px', ['--Tab-minHeight' as any]: '32px', ['--Tab-iconSpacing' as any]: '2px', }, loose: { + ['--Tabs-minHeight' as any]: '72px', ['--Tab-padBlock' as any]: '20px', ['--Tab-padInline' as any]: '32px', ['--Tab-minHeight' as any]: '72px', diff --git a/docs/pages/experiments/density-showcase.tsx b/docs/pages/experiments/density-showcase.tsx new file mode 100644 index 00000000000000..53f5df81fbcb07 --- /dev/null +++ b/docs/pages/experiments/density-showcase.tsx @@ -0,0 +1,258 @@ +'use client'; +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Divider from '@mui/material/Divider'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Accordion from '@mui/material/Accordion'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import CssBaseline from '@mui/material/CssBaseline'; +import { createTheme, ThemeProvider, enhanceDensity, DensityScale } from '@mui/material/styles'; +import demos from 'docs/src/modules/components/densityDemos'; + +// Client-facing showcase for the CSS-var density adapter (docs/adr/0001). +// Three presets each map to one `enhanceDensity(theme, { scale })` call — the +// only knob is the 7-step density scale. Flip a preset -> the whole gallery +// reflows because every component pulls its sized tokens from `--mui-density-*`. + +type PresetKey = 'compact' | 'normal' | 'comfort'; + +// `normal` = enhanceDensity defaults (theme.spacing) -> pixel-identical to today. +// compact/comfort override every step explicitly. +const presetScales: Record | undefined> = { + compact: { + xxs: '2px', + xs: '4px', + sm: '6px', + md: '8px', + lg: '12px', + xl: '18px', + xxl: '24px', + }, + normal: undefined, + comfort: { + xxs: '6px', + xs: '8px', + sm: '12px', + md: '16px', + lg: '24px', + xl: '32px', + xxl: '40px', + }, +}; + +const presetLabels: Record = { + compact: 'Compact', + normal: 'Normal', + comfort: 'Comfort', +}; + +const scaleKeys: (keyof DensityScale)[] = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl']; + +// Build the three enhanced themes once. +const baseTheme = createTheme({ cssVariables: true }); +const themes: Record> = { + compact: enhanceDensity(createTheme({ cssVariables: true }), { scale: presetScales.compact }), + normal: enhanceDensity(createTheme({ cssVariables: true })), + comfort: enhanceDensity(createTheme({ cssVariables: true }), { scale: presetScales.comfort }), +}; + +// Clean px readout for the scale. Under `cssVariables`, `theme.spacing()` returns +// a `calc(... var(--mui-spacing) ...)` string, so for the Normal preset we read +// the resolved px from a non-css-var theme instead. +const displayScales: Record = { + compact: presetScales.compact as DensityScale, + normal: enhanceDensity(createTheme()).density, + comfort: presetScales.comfort as DensityScale, +}; + +// Pull the density-var mappings enhanceDensity injected per component. Each +// component's `styleOverrides.root` is `[originalRoot, { '--Component-*': ... }]`; +// scan every plain-object element for the top-level `--*` token entries. +type VarMap = Record; +function collectComponentVars(theme: ReturnType): Record { + const out: Record = {}; + const components = (theme.components ?? {}) as Record; + Object.keys(components).forEach((name) => { + if (name === 'MuiCssBaseline') { + return; + } + const root = components[name]?.styleOverrides?.root; + const elements = Array.isArray(root) ? root : [root]; + const vars: VarMap = {}; + elements.forEach((el) => { + if (!el || typeof el !== 'object') { + return; + } + Object.keys(el).forEach((key) => { + if (key.startsWith('--')) { + vars[key] = String(el[key]); + } + }); + }); + if (Object.keys(vars).length > 0) { + out[name.replace(/^Mui/, '')] = vars; + } + }); + return out; +} + +const componentVarsByPreset: Record> = { + compact: collectComponentVars(themes.compact), + normal: collectComponentVars(themes.normal), + comfort: collectComponentVars(themes.comfort), +}; + +const mono = { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontSize: 12, +} as const; + +function ScalePanel({ preset }: { preset: PresetKey }) { + const scale = displayScales[preset]; + return ( +
+ + Density scale + + + {scaleKeys.map((key) => ( + + {`--mui-density-${key}`} + {scale[key]} + + ))} + +
+ ); +} + +function VarsPanel({ preset }: { preset: PresetKey }) { + const byComponent = componentVarsByPreset[preset]; + const names = Object.keys(byComponent); + return ( +
+ + Component tokens ({names.length}) + + + {names.map((name) => ( + + } sx={{ minHeight: 36, px: 0 }}> + {name} + + + + {Object.entries(byComponent[name]).map(([key, value]) => ( + + + {key} + + {': '} + + {value} + + + ))} + + + + ))} + +
+ ); +} + +export default function DensityShowcase() { + const [preset, setPreset] = React.useState('normal'); + + return ( + // Outer shell uses the base theme; the gallery + sidebar readouts use the + // enhanced theme so they reflect the active preset. + + + + + Density presets + + + One scale drives every component. Normal is pixel-identical to today. + + next && setPreset(next)} + sx={{ mb: 2 }} + > + {(Object.keys(presetLabels) as PresetKey[]).map((key) => ( + + {presetLabels[key]} + + ))} + + + + + + + + + + + {Object.keys(demos).map((name) => ( + + + {name} + + {demos[name]} + + ))} + + + + + + ); +} diff --git a/docs/src/modules/components/densityDemos.tsx b/docs/src/modules/components/densityDemos.tsx new file mode 100644 index 00000000000000..5ab114a93be066 --- /dev/null +++ b/docs/src/modules/components/densityDemos.tsx @@ -0,0 +1,642 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import TextField from '@mui/material/TextField'; +import InputAdornment from '@mui/material/InputAdornment'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import Chip from '@mui/material/Chip'; +import Avatar from '@mui/material/Avatar'; +import IconButton from '@mui/material/IconButton'; +import MenuItem from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import ListSubheader from '@mui/material/ListSubheader'; +import Toolbar from '@mui/material/Toolbar'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableRow from '@mui/material/TableRow'; +import TablePagination from '@mui/material/TablePagination'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardActions from '@mui/material/CardActions'; +import Select from '@mui/material/Select'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Link from '@mui/material/Link'; +import Badge from '@mui/material/Badge'; +import Checkbox from '@mui/material/Checkbox'; +import Radio from '@mui/material/Radio'; +import Switch from '@mui/material/Switch'; +import Typography from '@mui/material/Typography'; +import FaceIcon from '@mui/icons-material/Face'; +import DeleteIcon from '@mui/icons-material/Delete'; +import InboxIcon from '@mui/icons-material/Inbox'; +import DraftsIcon from '@mui/icons-material/Drafts'; +import FavoriteIcon from '@mui/icons-material/Favorite'; +import VisibilityIcon from '@mui/icons-material/Visibility'; + +// Shared density demo matrices for the CSS-var density adapter (docs/adr/0001). +// Consumed by both the screenshot fixture (density-fixture) and the client +// showcase (density-showcase). Each entry renders one component's load-bearing +// size/variant matrix. `level=default` (no token overrides) must stay +// pixel-identical to the pre-change baseline. +const demos: Record = { + Button: ( + + {(['small', 'medium', 'large'] as const).map((size) => ( + + + + + + ))} + + ), + OutlinedInput: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + @} + /> + + ))} + + ), + TextField: ( + + {(['medium', 'small'] as const).map((size) => ( + + {`outlined ${size}`} + + + ))} + + $, + endAdornment: kg, + }, + }} + /> + + + ), + Chip: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + A} /> + } /> + {}} /> + {}} + icon={} + /> + + ))} + + ), + IconButton: ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + MenuItem: ( + // MenuItem requires a MenuList/Menu ancestor (MenuListContext). + + Default item + Selected item + + + + + With icon + + With divider + No gutters + Dense item + + + + + Dense + icon + + + Dense no gutters + + + ), + ListItem: ( + + + Default item + + + + } + > + With secondary action + + Divider item + + + + + + Dense item + + + + } + > + Dense with action + + Dense, no gutters + + + ), + ListItemButton: ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + ListItemIcon: ( + + + + + + + + + + + + + + + + + + + + + + + + + ), + ListItemText: ( + + {([false, true] as const).map((dense) => ( + + + + + + + + + + + + + + + ))} + + ), + ListSubheader: ( + + + Gutters (default) + + + Inset + + + Disable gutters + + + Primary color + + + ), + Toolbar: ( + + {(['regular', 'dense'] as const).map((variant) => ( + + + {variant} gutters + action + + + {variant} no-gutters + action + + + ))} + + ), + Tab: ( + // Tab requires a Tabs ancestor (RovingTabIndexContext). + + {}}> + + + + + {}}> + } label="Top" iconPosition="top" value={0} /> + } label="Bottom" iconPosition="bottom" value={1} /> + } label="Start" iconPosition="start" value={2} /> + } label="End" iconPosition="end" value={3} /> + + {}}> + } aria-label="icon only" value={0} /> + + + + ), + Tabs: ( + + + + + + + + } label="Top" iconPosition="top" /> + } label="Bottom" iconPosition="bottom" /> + } label="Start" iconPosition="start" /> + } label="End" iconPosition="end" /> + + + } aria-label="fav" /> + } aria-label="del" /> + + + ), + TablePagination: ( + + + + + {}} + onRowsPerPageChange={() => {}} + /> + + + {}} + onRowsPerPageChange={() => {}} + /> + + + {}} + onRowsPerPageChange={() => {}} + /> + + +
+
+ ), + CardContent: ( + + + + Default + All-sides padding via --CardContent-pad. + + + + + Last-child + + Extra bottom inset (--CardContent-padBottom) since this is the last child. + + + + + + Above actions + Not last child -> base pad only on the bottom. + + + + + + + ), + Select: ( + + + + + + Age + + + + + + + ), + Breadcrumbs: ( + + + + Home + + + Catalog + + Shoes + + + + Home + + + Library + + + Data + + Reports + + + + Home + + + Catalog + + + Accessories + + Belts + + + ), + InputAdornment: ( + + {(['small', 'medium'] as const).map((size) => ( + + $ }, + }} + /> + + + + + + ), + }, + }} + /> + kg }, + }} + /> + kg }, + }} + /> + + ))} + + ), + Badge: ( + + + + + + + + + + + + + + + + + + ), + Checkbox: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + + + + + + ))} + + ), + Radio: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + + + + + ))} + + ), + Switch: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + + + + + ))} + + ), +}; + +export default demos; From 95ab46b384d309a06c6a70a4b4b310ae17afb3fb Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 8 Jun 2026 11:54:05 +0700 Subject: [PATCH 24/28] [material-ui] Chip: scale avatar/icon/deleteIcon with --Chip-height calc(var(--Chip-height) - inset) per size so they track density; insets reproduce today's medium/small sizes (pixel-identical default) --- packages/mui-material/src/Chip/Chip.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/mui-material/src/Chip/Chip.js b/packages/mui-material/src/Chip/Chip.js index e477ec00fb6508..ca3192f9809596 100644 --- a/packages/mui-material/src/Chip/Chip.js +++ b/packages/mui-material/src/Chip/Chip.js @@ -96,22 +96,27 @@ const ChipRoot = styled('div', { opacity: (theme.vars || theme).palette.action.disabledOpacity, pointerEvents: 'none', }, + // Density adapter (docs/adr/0001): avatar/icon/deleteIcon scale with the + // chip height. Each is `calc(var(--Chip-height) - inset)` where the inset + // reproduces today's medium size (height 32: avatar/icon 24, deleteIcon 22); + // the small variant overrides the inset for height 24. [`& .${chipClasses.avatar}`]: { marginLeft: 5, marginRight: -6, - width: 24, - height: 24, + width: 'calc(var(--Chip-height, var(--_height)) - 8px)', + height: 'calc(var(--Chip-height, var(--_height)) - 8px)', color: theme.vars ? theme.vars.palette.Chip.defaultAvatarColor : textColor, fontSize: theme.typography.pxToRem(12), }, [`& .${chipClasses.icon}`]: { marginLeft: 5, marginRight: -6, + fontSize: 'calc(var(--Chip-height, var(--_height)) - 8px)', }, [`& .${chipClasses.deleteIcon}`]: { WebkitTapHighlightColor: 'transparent', color: theme.alpha((theme.vars || theme).palette.text.primary, 0.26), - fontSize: 22, + fontSize: 'calc(var(--Chip-height, var(--_height)) - 10px)', cursor: 'pointer', margin: '0 5px 0 -6px', '&:hover': { @@ -158,17 +163,17 @@ const ChipRoot = styled('div', { [`& .${chipClasses.avatar}`]: { marginLeft: 4, marginRight: -4, - width: 18, - height: 18, + width: 'calc(var(--Chip-height, var(--_height)) - 6px)', + height: 'calc(var(--Chip-height, var(--_height)) - 6px)', fontSize: theme.typography.pxToRem(10), }, [`& .${chipClasses.icon}`]: { - fontSize: 18, + fontSize: 'calc(var(--Chip-height, var(--_height)) - 6px)', marginLeft: 4, marginRight: -4, }, [`& .${chipClasses.deleteIcon}`]: { - fontSize: 16, + fontSize: 'calc(var(--Chip-height, var(--_height)) - 8px)', marginRight: 4, marginLeft: -4, }, From 9ee251e90c37324a3c8a28fc109f68b5418cc7fa Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 8 Jun 2026 11:54:05 +0700 Subject: [PATCH 25/28] [docs] density demos: drop filled-variant inputs (no density support yet) --- docs/src/modules/components/densityDemos.tsx | 29 +------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/docs/src/modules/components/densityDemos.tsx b/docs/src/modules/components/densityDemos.tsx index 5ab114a93be066..865057f24dc78f 100644 --- a/docs/src/modules/components/densityDemos.tsx +++ b/docs/src/modules/components/densityDemos.tsx @@ -268,7 +268,7 @@ const demos: Record = { - + @@ -461,15 +461,6 @@ const demos: Record = { Ten Twenty - - - Age - - -