Skip to content

[WIP][prototype] density system#48749

Draft
siriwatknp wants to merge 87 commits into
mui:masterfrom
siriwatknp:exp/density-button-prototype
Draft

[WIP][prototype] density system#48749
siriwatknp wants to merge 87 commits into
mui:masterfrom
siriwatknp:exp/density-button-prototype

Conversation

@siriwatknp

@siriwatknp siriwatknp commented Jul 1, 2026

Copy link
Copy Markdown
Member

Part of #48746 (density RFC). Prototype / experiment — draft, not for merge. Scoped to Button only to keep the proposal reviewable; the mechanism generalizes to other families.

What this demonstrates

A holistic, opt-in density layer for @mui/material built entirely on CSS theme variables — no new density prop, zero cost when unused.

  • enhanceDensity(theme, options?) — emits a named --mui-density-* scale at :root and maps Button's sized tokens to steps via injected styleOverrides. Mirrors enhanceHighContrast; createTheme stays untouched.
  • Three-layer token model — public sized token (--Button-small-pad) → agnostic seam (--comp-pad) → internal default (--_pad). Unconfigured render falls back to the literal default, so it's pixel-identical to today (Argos zero-diff).
  • Button de-prefixed to Option A — static, unprefixed private_buttonVars map imported by both the styled component and enhanceDensity, so emitted and targeted names can't drift.
  • Experiment page /experiments/density-experiment — flip a preset (unset/compact/normal/comfort), and remap Button's internal tokens to density steps live. Element-level mapping wins over the preset — the editable view of the otherwise-internal layer. Component selector carries an All option (registry-driven) so more families can be added incrementally.
  • Local regression harnessscripts/density-screenshots/ (pnpm density:shot) asserts the default render is pixel-identical to a master baseline at maxDiffPixels: 0, plus dense/loose reflow captures.

Verification

  • Zero-diff gate: de-prefixed Button default render pixel-identical to master (maxDiffPixels: 0).
  • Computed-style: unset = literal defaults; compact/normal/comfort reflow every cell; element-level sx wins over the preset styleOverrides; unset emits no --mui-density-*.
  • @mui/material + docs typecheck, eslint, and Button.test.js (140 pass) all green.

Scope

  • Button only. The other ~22 families explored in earlier iterations are reverted here; rolling them out (and the full de-prefix) is follow-up work.
  • Production density scale values are settled internally (out of RFC scope).

🤖 Generated with Claude Code

siriwatknp added 30 commits June 5, 2026 12:41
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.
… + --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-<size>-pad) to density scale
…seam

