Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a1c29ff
[material-ui] spacing-derive Button + outlined input dimensions
siriwatknp May 27, 2026
cb86d3e
[docs] add spacing-derivation rollout plan + local screenshot harness
siriwatknp May 27, 2026
4ca7454
[material-ui] spacing-derive FilledInput + filled input label
siriwatknp May 27, 2026
e3b63b9
[docs] spacing harness outputDir fix + rollout learnings
siriwatknp May 27, 2026
e59d8f5
[material-ui] spacing-derive InputBase (standard input)
siriwatknp May 27, 2026
55e7aaf
[material-ui] spacing-derive Input standard label-gap
siriwatknp May 27, 2026
1706e49
[material-ui] spacing-derive InputAdornment + skip Select
siriwatknp May 27, 2026
8f4de2f
[material-ui] spacing-derive InputLabel standard transforms
siriwatknp May 27, 2026
5f6ed70
[material-ui] spacing-derive FormControl + FormControlLabel; finish f…
siriwatknp May 27, 2026
52ed763
[material-ui] spacing-derive IconButton; skip ButtonBase
siriwatknp May 27, 2026
6dc5e4a
[material-ui] spacing-derive Fab extended padding; skip ButtonGroup +…
siriwatknp May 27, 2026
f7dbd67
[material-ui] spacing-derive ToggleButton + Tab; skip ToggleButtonGroup
siriwatknp May 27, 2026
caa815c
[material-ui] spacing-derive BottomNavigationAction, SpeedDialAction,…
siriwatknp May 27, 2026
6301a0d
[material-ui] spacing-derive List family + MenuItem (vertical only)
siriwatknp May 27, 2026
6a2ba05
[material-ui] spacing-derive Autocomplete (vertical only); finish lis…
siriwatknp May 27, 2026
952d630
[material-ui] spacing-derive Accordion, Alert, Dialog content parts
siriwatknp May 27, 2026
a1b64f8
[material-ui] spacing-derive Card family, SnackbarContent, Tooltip, B…
siriwatknp May 27, 2026
eccbb5d
[material-ui] spacing-derive Stepper family (vertical/gap + button pa…
siriwatknp May 27, 2026
3110d48
[material-ui] spacing-derive TableCell, TablePagination, TableSortLabel
siriwatknp May 27, 2026
92ce74c
[material-ui] spacing-derive Badge + ImageListItemBar; finish misc la…
siriwatknp May 27, 2026
d734e5c
[docs] complete spacing-derived rollout: audit geometry group + summary
siriwatknp May 27, 2026
606acf6
[docs] add iteration-2 backlog: every skip + partial-skip with what's…
siriwatknp May 27, 2026
581a63e
[material-ui][iter-2] FormControlLabel -11 -> calc(spacing(-1) - 3px)
siriwatknp May 28, 2026
2785958
[material-ui][iter-2] Chip: full coupled spacing-derivation
siriwatknp May 28, 2026
ebda269
[material-ui][iter-2] List family gutter padding 16 -> spacing(2)
siriwatknp May 28, 2026
c83b99b
[material-ui][iter-2] Chip avatar/icon: +2px-per-spacing-unit step
siriwatknp May 28, 2026
b69e29b
[material-ui][iter-2][docs] document 'N as step coefficient' principle
siriwatknp May 28, 2026
62ece90
[material-ui][iter-2] SwitchBase padding 9 -> calc(spacing(1) + 1px)
siriwatknp May 28, 2026
f1fa0f9
add density selector
siriwatknp May 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,8 @@ test-results/
# typescript
*.tsbuildinfo
next-env.d.ts

# spacing-derivation review screenshots (local verification harness)
/spacing-screenshots/
/scripts/spacing-screenshots/__baselines__/
/scripts/spacing-screenshots/.playwright-output/
68 changes: 68 additions & 0 deletions docs/adr/0001-spacing-derived-component-dimensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Component dimensions are spacing-derived

Component dimension values (padding, label offsets) are expressed as
`calc(theme.spacing(N) ± <px>)` with an **integer** `N`, instead of hardcoded
pixels — so they ride the `--mui-spacing` runtime variable for density, while
rendering pixel-identically to the previous hardcoded values at the default
theme.

## Context

Material UI hardcodes component paddings as pixels (`padding: '6px 16px'`,

Check failure on line 11 in docs/adr/0001-spacing-derived-component-dimensions.md

View workflow job for this annotation

GitHub Actions / test-dev (ubuntu-latest)

[vale] reported by reviewdog 🐶 [MUI.MuiBrandName] Use a non-breaking space (option+space on Mac, Alt+0160 on Windows or AltGr+Space on Linux, instead of space) for brand name ('Material UI' instead of 'Material UI') Raw Output: {"message": "[MUI.MuiBrandName] Use a non-breaking space (option+space on Mac, Alt+0160 on Windows or AltGr+Space on Linux, instead of space) for brand name ('Material UI' instead of 'Material UI')", "location": {"path": "docs/adr/0001-spacing-derived-component-dimensions.md", "range": {"start": {"line": 11, "column": 1}}}, "severity": "ERROR"}
`'16.5px 14px'`, …). Under `createTheme({ cssVariables: true })`,
`theme.spacing(n)` compiles to `calc(n * var(--mui-spacing))`, so overriding
`--mui-spacing` at any scope reflows everything spacing-derived. But components
opt out of that dial wherever they use literal px — density stops at the
component edge.

We want components to follow `--mui-spacing` without:

Check warning on line 18 in docs/adr/0001-spacing-derived-component-dimensions.md

View workflow job for this annotation

GitHub Actions / test-dev (ubuntu-latest)

[vale] reported by reviewdog 🐶 [Google.We] Try to avoid using first-person plural like 'We'. Raw Output: {"message": "[Google.We] Try to avoid using first-person plural like 'We'.", "location": {"path": "docs/adr/0001-spacing-derived-component-dimensions.md", "range": {"start": {"line": 18, "column": 1}}}, "severity": "WARNING"}

- any visual change at the default theme (Argos must be a zero-diff), and
- introducing new public/agnostic CSS variables (that is a separate effort).

The spec paddings are not clean 8px multiples (`6`, `5`, `15`, `16.5`, …), so a
naive `theme.spacing(value/8)` yields ugly fractional multipliers
(`spacing(1.875)`) that are hard to read and debug.

## Decision

Express every **spacing** dimension as `calc(theme.spacing(N) ± offsetpx)`:

- **N = `round(px / 8)`** — the nearest whole unit. Integer multipliers stay
legible (`spacing(2)` reads as "2 units") and debuggable.
- **offset** = the px needed to land on the exact original value, so the
default theme (`--mui-spacing: 8px`) is pixel-identical. When the offset is
`0`, drop the `calc` and use `theme.spacing(N)` directly.
- **Outlined** input/button block = the contained expression **− 1px**;
the `−1` is the border compensation (`6 → 5`).
- **Vertical axis only where horizontal couples to fixed sibling geometry.**
The outlined input's notch (NotchedOutline `<legend>`) sits at a fixed left
inset; if the input's **inline** padding or the label's **transformX** scaled
with density, the value text, the floating label, and the notch gap would
desync — a visibly broken outline. So for OutlinedInput and the outlined
InputLabel, only the **block / y-axis** is spacing-derived; inline padding,
adornment paddings, and `transformX` stay literal. Components with **no** such
coupling (Button) keep both axes spacing-derived.
- **The outlined InputLabel resting y-offset tracks the input's block padding**
(so the label stays vertically aligned at any density). The _shrunk_ transform
stays fully literal — when floated, the label sits on the border line, which
padding does not move.

**Not** spacing, left untouched: the `1.4375em` line-box (font-size-coupled),
`border-radius`, `border-width`, `scale()` factors, the NotchedOutline `8px`
notch, and — per above — horizontal (inline/x) values on notch-coupled
components.

## Consequences

- Overriding `--mui-spacing` at a scope reflows Button padding and outlined
input height/label together — density with one dial, no per-component knobs.
- Pixel-identical at the default theme; Argos is the acceptance gate
(real browsers resolve `calc`; jsdom does not, so density assertions belong
in browser/visual tests, not jsdom unit tests).
- Conversion is mechanical and component-by-component, so there is a temporary
window where converted components (Button, OutlinedInput) follow the dial and
unconverted ones (FilledInput, standard Input, Select, …) do not. The
replication spec exists to close that window consistently — see
[spacing-derived-dimensions-spec.md](./spacing-derived-dimensions-spec.md).
- No new public API: `--variant-*` and other private vars are untouched.
193 changes: 193 additions & 0 deletions docs/adr/spacing-derived-dimensions-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# Spacing-derived dimensions — replication spec

How to convert a component's hardcoded px dimensions to spacing-derived values.
Follow this for the next set (FilledInput, standard `Input`, `Select`,
multiline, …). Decision + rationale: [0001](./0001-spacing-derived-component-dimensions.md).

## The rule

For each **spacing** px value `P` (padding / margin / gap / label offset):

1. `N = round(P / 8)` — nearest whole unit (integer).
2. `offset = P − 8 * N`.
3. Write `` `calc(${theme.spacing(N)} - <|offset|>px)` `` (or `+`) when
`offset !== 0`; otherwise `` `${theme.spacing(N)}` `` (no `calc`).
4. Verify it resolves to `P` at the default (`spacing(1) = 8px`).

Relationship rules:

- **Outlined = contained − 1px** (border compensation), e.g. Button outlined

Check failure on line 19 in docs/adr/spacing-derived-dimensions-spec.md

View workflow job for this annotation

GitHub Actions / test-dev (ubuntu-latest)

[vale] reported by reviewdog 🐶 [MUI.GoogleLatin] Use 'for example' instead of 'e.g.' Raw Output: {"message": "[MUI.GoogleLatin] Use 'for example' instead of 'e.g.'", "location": {"path": "docs/adr/spacing-derived-dimensions-spec.md", "range": {"start": {"line": 19, "column": 57}}}, "severity": "ERROR"}

Check failure on line 19 in docs/adr/spacing-derived-dimensions-spec.md

View workflow job for this annotation

GitHub Actions / test-dev (ubuntu-latest)

[vale] reported by reviewdog 🐶 [MUI.CorrectRererenceCased] Use 'e.g.' instead of 'e.g' Raw Output: {"message": "[MUI.CorrectRererenceCased] Use 'e.g.' instead of 'e.g'", "location": {"path": "docs/adr/spacing-derived-dimensions-spec.md", "range": {"start": {"line": 19, "column": 57}}}, "severity": "ERROR"}
block `5 = 6 − 1`.
- **Horizontal stays literal where x is anchored.** Keep inline padding,
adornment paddings, and label `transformX` **literal** whenever something is
horizontally anchored to the input's inline padding. The outlined notch
(`<legend>`, fixed left inset) is the obvious break — but the **filled and
standard labels also align their x to inline padding**, so keep their x
literal too. For inputs and their labels, only the **block / y-axis** derives.
Components with no anchored horizontal relationship (Button) derive **both**.
- **Floating-label y tracks the input — but the shrunk state splits by where it
floats:**
- **resting** y → tracks the input's block padding (the value position).
- **shrunk** y → **literal if it floats onto a border** (outlined: the floated
label sits on the border line, which padding doesn't move). **Tracks the
input if it floats into reserved padding** (filled: into the top padding).
Filled shrunk is the tricky one — tracking one unit while `paddingTop` tracks
three leaves a growing gap at high density; tune the coefficient and **verify
with a valued/focused field** (an empty field only shows the resting label).

Leave untouched (not spacing): `em` line-boxes, `border-radius`,
`border-width`, `scale()`, NotchedOutline notch padding, and horizontal
(inline/x) values on inputs + their labels.

## Choosing `N` — the step coefficient

Once a value is written as `calc(<literal> + theme.spacing(N))`, **N is the
step coefficient**: each 1px change in `--mui-spacing` shifts that value by
N px. So N is not just "the closest multiple of 8" — it's a design knob for
how fast that element scales with density.

**Rule of thumb: N reflects the size/role of the element.**

- **Larger / container-like elements → larger N.** A medium-sized container
(Button padding, Chip height) typically uses `N = 2` (+2px per spacing unit),
so the container visibly responds to density.
- **Smaller / inner elements → smaller N.** Inner glyphs inside a container
(Chip avatar/icon/deleteIcon `width`/`height`/`fontSize`) typically use `N = 1`
(+1px per spacing unit), so they scale gently and don't outgrow their parent.
- **The result:** at high density the container grows faster than its contents,
so inner gaps grow rather than shrink (avatars don't overflow); at low density
the container shrinks faster than its contents, but a separate **min-clamp**
prevents inner offsets from going negative — see below.

Choosing N this way trades two competing goals:

1. **Default fidelity** — `calc(<literal> + spacing(N))` must equal the original
px at `--mui-spacing = 8` (the math constraint).
2. **Density behavior** — at non-default densities, smaller N keeps proportions
tighter, larger N makes the element more density-responsive.

Pick the smallest N that still gives meaningful response. For sub-unit values
(`< 4px`) that are **load-bearing in a coupled system** (e.g. Chip's `5/-6/4`

Check failure on line 70 in docs/adr/spacing-derived-dimensions-spec.md

View workflow job for this annotation

GitHub Actions / test-dev (ubuntu-latest)

[vale] reported by reviewdog 🐶 [MUI.GoogleLatin] Use 'for example' instead of 'e.g.' Raw Output: {"message": "[MUI.GoogleLatin] Use 'for example' instead of 'e.g.'", "location": {"path": "docs/adr/spacing-derived-dimensions-spec.md", "range": {"start": {"line": 70, "column": 58}}}, "severity": "ERROR"}

Check failure on line 70 in docs/adr/spacing-derived-dimensions-spec.md

View workflow job for this annotation

GitHub Actions / test-dev (ubuntu-latest)

[vale] reported by reviewdog 🐶 [MUI.CorrectRererenceCased] Use 'e.g.' instead of 'e.g' Raw Output: {"message": "[MUI.CorrectRererenceCased] Use 'e.g.' instead of 'e.g'", "location": {"path": "docs/adr/spacing-derived-dimensions-spec.md", "range": {"start": {"line": 70, "column": 58}}}, "severity": "ERROR"}
offsets), prefer `N = 1` with a px offset (`calc(spacing(1) - 3px)`) — this
keeps the value scaling at the gentlest rate, matching the surrounding inner
elements.

### Clamp inner-margin negatives at `0`

Compensation negatives like `calc(spacing(1) - 3px)` go _negative_ when
`--mui-spacing < 3` (would pull the inner element past the container edge).
Wrap them in `max(…, 0px)` to clamp:

```js
marginLeft: `max(calc(${theme.spacing(1)} - 3px), 0px)`;
```

At default the `max()` is a no-op (5px stays 5px). At ultra-low density it
prevents the negative shift. Use this for inner-positive offsets that must
not go negative (Chip avatar/icon `marginLeft`, deleteIcon `marginRight`).
Compensation negatives that _should_ stay negative (FormControlLabel `-11`
cancelling IconButton padding) don't need the clamp — they're meant to be
negative at every density.

## Worked examples (this PR)

### Button (`theme.spacing(1)=8`, `(2)=16`, `(3)=24`)

| variant | size | block | inline |
| :-------- | :--- | :--------------------- | :---------------------- |
| contained | md | `spacing(1) − 2px` (6) | `spacing(2)` (16) |
| contained | sm | `spacing(1) − 4px` (4) | `spacing(1) + 2px` (10) |
| contained | lg | `spacing(1)` (8) | `spacing(3) − 2px` (22) |
| text | md | `spacing(1) − 2px` (6) | `spacing(1)` (8) |
| text | sm | `spacing(1) − 4px` (4) | `spacing(1) − 3px` (5) |
| text | lg | `spacing(1)` (8) | `spacing(1) + 3px` (11) |
| outlined | \* | contained − 1px | contained − 1px |

Button has no notch, so both axes derive.

### OutlinedInput

| target | value |
| :------------------------ | :-------------------------- |
| input block, md | `spacing(2) + 0.5px` (16.5) |
| input block, sm | `spacing(1) + 0.5px` (8.5) |
| input inline + adornments | `14px` — literal (notch) |

### InputLabel (outlined)

| state | transform |
| :---------- | :---------------------------------------- |
| resting, md | `translate(14px, spacing(2))` — x literal |
| resting, sm | `translate(14px, spacing(1) + 1px)` |
| shrunk | `translate(14px, −9px)` — fully literal |

### InputLabel (standard) — x stays `0`

Resting y tracks the standard input's text top = `Input` `marginTop`
(`spacing(2)`) + `InputBase` `paddingTop`:

| state | transform |
| :---------- | :--------------------------------------------- |
| resting, md | `translate(0, spacing(3) − 4px)` (20) |
| resting, sm | `translate(0, spacing(2) + 1px)` (17) |
| shrunk | `translate(0, −1.5px)` — floats above, literal |

### FilledInput + InputLabel (filled) — block only, x literal

Input block padding (top / bottom); inline `12` + adornments stay literal:

| state | top | bottom |
| :------------- | :---------------------- | :---------------------- |
| md | `spacing(3) + 1px` (25) | `spacing(1)` (8) |
| sm | `spacing(3) − 3px` (21) | `spacing(1) − 4px` (4) |
| hiddenLabel md | `spacing(2)` (16) | `spacing(2) + 1px` (17) |
| hiddenLabel sm | `spacing(1)` (8) | `spacing(1) + 1px` (9) |

Filled label `transformY` (x stays `12px`):

| state | y |
| :---------- | :----------------------------------------------------------- |
| resting, md | `spacing(2)` (16) |
| resting, sm | `spacing(2) − 3px` (13) |
| shrunk, md | `spacing(1) − 1px` (7) — floats _into_ padding, so it tracks |
| shrunk, sm | `spacing(1) − 4px` (4) |

### InputBase (standard `Input`) — block only, inline already `0`

Root-multiline + input share `4px 0 5px`. No inline padding ⇒ nothing anchored,
but the variant has no notch either — still derive **block only** (the `0`
inline is already density-free):

| target | value |
| :------------------------ | :------------------------------- |
| block top | `spacing(1) − 4px` (4) |
| block bottom | `spacing(1) − 3px` (5) |
| small `paddingTop` | `1px` — literal (sub-unit nudge) |
| multiline input `padding` | `0` — unchanged |

The standard `InputLabel` transforms track this in their own rollout item.

### Surrounding form components

| component | value | derivation |
| :--------------- | :----------------------------- | :------------------------------------ |
| FormControl | normal `marginTop 16` | `spacing(2)` |
| FormControl | normal `marginBottom 8` | `spacing(1)` |
| FormControl | dense `marginTop 8` | `spacing(1)` |
| FormControl | dense `marginBottom 4` | `spacing(1) − 4px` |
| FormControlLabel | row gaps `marginRight/Left 16` | `spacing(2)` |
| FormControlLabel | `marginLeft/Right −11` | literal — compensates control padding |
| FormHelperText | `marginTop 3/4`, inline `14` | literal — micro-gap / input-anchored |
| InputAdornment | filled start `marginTop 16` | `spacing(2)` (tracks label-space) |

FormControl's root was a static styled object — wrap it in `memoTheme(({ theme })
=> …)` to reach `theme.spacing`.

## Verification

Use the local harness (`scripts/spacing-screenshots/`) — see the rollout plan's
"How to verify". Default render must be **pixel-identical** to the pre-change
baseline (`toHaveScreenshot`, `maxDiffPixels: 0`); review 6px/10px shots for
density. For floating-label components, put a **valued field** in the fixture so
the shrunk label is visible. Unit tests (vitest, browser + node) green; lint,
prettier. No jsdom assertion on computed padding px.
Loading
Loading