- OutlinedInput: block-only density (--OutlinedInput-<size>-padBlock); root routes, input inherits; drop redundant size/multiline variants
- InputLabel: generic --InputLabel-y seam; OutlinedInput bridges sibling label via :has(~ &)
- Docs: ADR-0001 OutlinedInput + label :has bridge, CONTEXT, density-adapter-rollout guide, experiment demo
- density-fixture.tsx: per-component matrix scoped by ?c=&level (default pixel-identical)
- scripts/density-screenshots: config + spec + README (maxDiffPixels 0)
- density:shot / density:shot:update scripts; gitignore harness outputs
- 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
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-<size>-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.
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-<key> and only the dense
override as --Component-dense-<key>. Toolbar keeps theme.mixins.toolbar for its
regular height (only dense + gutters tokenized).
Boolean compactness toggles (dense) use a state token: default state is the
plain seam --Component-<key> (base-token-shaped, no base routing), only the on
state is qualified --Component-dense-<key>. 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.
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-<size>-pad into the seam; default 9px both sizes
(pixel-identical). Switch routes its thumb (SwitchBase) padding via --Switch-<size>-pad
(9/4); box geometry stays literal (size-coupled). enhanceDensity + fixture wired.
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 --_<key>-shadowing caveat. Added to CONTEXT
relationships, ADR 0001 specifics, rollout Recipe C + Done list.
Tokenize Switch's four real dims per size (--Switch-<size>-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.
…lues

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.
The root padding (12/7, track inset) is its own axis -> tokenize as
--Switch-<size>-pad over --_pad, consumed padding: var(--Switch-pad, var(--_pad)).
Distinct from the derived thumb SwitchBase pad. Fixture scope + docs updated.
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).
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.
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.
…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
- 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
calc(var(--Chip-height) - inset) per size so they track density; insets reproduce today's medium/small sizes (pixel-identical default)
Signed-off-by: Siriwat K <siriwatkunaporn@gmail.com>
Prefix density tokens, tracking the css-var feature: --mui-Button-pad with
cssVariables, bare --Button-pad without. One cached getButtonVars(theme) resolver
shared by component + consumer so emitted/targeted names can't drift; as-const key
map is the typed handle. Button + OutlinedInput + InputLabel converted;
enhanceDensity blocks guarded to match. No theme.vars, no getCssVar, no forced
prefix; custom-size inline routing dropped. Default render unchanged (Argos
zero-diff). Demo: /experiments/density-var-prefix.
Rename the agnostic seam off the component name and prefix: --Button-pad ->
literal --comp-pad, --OutlinedInput-pad{Block,Inline} -> --comp-pad{Block,Inline},
--InputLabel-y -> --comp-labelY. The seam is the bare root's consumption point, not
a design knob, so it stays literal/unprefixed and out of buttonVars. buttonVars now
holds only the Material UI layer's public sized tokens (which track the prefix).
Document the per-layer naming in ADR-0003.
…e granularity

Custom (theme-added) sizes are excluded from density (no inline routing) — the
seam is only set for built-in sizes, so the generic-seam inheritance case is a
documented non-goal, and we avoid prefixing turning the free inline custom-size
string into a theme-dependent read. Record that as a concrete cost of prefixing.
siriwatknp added 30 commits July 1, 2026 18:43
Read raw-px sizing tokens (minHeight/denseMinHeight) live off the enhanced
theme's MuiMenuItem override instead of leaving them empty; re-sync the
mapping when the preset changes. Single source of truth = enhanceDensity,
no drift. Spacing fields keep their density-key prefill.
Fixed-viewport shell. Move enhanceDensity preset (horizontal radios) and
Visual debug toggles into a full-width top bar (preset left, debug right).
Sidebar = fixed Component selector + independently scrollable Vars mapping.
Canvas scrolls independently. Title+subtitle shrunk to one compact row.
…/comfort)

Display-only; 'unset' key unchanged.
…) + wire 3 presets

- 3-layer seams on `tooltip` slot (no root slot); arrow width/protrusion/insets derive from one --comp-arrowSize, 0.71 literal kept
- tooltipVars.ts camelCase, re-export index.js/.d.ts
- presets: spacing->steps (blockPad xxs, inlinePad sm, offset lg), arrowSize->raw px (sizing, spacing-only policy)
- addRootOverride gains optional slot param (default root); themeTokenValue scans all slots
- playground Tooltip entry + padding-ring/text-box debug on .MuiTooltip-tooltip; fixture demo
- zero-diff maxDiffPixels:0 vs baseline; reflow verified via getComputedStyle; Tooltip.test 182 pass (RTL offset assertion tokenized)
…nputAdornment gap

- OutlinedInput input block/inline padding + root adornment/multiline gutters, per size (base medium / small variant)
- InputLabel outlined resting-Y consumes --InputLabel-y; OutlinedInput sets it via :has(~&) from the blockPad token (±0.5), presets from the density step (siblings don't inherit)
- InputAdornment gap + filled marginTop, per size; small size variant added
- vars maps outlinedInputVars/inputLabelVars/inputAdornmentVars (re-export js+d.ts)
- addRootOverride overrides typed Record<string,unknown> (nested selectors + variants); presets wire MuiOutlinedInput (+size variant label rule) + MuiInputAdornment
- playground OutlinedInput entry (mapping on TextField ancestor so bridge sees it); fixture demo + scopes
- zero-diff maxDiffPixels:0; reflow verified via getComputedStyle (label-Y tracks blockPad ±0.5 every preset); 140 tests pass
- single gap token both adornment sides (not leading/trailing split); --comp-* seam; blockPad/inlinePad naming
…l bridge

- FilledInput input + root top/bottom/inline padding, per size (base medium / small variant); hiddenLabel keeps literals (out of scope)
- InputLabel filled resting+shrunk transforms consume --FilledInputLabel-restY/-shrinkY (fallback = today literals); identity in inputLabelVars (filledRestY/filledShrinkY)
- no clean topPad->Y formula; presets set label Ys as tuned raw px per size via :has(~&)+variants
- filledInputVars (medium/small x topPad/bottomPad/inlinePad); re-export js+d.ts
- playground FilledInput entry; fixture demo + scopes
- zero-diff maxDiffPixels:0; reflow via getComputedStyle (box + label track); 92 tests pass
…andard slot

- tokenize on Input's InputInput (was empty {}), not the shared InputBase baseline, so the literal stays the zero-diff fallback for Filled/Outlined
- topPad per size (base medium / small variant), bottomPad shared; inline 0; multiline resets to 0
- inputVars (mediumTopPad/smallTopPad/bottomPad); re-export js+d.ts; presets map mediumTopPad->xs, smallTopPad->xxs, bottomPad->xs
- root marginTop:16 label reserve untouched (typography, out of scope)
- playground Input entry; fixture demo + scopes
- zero-diff maxDiffPixels:0; reflow via getComputedStyle; 38 tests pass
- completes TextField's 3 input slices (CS-19)
…minHeight pairing

- Tab default block/inline pad + minHeight; icon+label state reroutes its own block-pad + minHeight tokens (base decls re-resolve via --comp-* seam)
- icon gaps split stack (top/bottom) vs inline (start/end); Tabs root minHeight
- tabVars + tabsVars; re-export js+d.ts
- presets: spacing->steps; minHeights->raw px with MuiTabs.minHeight == MuiTab.minHeight per preset (40/48/56, icon+label 60/72/84)
- playground Tabs entry; fixture demo + scopes; Tab icon-gap test -> skipIf(isJsdom)
- zero-diff maxDiffPixels:0; reflow + pairing (Tabs.minHeight===Tab.minHeight every preset) verified via getComputedStyle; 300 tests pass
…e from Checkbox

- SwitchBase padding: 9 -> var(--comp-pad, var(--_pad)) (--_pad 9px); shared, so Radio/Switch fall back to 9px unrouted
- Checkbox routes per-size public token into --comp-pad via size medium/small variants
- checkboxVars (mediumPad/smallPad); re-export js+d.ts; presets map mediumPad->sm, smallPad->xs
- playground Checkbox entry; fixture demo + scopes
- zero-diff maxDiffPixels:0 (incl. Switch/Radio unaffected); reflow via getComputedStyle; 245 tests pass
- CardContent padding + &:last-child paddingBottom over --comp-* seams (no size axis)
- cardContentVars (pad/padBottom); re-export js+d.ts; presets map pad->lg, padBottom->xl
- playground CardContent entry; fixture demo + scopes
- zero-diff maxDiffPixels:0; reflow via getComputedStyle; normal == today (16/24)
- SelectInput minHeight 1.4375em -> var(--comp-minHeight, var(--_minHeight)); selectVars (minHeight); re-export js+d.ts
- presets map minHeight to raw px (20/23/28); floor is typographic + mostly inert (visible density comes from the input variant)
- playground Select entry; fixture demo + scopes; dedupe playground Select import
- zero-diff maxDiffPixels:0; 336 Select tests pass
- Alert root block/inline padding + AlertIcon marginRight (--comp-iconGap) over --comp-* seams (no size axis)
- alertVars (blockPad/inlinePad/iconGap); re-export js+d.ts; presets map blockPad->xs, inlinePad->lg, iconGap->md
- icon/message vertical alignment paddings left literal (row alignment, not density)
- playground Alert entry; fixture demo + scopes
- zero-diff maxDiffPixels:0; reflow via getComputedStyle; normal == today (6/16/12); 128 tests pass
…nline padding

- Chip height per size (--comp-height, raw px); avatar/icon/deleteIcon derive via calc(height - inset) so they scale with height
- label inline padding per size (--comp-padInline) with per-(variant,size) --_padInline defaults + size routing
- chipVars (mediumHeight/smallHeight/mediumPadInline/smallPadInline); re-export js+d.ts
- presets map height->raw px (28/32/36, 20/24/28), padInline->md/sm
- playground Chip entry; fixture demo + scopes
- zero-diff maxDiffPixels:0; reflow via getComputedStyle (avatar tracks height); normal == today; 162 tests pass
…adding

- AccordionSummary collapsed/expanded minHeight (raw px) + inline pad + content block margin (reduces with minHeight so it doesn't bind the header)
- AccordionDetails top/inline/bottom padding
- accordionSummaryVars + accordionDetailsVars; re-export js+d.ts
- presets: minHeights raw px (40/48/56, 52/64/76); spacing steps (inlinePad/marginBlock/details)
- playground Accordion entry; fixture demo + scopes
- zero-diff maxDiffPixels:0; reflow via getComputedStyle; normal == today (48/64, 8/16/16); 91 tests pass
…am (mirrors Checkbox)

- Radio size medium/small variants route --comp-pad; radioVars (mediumPad/smallPad); re-export js+d.ts
- presets map mediumPad->sm, smallPad->xs
- playground Radio entry (dedupe Radio import); fixture demo + scopes
- zero-diff maxDiffPixels:0; reflow identical to Checkbox
- separator marginLeft/Right 8 -> var(--comp-separatorGap, var(--_separatorGap)); breadcrumbsVars (separatorGap); re-export js+d.ts
- presets map separatorGap->sm (normal 8 == today)
- playground Breadcrumbs entry; fixture demo + scopes
- zero-diff maxDiffPixels:0
- padding 11/7/15 -> var(--comp-pad, var(--_pad)) (base medium, small/large variants reroute); toggleButtonVars; re-export js+d.ts
- presets map smallPad->sm, mediumPad->md, largePad->lg
- playground ToggleButton entry; fixture demo + scopes
- zero-diff maxDiffPixels:0
- width/height 40 -> var(--comp-size, var(--_size)); avatarVars (size); re-export js+d.ts
- presets map size raw px 32/40/48 (normal == today); fontSize left literal (typography)
- playground Avatar entry; fixture demo + scopes
- zero-diff maxDiffPixels:0
- standard base + dot variant: minWidth/height + padding via --comp-size/--comp-pad; badgeVars; re-export js+d.ts
- presets: sizes raw px (standard 18/20/24, dot 4/6/8), standardPad '0 <xs>' (normal == today)
- playground Badge entry; fixture demo + scopes
- zero-diff maxDiffPixels:0
- grouped minWidth 40 -> var(--comp-minWidth, var(--_minWidth)); buttonGroupVars; re-export js+d.ts
- presets map minWidth raw px 32/40/48 (normal == today)
- playground ButtonGroup entry; fixture demo + scopes
- zero-diff maxDiffPixels:0
…ared inline)

- TableCell block pad per size (medium base / small variant) + inline pad via --comp-blockPad/--comp-inlinePad; tableCellVars; re-export js+d.ts
- presets map mediumBlockPad->lg, smallBlockPad->xs, inlinePad->lg (normal 16/6/16 == today)
- checkbox/none affordances left literal
- playground TableCell entry; fixture demo + scopes
- zero-diff maxDiffPixels:0
- option minHeight (raw px, ≥sm auto via seam fallback) + block/inline pad via --comp-*; autocompleteVars; re-export js+d.ts
- presets map optionMinHeight 36/44/56, blockPad->xs, inlinePad->lg (normal 6/16 == today)
- input density comes from its variant (already tokenized)
- playground Autocomplete entry (open inline); fixture demo + scopes
- zero-diff maxDiffPixels:0
- Step horizontal paddingLeft/Right 8 -> --comp-inlinePad; StepLabel icon-container paddingRight 8 -> --comp-iconGap
- stepVars (inlinePad) + stepLabelVars (iconGap); re-export js+d.ts; presets map both ->sm (normal 8 == today)
- position overrides + connector left literal
- playground Stepper entry; fixture demo + scopes
- zero-diff maxDiffPixels:0
- Toolbar gutter inline pad (16 base / 24 >=sm, rerouted at breakpoint) -> --comp-inlinePad; dense minHeight 48 -> --comp-minHeight
- toolbarVars (inlinePad/wideInlinePad/denseMinHeight); re-export js+d.ts
- presets map inlinePad->lg, wideInlinePad->xl (normal 16/24 == today), denseMinHeight raw px 40/48/56
- regular height stays theme.mixins.toolbar (fixed chrome)
- playground Toolbar entry; fixture demo + scopes
- zero-diff maxDiffPixels:0
- width/height per size (base large 56, small 40, medium 48) -> --comp-size; fabVars; re-export js+d.ts
- presets map raw px 36/44/52 . 40/48/56 . 44/52/64 (normal == today); extended variant left literal
- playground Fab entry; fixture demo + scopes
- zero-diff maxDiffPixels:0
- PaginationItem page + ellipsis minWidth/height per size -> --comp-size (shared so they stay aligned); paginationItemVars; re-export js+d.ts
- presets map raw px 22/28/36 . 26/32/40 . 30/36/44 (normal == today); padding/margin/radius left literal
- playground Pagination entry; fixture demo + scopes
- zero-diff maxDiffPixels:0
- SnackbarContent root block/inline padding 6px 16px -> --comp-*; snackbarContentVars; re-export js+d.ts
- presets map blockPad->xs, inlinePad->lg (normal 6/16 == today)
- playground SnackbarContent entry; fixture demo + scopes
- zero-diff maxDiffPixels:0
- BottomNavigation height 56 -> --comp-height (raw px); BottomNavigationAction padding 0 12px -> --comp-inlinePad
- bottomNavigationVars + bottomNavigationActionVars; re-export js+d.ts; presets height 44/56/64, inlinePad->md (normal == today)
- playground BottomNavigation entry; fixture demo + scopes
- zero-diff maxDiffPixels:0
- DialogTitle (16x24) + DialogContent (20x24) block/inline pad + DialogActions (8) pad, each over --comp-*
- dialogTitleVars/dialogContentVars/dialogActionsVars; re-export js+d.ts
- presets: title lg/xl, content lg/xl, actions sm (normal == today except content block 16 vs 20, flagged)
- dividers variant + paddingTop-0 reset + action gap left literal
- playground Dialog entry (Paper surface, no modal); fixture demo + scopes
- zero-diff maxDiffPixels:0
- ListItemButton block pad (8, dense 4) + gutters inline pad (16) over --comp-* (mirrors MenuItem dense axis)
- listItemButtonVars (blockPad/denseBlockPad/inlinePad); re-export js+d.ts; presets map blockPad->sm, denseBlockPad->xxs, inlinePad->lg (normal == today)
- List blockPad already tokenized (Menu pass); plain ListItem insets left literal
- playground ListItemButton entry (dedupe List import); fixture demo + scopes
- zero-diff maxDiffPixels:0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

RFC Request For Comments.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant