diff --git a/docs/pages/experiments/color-gap.tsx b/docs/pages/experiments/color-gap.tsx new file mode 100644 index 00000000000000..aeba8bcd66a773 --- /dev/null +++ b/docs/pages/experiments/color-gap.tsx @@ -0,0 +1,930 @@ +/** + * Color gap prototype — how close can NARROW theme tokens alone get to a real + * design system's color language? + * + * Rules of the experiment: + * - Theme input is TOKENS ONLY: palette (incl. the vars-channel component slots + * like `palette.Alert.*` / `palette.Tooltip.bg` — they are `colorSchemes` + * palette input) / typography / shape / shadows. ZERO `styleOverrides` — + * that surface is the wide API this experiment deliberately refuses. + * - Palette values come 1:1 from the captured design token export (semantic + * layer, light + dark modes). Typography ramp and radius reuse the density + * prototype's medium preset input. + * - `palette.secondary` is repurposed as the NEUTRAL interactive role (dark + * gray label / border, black-overlay hover) — the design's default + * outline/flat button. `.dark` slots hold the design's own hover colors so + * MUI's hover derivation lands per design. + * - The visible delta between "Baseline" and "Design tokens" shows what tokens + * CAN move; the remaining delta vs the reference images is the gap a future + * narrow color/state API must close. Known deltas are annotated per section. + * - defaultProps lane (allowed, tracked separately from tokens): swapping + * intrinsic GLYPHS via theme `components.*.defaultProps` (Checkbox/Radio + * icons) is component config, not style authoring — `styleOverrides` stays + * forbidden. Glyph geometry from the captured spec: 16×16 control in a 32px + * touch target, 1px border, radio-on = solid thick ring; fills use + * currentColor + the contrastText CSS var so both schemes stay token-driven. + */ +import * as React from 'react'; +import Head from 'next/head'; +import { + createTheme, + useTheme, + ThemeProvider, + ThemeOptions, + useColorScheme, + enhanceColorStates, + colorMix, + relativeColor, + alpha, +} from '@mui/material/styles'; +import type { Shadows } from '@mui/material/styles'; +import CssBaseline from '@mui/material/CssBaseline'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import Alert from '@mui/material/Alert'; +import Autocomplete from '@mui/material/Autocomplete'; +import Avatar from '@mui/material/Avatar'; +import AvatarGroup from '@mui/material/AvatarGroup'; +import Badge from '@mui/material/Badge'; +import Box from '@mui/material/Box'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Button from '@mui/material/Button'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Checkbox from '@mui/material/Checkbox'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import Divider from '@mui/material/Divider'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import IconButton from '@mui/material/IconButton'; +import LinearProgress from '@mui/material/LinearProgress'; +import Link from '@mui/material/Link'; +import MenuItem from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import Paper from '@mui/material/Paper'; +import Radio from '@mui/material/Radio'; +import Skeleton from '@mui/material/Skeleton'; +import Slider from '@mui/material/Slider'; +import Stack from '@mui/material/Stack'; +import Step from '@mui/material/Step'; +import StepLabel from '@mui/material/StepLabel'; +import Stepper from '@mui/material/Stepper'; +import Switch from '@mui/material/Switch'; +import Tab from '@mui/material/Tab'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import Tabs from '@mui/material/Tabs'; +import TextField from '@mui/material/TextField'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import FolderIcon from '@mui/icons-material/Folder'; +import SearchIcon from '@mui/icons-material/Search'; +import SvgIcon from '@mui/material/SvgIcon'; + +// Design glyphs — 16×16 control drawn centered in the 24px icon box (net 16px +// visual at default fontSize). currentColor follows the component's state +// color; inner marks ride --mui-palette-primary-contrastText (white in light, +// near-black in dark) so the glyph pair stays scheme-correct without styles. +const CONTRAST = 'var(--mui-palette-primary-contrastText, #fff)'; + +function BoxBlankIcon(props: React.ComponentProps) { + return ( + + + + ); +} + +function BoxCheckedIcon(props: React.ComponentProps) { + return ( + + + + + ); +} + +function BoxIndeterminateIcon(props: React.ComponentProps) { + return ( + + + + + ); +} + +function ChevronDownIcon(props: React.ComponentProps) { + return ( + + + + ); +} + +function ThinCloseIcon(props: React.ComponentProps) { + return ( + + + + ); +} + +function CircleBlankIcon(props: React.ComponentProps) { + return ( + + + + ); +} + +function CircleCheckedIcon(props: React.ComponentProps) { + return ( + + + + ); +} + +// Soft, diffuse ladder replacing MUI's triple-umbra defaults (the reference +// surfaces use a single feathered drop; elevation colors black @10%/@20% in +// both modes per the token export). +const softShadows = Array.from({ length: 25 }, (_, i) => { + if (i === 0) { + return 'none'; + } + const y = Math.round(1 + i * 0.75); + const blur = Math.round(4 + i * 1.5); + return `0 ${y}px ${blur}px ${alpha('#000000', 0.08 + i * 0.004)}, 0 1px 2px ${alpha( + '#000000', + 0.06, + )}`; +}) as Shadows; + +// semantic/* token values, light-gray mode. +const lightPalette = { + // Plain palette colors — nothing here opts in. The single call to + // enhanceColorStates() at the bottom of this file derives states for ALL of + // them, which is the whole public surface. + primary: { main: '#006DA2', dark: '#006698', contrastText: '#FFFFFF' }, + secondary: { main: '#363636', dark: '#262626', contrastText: '#FFFFFF' }, + error: { main: '#D13F3F', dark: '#C83838' }, + warning: { main: '#BF8014' }, // icon-color/feedback/status/warning + success: { main: '#547919' }, + info: { main: '#006DA2' }, + text: { + primary: '#363636', + secondary: 'rgba(54, 54, 54, 0.75)', + disabled: 'rgba(54, 54, 54, 0.5)', // opacity/disabled = 50 + }, + divider: 'rgba(0, 0, 0, 0.1)', // border-color/light + background: { default: '#FAFAFA', paper: '#FFFFFF' }, // surface/200 + surface/100 + action: { + active: '#363636', // icon-color/default + hover: 'rgba(0, 0, 0, 0.05)', // input/secondary/hover + hoverOpacity: 0.05, + selected: 'rgba(205, 234, 247, 0.4)', // selection/default + focus: 'rgba(0, 109, 162, 0.12)', + disabled: 'rgba(54, 54, 54, 0.5)', + disabledBackground: 'rgba(0, 0, 0, 0.08)', + }, + // vars-channel component slots — still colorSchemes palette INPUT (tokens-only). + Alert: { + errorStandardBg: '#FDEEEE', // feedback/notification/error + infoStandardBg: '#E6F5FB', + successStandardBg: '#F3F7EC', + warningStandardBg: '#FFF9E8', + errorColor: '#A62626', // text-color/feedback/status/* + infoColor: '#006698', + successColor: '#547919', + warningColor: '#8C530E', + errorIconColor: '#D13F3F', // icon-color/feedback/status/* + infoIconColor: '#006DA2', + successIconColor: '#547919', + warningIconColor: '#BF8014', + }, + Tooltip: { bg: '#363636' }, // data-display/info/default + FilledInput: { + bg: 'rgba(0, 0, 0, 0.04)', // input/text/default + hoverBg: 'rgba(0, 0, 0, 0.1)', // input/text/hover + disabledBg: 'rgba(0, 0, 0, 0.02)', + }, + Chip: { defaultBorder: 'rgba(0, 0, 0, 0.15)' }, // border-color/medium +}; + +// semantic/* token values, dark-gray mode. +const darkPalette = { + primary: { main: '#38ABDF', dark: '#44B0E1', contrastText: '#080808' }, + secondary: { main: '#FFFFFF', dark: '#F5F5F5', contrastText: '#080808' }, + error: { main: '#F18888' }, + warning: { main: '#FFD153' }, + success: { main: '#9FC266' }, + info: { main: '#38ABDF' }, + text: { + primary: '#FFFFFF', + secondary: 'rgba(255, 255, 255, 0.7)', + disabled: 'rgba(255, 255, 255, 0.5)', + }, + divider: 'rgba(255, 255, 255, 0.2)', + background: { default: '#1F1F1F', paper: '#262626' }, // dark ramp inverts: 100 = lightest + action: { + active: '#FFFFFF', + hover: 'rgba(255, 255, 255, 0.1)', + hoverOpacity: 0.1, + selected: 'rgba(56, 171, 223, 0.3)', + focus: 'rgba(56, 171, 223, 0.12)', + disabled: 'rgba(255, 255, 255, 0.5)', + disabledBackground: 'rgba(255, 255, 255, 0.12)', + }, + Alert: { + errorStandardBg: '#330505', + infoStandardBg: '#081B34', + successStandardBg: '#1D2D04', + warningStandardBg: '#331805', + errorColor: '#FAD5D5', + infoColor: '#C1E5F5', + successColor: '#CFE1B3', + warningColor: '#FFEDBA', + errorIconColor: '#F18888', + infoIconColor: '#38ABDF', + successIconColor: '#ABCA79', + warningIconColor: '#FFD153', + }, + Tooltip: { bg: '#FFFFFF' }, // per design — but tooltip TEXT stays common.white (gap) + FilledInput: { + bg: 'rgba(255, 255, 255, 0.1)', + hoverBg: 'rgba(255, 255, 255, 0.2)', + disabledBg: 'rgba(255, 255, 255, 0.05)', + }, + Chip: { defaultBorder: 'rgba(255, 255, 255, 0.3)' }, +}; + +const designTokens: ThemeOptions = { + colorSchemes: { + light: { palette: lightPalette }, + dark: { palette: darkPalette }, + }, + typography: { + // Fira Sans = closest open font to the design's typeface (same designers: + // Spiekermann/Carrois); loaded via Google Fonts in the page Head. + fontFamily: '"Fira Sans", "Segoe UI", Roboto, Helvetica, Arial, sans-serif', + h1: { fontSize: '1.75rem', lineHeight: '36px' }, + h2: { fontSize: '1.5rem', lineHeight: '30px' }, + h3: { fontSize: '1rem', lineHeight: '26px' }, + h4: { fontSize: '0.9375rem', lineHeight: '24px' }, + h5: { fontSize: '0.875rem', lineHeight: '22px' }, + h6: { fontSize: '0.8125rem', lineHeight: '20px' }, + subtitle1: { fontSize: '0.875rem', lineHeight: '22px' }, + subtitle2: { fontSize: '0.8125rem', lineHeight: '20px' }, + body1: { fontSize: '0.875rem', lineHeight: '20px' }, + body2: { fontSize: '0.8125rem', lineHeight: '18px' }, + caption: { fontSize: '0.75rem', lineHeight: '16px' }, + button: { + fontSize: '0.875rem', + lineHeight: '20px', + textTransform: 'initial', + letterSpacing: 0, + }, + }, + shape: { borderRadius: 6 }, + shadows: softShadows, + // defaultProps lane — component config only, never styleOverrides. + components: { + // Flat buttons per design (the reference solid button has no drop shadow). + // disableElevation exists ONLY on Button + ButtonGroup. + MuiButton: { defaultProps: { disableElevation: true } }, + MuiButtonGroup: { defaultProps: { disableElevation: true } }, + MuiCheckbox: { + defaultProps: { + icon: , + checkedIcon: , + indeterminateIcon: , + }, + }, + MuiRadio: { + defaultProps: { + icon: , + checkedIcon: , + }, + }, + MuiSelect: { + defaultProps: { IconComponent: ChevronDownIcon }, + }, + MuiAutocomplete: { + defaultProps: { + popupIcon: , + clearIcon: , + }, + }, + MuiAccordionSummary: { + defaultProps: { expandIcon: }, + }, + MuiChip: { + defaultProps: { deleteIcon: }, + }, + }, +}; + +// The state API: FLAT, one entry per palette colour, each naming its own +// generator. Nothing is derived for a colour that is not listed, so `info` and +// `success` below keep today's styles — adoption is per colour. +// +// `warning` demonstrates `relativeColor`. Amber is the only hue family in the +// reference export that rotates hue along its ramp (4.15 deg/level, vs <0.4 for +// every other family) because it loses chroma steeply as lightness drops and +// reads brown without the correction — a capability `color-mix` does not have. +// NOTE the numbers below were fitted to the reference's amber SURFACE token, not +// to this `warning.main`; the design has no solid interactive warning fill to +// calibrate against. They show the backend working, not a verified match. +const tokensTheme = enhanceColorStates( + createTheme({ + cssVariables: { colorSchemeSelector: 'class' }, + // Opt-in keyboard focus ring (#48743). No outlineColor authored → each scheme + // rings with its own primary.main, which IS the design's focus color + // (box-shadow state/focus token) in both schemes. + focusVisible: true, + ...designTokens, + colorSchemes: { + // Calibrated against the reference tokens by minimising worst-channel error. + // The SOLID step lands at ~4.5% in both schemes — flipping the pole absorbs + // the asymmetry, so it barely varies. The OVERLAY step does not: it is an + // authored alpha and the reference doubles it in dark (5% → 10%). That one + // magnitude is the reason `states` has to live per scheme. + // Per-scheme levers ride `palette.states` so the EXISTING var emitter ships + // them per scheme as `--mui-palette-states-*`. The generator reads those vars, + // which is why one set of generated styles is correct in both schemes. + // Upstream this wants its own `--mui-state-*` namespace rather than a palette key. + light: { + palette: { + ...lightPalette, + states: { step: '4.6%', overlayStep: '5%', error: { step: '3.7%' } }, + }, + }, + dark: { + palette: { + ...darkPalette, + states: { step: '4.4%', overlayStep: '10%', error: { step: '6.5%' } }, + }, + }, + }, + components: { + MuiButtonBase: { defaultProps: { disableRipple: true } }, + }, + }), + { + // The magnitudes are passed as `var()` references so they resolve PER SCHEME + // off `palette.states` above. The library never invents a custom-property + // name — it cannot know this theme's prefix — so the reference is authored + // here, by the theme that owns the variable. + default: colorMix({ overlayStep: 'var(--mui-palette-states-overlayStep, 5%)' }), + primary: colorMix({ step: 'var(--mui-palette-states-step, 4.5%)' }), + secondary: colorMix({ step: 'var(--mui-palette-states-step, 4.5%)' }), + error: colorMix({ step: 'var(--mui-palette-states-error-step, 3.7%)' }), + warning: relativeColor({ lightness: 0.072, chroma: 0.014, hue: 4.15 }), + }, +); + +const baselineTheme = createTheme({ + cssVariables: { colorSchemeSelector: 'class' }, + colorSchemes: { light: true, dark: true }, + // Keep the stock expand icon visible in the Baseline state — the sheet no + // longer passes expandIcon per instance (the tokens theme defaults it). + components: { + MuiButtonBase: { + defaultProps: { + disableRipple: true, + }, + }, + MuiAccordionSummary: { defaultProps: { expandIcon: } }, + }, +}); + +function Section({ + title, + gap, + children, +}: { + title: string; + /** known token-unreachable deltas vs the reference — the gap evidence */ + gap?: string; + children: React.ReactNode; +}) { + return ( + + + {title} + + + {children} + + {gap ? ( + + gap: {gap} + + ) : null} + + ); +} + +/** A swatch painted with a ready-to-use value straight out of `theme.states`. */ +function StateChip({ value, label }: { value?: string; label: string }) { + return ( + + + + {label} + + + ); +} + +/** The same swatch, painted with the verbatim reference token for comparison. */ +function RefChip({ light, dark, label }: { light: string; dark: string; label: string }) { + return ( + + ({ + width: 96, + height: 40, + borderRadius: 1, + border: 1, + borderColor: 'divider', + backgroundColor: light, + ...theme.applyStyles('dark', { backgroundColor: dark }), + })} + /> + + {label} + + + ); +} + +function StateEngineSection() { + const theme = useTheme(); + const states = theme.states; + const solid = states?.primary; + const ghost = states?.default; + return ( + + + State engine — generated once, stored as ready-to-use styles + + + A generator runs once per theme and writes{' '} + + theme.states + {' '} + — plain style objects, no logic to interpret. Components spread them; nothing derives a + colour at render time. Swatches below read those objects directly. + + + + + + + theme.states.primary — colour-derived, vs. reference tokens + + + + + + + + + + + + + + + theme.states.default — colour-independent overlays (ghost surfaces, list rows) + + + + + + + + + + + + + + + + + + + Live — hover, hold, and note the disabled fade + + + + + + + + + + + + + Compound — selected × hover, enumerated by the generator + + + + Rest → hover → pressed + Selected + Disabled + + + + + + ); +} + +function ComponentSheet() { + const [tab, setTab] = React.useState(0); + const [toggle, setToggle] = React.useState('list'); + return ( + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + } label="Option" /> +
+ +
+ + + + + + + Option A + Option B + + } + /> +
+ +
+ + + Item name + Selected item + Item name + Disabled item + + +
+ +
+ setTab(v)}> + + + + + Text link + + Home + Files + Current + +
+ +
+ + Message + Message + Message + Message + +
+ +
+ + + + + + + + + + + +
+ +
+ + + + {}} /> + +
+ +
+ A + + B + C + D + E + + + + +
+ +
+ setToggle(v)}> + List + Grid + + Table + + + + +
+ +
+ + + Done + + + Active + + + Pending + + +
+ +
+ + + Expanded + + Details content + + + + Collapsed + + Details content + + + + + + Card title + + Supporting copy on the card surface. + + + + + e1 + + + e4 + + + e8 + +
+ +
+ + + + Name + Status + Amount + + + + + Alpha + Active + 24 + + + Beta (selected) + Active + 18 + + + Gamma + Paused + 7 + + +
+
+ +
+ + Heading 1 + Heading 2 + Heading 3 + Body 1 — primary text + + Body 2 — secondary text + + Caption + + Below the divider + +
+
+ ); +} + +function ModeSwitch() { + const { mode, setMode } = useColorScheme(); + return ( + setMode(event.target.checked ? 'dark' : 'light')} + /> + } + label="Dark" + /> + ); +} + +export default function ColorGapPage() { + const [useTokens, setUseTokens] = React.useState(true); + return ( + + + + + + + + + + Color gap — tokens-only theme + + setUseTokens(event.target.checked)} /> + } + label={useTokens ? 'Design tokens' : 'Baseline'} + /> + + + + + ); +} diff --git a/docs/pages/experiments/color-states.tsx b/docs/pages/experiments/color-states.tsx new file mode 100644 index 00000000000000..1ae1d99aad26f6 --- /dev/null +++ b/docs/pages/experiments/color-states.tsx @@ -0,0 +1,387 @@ +/** + * Color states — fidelity harness. + * + * Renders every configured palette in both schemes and compares what the shipped + * generators actually emit against the reference tokens they were fitted to. + * + * The swatch IS the probe: each generated cell paints the CSS string the theme + * produced, reads it back with `getComputedStyle`, and resolves it to sRGB bytes + * through a canvas — so `color-mix()` and `oklch(from …)` are both measured as the + * browser actually resolved them, not as we assume they resolve. Delta is the + * worst 8-bit channel difference from the reference token. + * + * Two independent themes rather than one with per-scheme variables: the values + * differ per scheme anyway, and literals keep this page about fidelity rather + * than about variable plumbing. + */ +import * as React from 'react'; +import Head from 'next/head'; +import { + createTheme, + useTheme, + ThemeProvider, + enhanceColorStates, + colorMix, + relativeColor, +} from '@mui/material/styles'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; + +type Triad = [string, string, string]; + +interface Case { + /** palette key — `accent` and the `surface*` entries are custom colours */ + key: string; + label: string; + generator: string; + reference: Triad; +} + +const LIGHT_CASES: Case[] = [ + { + key: 'primary', + label: 'primary', + generator: "colorMix({ step: '4.36%' })", + reference: ['#006DA2', '#006698', '#005F8E'], + }, + { + key: 'error', + label: 'error', + generator: "colorMix({ step: '3.66%' })", + reference: ['#D13F3F', '#C83838', '#BE3232'], + }, + { + key: 'accent', + label: 'accent (custom)', + generator: "colorMix({ step: '10.54%' })", + reference: ['#855ABF', '#724AA6', '#603C8C'], + }, + { + key: 'surfaceNeutral', + label: 'neutral surface', + generator: "colorMix({ step: '4.34%' })", + reference: ['#FFFFFF', '#F0F0F0', '#E5E5E5'], + }, + { + key: 'surfaceWarning', + label: 'warning surface', + generator: 'relativeColor({ lightness: -0.0173, chroma: 0.0229 })', + reference: ['#FFF9E8', '#FFF3D1', '#FFEDBA'], + }, +]; + +const DARK_CASES: Case[] = [ + { + key: 'primary', + label: 'primary', + generator: "colorMix({ step: '4.02%' })", + reference: ['#38ABDF', '#44B0E1', '#51B6E3'], + }, + { + key: 'error', + label: 'error', + generator: "colorMix({ step: '6.54%' })", + reference: ['#F18888', '#F29191', '#F39999'], + }, + { + key: 'accent', + label: 'accent (custom)', + generator: "colorMix({ step: '8.10%' })", + reference: ['#AA7CE9', '#B385F2', '#BB91F3'], + }, + { + key: 'surfaceRaised', + label: 'raised surface', + generator: "colorMix({ step: '8.52%' })", + reference: ['#262626', '#363636', '#454545'], + }, + { + key: 'surfaceSelected', + label: 'selected surface', + generator: "colorMix({ step: '8.40%', target: '#38ABDF' })", + reference: ['#254C5F', '#2E5D75', '#366E8A'], + }, + { + key: 'surfaceWarning', + label: 'warning surface', + generator: 'relativeColor({ lightness: 0.0719, chroma: 0.0136, hue: 4.15 })', + reference: ['#73400C', '#8C530E', '#B37412'], + }, +]; + +const lightTheme = enhanceColorStates( + createTheme({ + palette: { + mode: 'light', + primary: { main: '#006DA2' }, + error: { main: '#D13F3F' }, + accent: { main: '#855ABF' }, + surfaceNeutral: { main: '#FFFFFF' }, + surfaceWarning: { main: '#FFF9E8' }, + } as any, + }), + { + primary: colorMix({ step: '4.36%' }), + error: colorMix({ step: '3.66%' }), + accent: colorMix({ step: '10.54%' }), + surfaceNeutral: colorMix({ step: '4.34%' }), + surfaceWarning: relativeColor({ lightness: -0.0173, chroma: 0.0229 }), + }, +); + +const darkTheme = enhanceColorStates( + createTheme({ + palette: { + mode: 'dark', + primary: { main: '#38ABDF' }, + error: { main: '#F18888' }, + accent: { main: '#AA7CE9' }, + surfaceRaised: { main: '#262626' }, + surfaceSelected: { main: '#254C5F' }, + surfaceWarning: { main: '#73400C' }, + } as any, + }), + { + primary: colorMix({ step: '4.02%' }), + error: colorMix({ step: '6.54%' }), + accent: colorMix({ step: '8.10%' }), + surfaceRaised: colorMix({ step: '8.52%' }), + surfaceSelected: colorMix({ step: '8.40%', target: '#38ABDF' }), + surfaceWarning: relativeColor({ lightness: 0.0719, chroma: 0.0136, hue: 4.15 }), + }, +); + +/** + * Resolve any CSS colour — color-mix, oklch(from …), hex — to sRGB bytes. + * + * A sentinel is written first: if the browser cannot parse the colour, assigning + * it leaves `fillStyle` untouched, and we report that rather than silently + * measuring the sentinel. That is what distinguishes "unsupported syntax" from + * "resolved to black". + */ +const SENTINEL = '#123456'; + +function toBytes(cssColor: string): [number, number, number] | 'unparsed' | null { + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) { + return null; + } + ctx.fillStyle = SENTINEL; + ctx.fillStyle = cssColor; + if (ctx.fillStyle === SENTINEL && cssColor.toLowerCase() !== SENTINEL) { + return 'unparsed'; + } + ctx.clearRect(0, 0, 1, 1); + ctx.fillRect(0, 0, 1, 1); + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + return a === 0 ? null : [r, g, b]; +} + +const hex = (b: [number, number, number]) => + `#${b + .map((c) => c.toString(16).padStart(2, '0')) + .join('') + .toUpperCase()}`; + +const refBytes = (h: string): [number, number, number] => [ + parseInt(h.slice(1, 3), 16), + parseInt(h.slice(3, 5), 16), + parseInt(h.slice(5, 7), 16), +]; + +const STATES = ['rest', 'hover', 'active'] as const; + +function toneFor(delta: number | null) { + if (delta === null) { + return 'text.disabled'; + } + if (delta <= 3) { + return 'success.main'; + } + if (delta <= 8) { + return 'warning.main'; + } + return 'error.main'; +} + +function deltaLabel(reference: string | undefined, delta: number | null) { + if (!reference) { + return 'ref'; + } + return delta === null ? '—' : `Δ${delta}`; +} + +function Cell({ value, reference }: { value: string; reference?: string }) { + const ref = React.useRef(null); + const [resolved, setResolved] = React.useState(null); + const [delta, setDelta] = React.useState(null); + + // Emotion applies the class after mount, so the first frame can still read + // `rgba(0, 0, 0, 0)`. Retry across a few frames rather than reporting a colour + // the element had not been given yet. + React.useEffect(() => { + let frames = 0; + let raf = 0; + const read = () => { + const node = ref.current; + if (!node) { + return; + } + const used = getComputedStyle(node).backgroundColor; + const transparent = !used || used === 'rgba(0, 0, 0, 0)' || used === 'transparent'; + if (transparent && frames < 12) { + frames += 1; + raf = requestAnimationFrame(read); + return; + } + const bytes = toBytes(used); + if (bytes === 'unparsed') { + setResolved('unsupported'); + return; + } + if (!bytes) { + setResolved('transparent'); + return; + } + setResolved(hex(bytes)); + if (reference) { + const r = refBytes(reference); + setDelta(Math.max(...bytes.map((c, i) => Math.abs(c - r[i])))); + } + }; + raf = requestAnimationFrame(read); + return () => cancelAnimationFrame(raf); + }, [value, reference]); + + const tone = toneFor(delta); + + return ( + + + + {resolved ?? '…'} + + + {deltaLabel(reference, delta)} + + + ); +} + +function Row({ item }: { item: Case }) { + const theme = useTheme(); + const states = theme.states?.[item.key]; + const main = (theme.palette as any)[item.key]?.main as string; + const generated: Record = { + rest: main, + hover: states?.hover.backgroundColor ?? main, + active: states?.active.backgroundColor ?? main, + }; + + return ( + + + {item.label} + + {item.generator} + + + + {STATES.map((s, i) => ( + + ))} + + + {STATES.map((s, i) => ( + + ))} + + + ); +} + +function Section({ title, cases }: { title: string; cases: Case[] }) { + return ( + + {title} + + + + reference tokens — rest · hover · active + + + generated by the theme + + + {cases.map((c) => ( + + ))} + + ); +} + +export default function ColorStates() { + // Computed after mount: deriving it during render differs between server and + // client and trips hydration. + const [supportsRelative, setSupportsRelative] = React.useState(true); + React.useEffect(() => { + setSupportsRelative(CSS.supports('color', 'oklch(from red l c h)')); + }, []); + + return ( + + + Color states — fidelity + + + Color states — fidelity + + Each generated swatch paints what the theme emitted, then reads it back through the + browser and resolves it to sRGB. Δ is the worst 8-bit channel difference from the + reference token the generator was fitted to. + + {!supportsRelative && ( + + This browser does not support relative color syntax — rows using relativeColor() will + not resolve. + + )} + + +
+ + +
+ + + + + ); +} diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index d63ce839cd614e..e73b796cf11178 100644 --- a/packages/mui-material/src/Button/Button.js +++ b/packages/mui-material/src/Button/Button.js @@ -110,9 +110,16 @@ const ButtonRoot = styled(ButtonBase, { '&:hover': { textDecoration: 'none', }, - [`&.${buttonClasses.disabled}`]: { - color: (theme.vars || theme).palette.action.disabled, - }, + // Disabled is a whole-theme treatment, not a per-colour one: when the state + // layer is active it decides how disabled reads (fade vs recolour), so the + // fixed disabled colours here are omitted for every variant below too. + ...(theme.states?.default + ? null + : { + [`&.${buttonClasses.disabled}`]: { + color: (theme.vars || theme).palette.action.disabled, + }, + }), variants: [ { props: { variant: 'contained' }, @@ -136,11 +143,15 @@ const ButtonRoot = styled(ButtonBase, { ? `${(theme.vars || theme).shadows[6]}, ${theme.focusVisible.boxShadow}` : (theme.vars || theme).shadows[6], }, - [`&.${buttonClasses.disabled}`]: { - color: (theme.vars || theme).palette.action.disabled, - boxShadow: (theme.vars || theme).shadows[0], - backgroundColor: (theme.vars || theme).palette.action.disabledBackground, - }, + ...(theme.states?.default + ? null + : { + [`&.${buttonClasses.disabled}`]: { + color: (theme.vars || theme).palette.action.disabled, + boxShadow: (theme.vars || theme).shadows[0], + backgroundColor: (theme.vars || theme).palette.action.disabledBackground, + }, + }), }, }, { @@ -151,9 +162,13 @@ const ButtonRoot = styled(ButtonBase, { borderColor: `var(--variant-outlinedBorder, currentColor)`, backgroundColor: `var(--variant-outlinedBg)`, color: `var(--variant-outlinedColor)`, - [`&.${buttonClasses.disabled}`]: { - border: `1px solid ${(theme.vars || theme).palette.action.disabledBackground}`, - }, + ...(theme.states?.default + ? null + : { + [`&.${buttonClasses.disabled}`]: { + border: `1px solid ${(theme.vars || theme).palette.action.disabledBackground}`, + }, + }), }, }, { @@ -177,18 +192,33 @@ const ButtonRoot = styled(ButtonBase, { ), '--variant-containedColor': (theme.vars || theme).palette[color].contrastText, '--variant-containedBg': (theme.vars || theme).palette[color].main, + // Each fixed per-state colour is gated on the entry that would REPLACE + // it, not on the feature as a whole: the fill and the border come + // from `states[color]`, the text/outlined tints from the + // colour-independent `states.default`. Those are configured + // separately, so gating them together would drop a value nothing + // puts back. Rest values above are always emitted, so a colour the + // state layer does not cover degrades to "no hover", never "no fill". '@media (hover: hover)': { '&:hover': { - '--variant-containedBg': (theme.vars || theme).palette[color].dark, - '--variant-textBg': theme.alpha( - (theme.vars || theme).palette[color].main, - (theme.vars || theme).palette.action.hoverOpacity, - ), - '--variant-outlinedBorder': (theme.vars || theme).palette[color].main, - '--variant-outlinedBg': theme.alpha( - (theme.vars || theme).palette[color].main, - (theme.vars || theme).palette.action.hoverOpacity, - ), + ...(theme.states?.[color] + ? null + : { + '--variant-containedBg': (theme.vars || theme).palette[color].dark, + '--variant-outlinedBorder': (theme.vars || theme).palette[color].main, + }), + ...(theme.states?.default + ? null + : { + '--variant-textBg': theme.alpha( + (theme.vars || theme).palette[color].main, + (theme.vars || theme).palette.action.hoverOpacity, + ), + '--variant-outlinedBg': theme.alpha( + (theme.vars || theme).palette[color].main, + (theme.vars || theme).palette.action.hoverOpacity, + ), + }), }, }, }, diff --git a/packages/mui-material/src/MenuItem/MenuItem.js b/packages/mui-material/src/MenuItem/MenuItem.js index e4e3d8cbea919b..68d59360ee7b26 100644 --- a/packages/mui-material/src/MenuItem/MenuItem.js +++ b/packages/mui-material/src/MenuItem/MenuItem.js @@ -74,39 +74,49 @@ const MenuItemRoot = styled(ButtonBase, { whiteSpace: 'nowrap', '&:hover': { textDecoration: 'none', - backgroundColor: (theme.vars || theme).palette.action.hover, - // Reset on touch devices, it doesn't add specificity - '@media (hover: none)': { - backgroundColor: 'transparent', - }, - }, - [`&.${menuItemClasses.selected}`]: { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - (theme.vars || theme).palette.action.selectedOpacity, - ), - ...(!theme.focusVisible && { - [`&.${menuItemClasses.focusVisible}`]: { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - `${(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}`, - ), - // Reset on touch devices, it doesn't add specificity - '@media (hover: none)': { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - (theme.vars || theme).palette.action.selectedOpacity, - ), - }, + // Only the state COLOURS are gated; the touch reset and text-decoration are + // component behaviour, not state colour, so they always apply. + ...(theme.states?.default + ? null + : { + backgroundColor: (theme.vars || theme).palette.action.hover, + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + backgroundColor: 'transparent', + }, + }), }, + ...(theme.states?.default + ? null + : { + [`&.${menuItemClasses.selected}`]: { + backgroundColor: theme.alpha( + (theme.vars || theme).palette.primary.main, + (theme.vars || theme).palette.action.selectedOpacity, + ), + ...(!theme.focusVisible && { + [`&.${menuItemClasses.focusVisible}`]: { + backgroundColor: theme.alpha( + (theme.vars || theme).palette.primary.main, + `${(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}`, + ), + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + backgroundColor: theme.alpha( + (theme.vars || theme).palette.primary.main, + (theme.vars || theme).palette.action.selectedOpacity, + ), + }, + }, + }), ...(theme.focusVisible ? // Inset the ring: a scrolling Menu/MenuList clips an outset ring. applyInsetFocusVisible(1) diff --git a/packages/mui-material/src/styles/createPalette.d.ts b/packages/mui-material/src/styles/createPalette.d.ts index 8de49a7e063c77..c816bb62de292e 100644 --- a/packages/mui-material/src/styles/createPalette.d.ts +++ b/packages/mui-material/src/styles/createPalette.d.ts @@ -112,6 +112,7 @@ export interface Palette { background: TypeBackground; getContrastText: (background: string) => string; augmentColor: (options: PaletteAugmentColorOptions) => PaletteColor; + states?: PaletteStateLevers | undefined; } export interface Channels { @@ -123,7 +124,25 @@ export interface Channels { export type PartialTypeObject = { [P in keyof TypeObject]?: Partial }; +/** + * Per-scheme input levers for `enhanceColorStates`. + * + * These ride the palette so the existing color-scheme variable emitter ships them + * per scheme; the generator then references them as `var()`, which is how ONE set + * of generated styles stays correct in every scheme. Plain CSS strings — the + * generated OUTPUT lives on `theme.states`, never here. + */ +export interface PaletteStateLevers { + /** distance per level for opaque bases, e.g. `'4.5%'` */ + step?: string | undefined; + /** overlay alpha per level for transparent bases, e.g. `'5%'` */ + overlayStep?: string | undefined; + /** per-colour magnitudes, e.g. `{ error: { step: '3.7%' } }` */ + [color: string]: string | { step?: string; overlayStep?: string } | undefined; +} + export interface PaletteOptions { + states?: PaletteStateLevers | undefined; primary?: PaletteColorOptions | undefined; secondary?: PaletteColorOptions | undefined; error?: PaletteColorOptions | undefined; diff --git a/packages/mui-material/src/styles/createStates.ts b/packages/mui-material/src/styles/createStates.ts new file mode 100644 index 00000000000000..d367739d8dd185 --- /dev/null +++ b/packages/mui-material/src/styles/createStates.ts @@ -0,0 +1,135 @@ +/** + * `theme.states` — ready-to-use interaction styles, a sibling of `theme.palette`. + * + * theme.states.default // states that do not depend on a colour + * theme.states.primary // states derived from `palette.primary` + * + * The theme stores OUTPUT ONLY: plain style objects, keyed by state name, with no + * logic to interpret. They are serializable, visible in a console.log, and + * spreadable straight into a `styled()`. + * + * WHY A SIBLING OF `palette` AND NOT INSIDE IT + * Code in the wild iterates palette colours and their properties. Nesting style + * objects under `palette.primary` would put state keys in front of every such + * loop. `theme.states` keeps that surface untouched. + * + * WHY THERE IS NO DEFAULT BEHAVIOUR + * Every entry is asked for by name. `enhanceColorStates(theme, {})` generates + * nothing, so a colour nobody configured keeps exactly today's styles and the + * zero-diff contract is provable per colour rather than per theme. It also means + * Material UI never picks a magnitude, a ramp direction, or a CSS backend on the + * author's behalf — measured against a real design system, the right values + * differ per colour AND per scheme, so any house default would be wrong for + * someone. + */ + +import type { Theme } from './createThemeNoVars'; + +/** + * One state's styles. Deliberately NARROWER than `CSSObject`: every value a + * generator emits is a colour string, so a consumer reading a single property + * (`states.primary.hover.backgroundColor`) gets a `string`, not the wide union + * `CSSObject` would give. Still spreads into `styled()` / `styleOverrides`. + */ +export interface StateStyle { + backgroundColor?: string | undefined; + borderColor?: string | undefined; +} + +/** `disabled` is the one state that may fade rather than recolour. */ +export interface DisabledStyle { + opacity?: number | string | undefined; + boxShadow?: string | undefined; + color?: string | undefined; + backgroundColor?: string | undefined; +} + +/** + * Ready-to-use styles for one colour, keyed by STATE NAME rather than by selector. + * + * Selectors are deliberately NOT encoded here: which selector a state maps to is + * a DOM concern belonging to the component that applies it, and `Mui-selected` / + * hover media gating already differ per family today. Keying by name is what + * makes both call sites clean — apply the whole state (`'&:hover': s.hover`) or + * read one property (`s.hover.backgroundColor`) — with no traversal. + */ +export interface ColorStates { + hover: StateStyle; + active: StateStyle; + selected: StateStyle; + /** selected AND hovered — enumerated so no consumer composes it */ + selectedHover: StateStyle; + disabled: DisabledStyle; +} + +/** + * `theme.states`. Every key is optional: only the colours that were configured + * appear, which is exactly what the converted components gate on. + */ +export interface ThemeStates { + [color: string]: ColorStates | undefined; +} + +/** A theme that may carry the generated states. */ +export type StatefulTheme = Theme & { + states?: ThemeStates | undefined; + vars?: unknown; +}; + +export interface GeneratorContext { + /** the config key — a palette colour name, or `default` */ + name: string; + /** + * A CSS reference to the colour's `main`, already scheme-correct (under + * `cssVariables` it is a `var(--…-palette--main, fallback)` string). + * Absent for `default`, which is the colour-independent ramp. + */ + color?: string | undefined; + theme: StatefulTheme; +} + +/** Produces one colour's states. Runs ONCE, at theme-build time. */ +export type StateGenerator = (context: GeneratorContext) => ColorStates; + +/** + * The whole public config: FLAT, one entry per palette colour, plus the optional + * `default` key for the colour-independent ramp. Every value names its generator + * explicitly, so the CSS backend a colour uses — and therefore the browser + * support it needs — is readable at the call site. + */ +export type ColorStatesConfig = Record; + +/** + * Run each configured generator once and collect the output. All of the logic + * lives in the generators; this only resolves each colour's `main` and calls them. + */ +export default function createStates( + theme: StatefulTheme, + config: ColorStatesConfig = {}, +): ThemeStates | undefined { + const names = Object.keys(config); + if (!names.length) { + return undefined; + } + // Values come from `(theme.vars || theme).palette` — under `cssVariables` that + // mirror already holds `var(---palette-…, fallback)` strings built with + // this theme's own prefix, so nothing here has to know the naming scheme, and + // it degrades to plain literals under `cssVariables: false`. + const palette = ((theme as any).vars || theme).palette as Record; + if (!palette) { + return undefined; + } + + const states: ThemeStates = {}; + names.forEach((name) => { + const color = name === 'default' ? undefined : palette[name]?.main; + if (name !== 'default' && !color) { + throw new Error( + `MUI: enhanceColorStates() was given states for \`${name}\`, which is not a palette colour ` + + 'with a `main` value.', + ); + } + states[name] = config[name]({ name, color, theme }); + }); + return states; +} diff --git a/packages/mui-material/src/styles/createThemeNoVars.d.ts b/packages/mui-material/src/styles/createThemeNoVars.d.ts index ad8a3fcb4a13ee..a804d801443fba 100644 --- a/packages/mui-material/src/styles/createThemeNoVars.d.ts +++ b/packages/mui-material/src/styles/createThemeNoVars.d.ts @@ -107,6 +107,12 @@ type CssVarsProperties = CssThemeVariables extends { enabled: true } export interface Theme extends BaseTheme, CssVarsProperties { cssVariables?: false | undefined; components?: Components | undefined; + /** + * Ready-to-use interaction styles keyed by state name. Present only after + * `enhanceColorStates()` — absent on every theme that does not call it, which + * is exactly what the converted components gate on. + */ + states?: import('./createStates').ThemeStates | undefined; unstable_sx: (props: SxProps) => CSSObject; unstable_sxConfig: SxConfig; alpha: (color: string, value: number | string) => string; diff --git a/packages/mui-material/src/styles/enhanceColorStates.ts b/packages/mui-material/src/styles/enhanceColorStates.ts new file mode 100644 index 00000000000000..3b69d7a89397c7 --- /dev/null +++ b/packages/mui-material/src/styles/enhanceColorStates.ts @@ -0,0 +1,46 @@ +import createStates, { ColorStatesConfig, StatefulTheme } from './createStates'; +import applySharedStates from './sharedStateComponents'; + +/** + * Define how palette colours behave under interaction. + * + * Apply LAST, on the final composed theme — a later `createTheme()` wrap rebuilds + * the vars machinery and drops what this attaches: + * + * const theme = enhanceColorStates(createTheme({ … }), { + * default: colorMix({ overlayStep: '5%' }), + * primary: colorMix({ step: '4.4%' }), + * warning: relativeColor({ lightness: 0.072, chroma: 0.014, hue: 4.15 }), + * }); + * + * The config is FLAT and fully explicit: one entry per palette colour, each + * naming the generator that produces its states. Nothing is derived for a colour + * that is not listed, so: + * + * - adoption is per colour, and zero-diff is provable per colour; + * - Material UI never chooses a magnitude, a ramp direction, or a CSS backend + * on the author's behalf; + * - the browser support a colour needs is readable at the call site, because + * `relativeColor` (Chrome 119+) is named where it is used. + * + * It: + * 1. runs each generator once, producing `theme.states` — ready-to-use style + * objects keyed by state name (`hover`, `active`, `selected`, …); + * 2. registers the shared component styles that consume them. + * + * Deliberately NOT wired into `createTheme`: the enhancer is the public seam, + * exactly as it is for density. + */ +export default function enhanceColorStates( + themeInput: T, + config: ColorStatesConfig = {}, +): T { + const states = createStates(themeInput, config); + if (!states) { + return themeInput; + } + const theme = { ...themeInput, states }; + theme.components = { ...theme.components }; + applySharedStates(theme); + return theme; +} diff --git a/packages/mui-material/src/styles/index.d.ts b/packages/mui-material/src/styles/index.d.ts index c8b47aa077f1b5..5c7bcf9e40d70e 100644 --- a/packages/mui-material/src/styles/index.d.ts +++ b/packages/mui-material/src/styles/index.d.ts @@ -10,6 +10,23 @@ export { FocusVisible, } from './createTheme'; export { default as enhanceHighContrast, HighContrastTokens } from './enhanceHighContrast'; +export { default as enhanceColorStates } from './enhanceColorStates'; +export { + ColorStates, + ThemeStates, + ColorStatesConfig, + StateGenerator, + GeneratorContext, + StateStyle, + DisabledStyle, +} from './createStates'; +export { + colorMix, + relativeColor, + ColorMixOptions, + RelativeColorOptions, + StateLevels, +} from './stateGenerators'; export { default as adaptV4Theme, DeprecatedThemeOptions } from './adaptV4Theme'; export { Shadows } from './shadows'; export { ZIndex } from './zIndex'; @@ -19,6 +36,7 @@ export { PaletteColor, PaletteColorOptions, PaletteOptions, + PaletteStateLevers, SimplePaletteColorOptions, TypeText, TypeAction, diff --git a/packages/mui-material/src/styles/index.js b/packages/mui-material/src/styles/index.js index b9dfa4913c7d61..4fa38d0afc6af3 100644 --- a/packages/mui-material/src/styles/index.js +++ b/packages/mui-material/src/styles/index.js @@ -25,6 +25,8 @@ export function experimental_sx() { ); } export { default as createTheme } from './createTheme'; +export { default as enhanceColorStates } from './enhanceColorStates'; +export { colorMix, relativeColor } from './stateGenerators'; export { default as enhanceHighContrast } from './enhanceHighContrast'; export { default as unstable_createMuiStrictModeTheme } from './createMuiStrictModeTheme'; export { default as createStyles } from './createStyles'; diff --git a/packages/mui-material/src/styles/sharedStateComponents.ts b/packages/mui-material/src/styles/sharedStateComponents.ts new file mode 100644 index 00000000000000..20cddc3a6a5c6d --- /dev/null +++ b/packages/mui-material/src/styles/sharedStateComponents.ts @@ -0,0 +1,98 @@ +import type { CSSObject } from '@mui/system'; +import type { ColorStates, StatefulTheme } from './createStates'; + +/** + * PRIVATE shared component mapping behind `enhanceColorStates` (not re-exported + * from the styles barrel — the enhancer is the public surface). Sibling of + * `sharedDensityComponents.ts`: one module re-authoring many families rather than + * the same edit repeated in every component. + * + * NOTHING IS DERIVED HERE. `theme.states` already holds ready-to-use styles; this + * module only applies them at each component's own selectors. + * + * Overrides are FUNCTIONS so they resolve against the theme a component actually + * renders with, which keeps them correct if the theme is re-composed later. + */ + +/** Compose our root styles under any the user already registered — theirs wins. */ +function addRootOverride( + components: Record, + name: string, + ours: (props: { theme: StatefulTheme }) => CSSObject, +) { + const existing = components[name] ?? {}; + const overrides = existing.styleOverrides ?? {}; + const theirs = overrides.root; + components[name] = { + ...existing, + styleOverrides: { + ...overrides, + root: (props: { theme: StatefulTheme }) => ({ + ...ours(props), + ...(typeof theirs === 'function' ? theirs(props) : theirs), + }), + }, + }; +} + +export default function applySharedStates(theme: T): void { + const components = theme.components as Record; + + // MenuItem paints `backgroundColor` directly, so each named state is applied + // straight at its own selector. Hover sits inside `@media (hover: hover)` and so + // never applies on touch — which is why nothing here needs a `hover: none` reset. + addRootOverride(components, 'MuiMenuItem', ({ theme: t }) => ({ + '&:active': t.states?.default?.active, + '&.Mui-selected': t.states?.default?.selected, + '&.Mui-disabled': t.states?.default?.disabled, + '@media (hover: hover)': { + '&:hover': t.states?.default?.hover, + '&.Mui-selected:hover': t.states?.default?.selectedHover, + }, + })); + + // Button paints through private `--variant-*` custom properties, which decompose + // 3 variants x N colors into 3 + N rules. That indirection is worth keeping, so + // the values are unpacked onto those properties here — the one place this file + // holds component-private knowledge, and the reason it needs a test asserting + // those variable names still exist in the component's output. + addRootOverride(components, 'MuiButton', ({ theme: t }) => ({ + '&.Mui-disabled': t.states?.default?.disabled, + variants: Object.keys(t.states ?? {}) + .filter((key) => key !== 'default') + .map((color) => { + const state = t.states?.[color] as ColorStates; + // Text and outlined fills tint toward the pole whatever the button's + // color, so they come from the color-independent entry. + const ghost = t.states?.default; + // The ghost properties are emitted ONLY when `default` was configured. + // Writing them as `undefined` would blank the value the component still + // emits for itself when `default` is absent — the two are gated + // independently, so they must be applied independently too. + const ghostAt = (level: 'hover' | 'active') => + ghost + ? { + '--variant-textBg': ghost[level].backgroundColor, + '--variant-outlinedBg': ghost[level].backgroundColor, + } + : null; + return { + props: { color }, + style: { + '@media (hover: hover)': { + '&:hover': { + '--variant-containedBg': state.hover.backgroundColor, + '--variant-outlinedBorder': state.hover.borderColor, + ...ghostAt('hover'), + }, + }, + '&:active': { + '--variant-containedBg': state.active.backgroundColor, + '--variant-outlinedBorder': state.active.borderColor, + ...ghostAt('active'), + }, + }, + }; + }), + })); +} diff --git a/packages/mui-material/src/styles/shouldSkipGeneratingVar.ts b/packages/mui-material/src/styles/shouldSkipGeneratingVar.ts index 2a408c16d4984a..21340f843406e4 100644 --- a/packages/mui-material/src/styles/shouldSkipGeneratingVar.ts +++ b/packages/mui-material/src/styles/shouldSkipGeneratingVar.ts @@ -9,6 +9,12 @@ export default function shouldSkipGeneratingVar(keys: string[]) { /(cssVarPrefix|colorSchemeSelector|modularCssLayers|rootSelector|typography|mixins|breakpoints|direction|transitions)/, ) || !!keys[0].match(/sxConfig$/) || // ends with sxConfig - (keys[0] === 'palette' && !!keys[1]?.match(/(mode|contrastThreshold|tonalOffset)/)) + (keys[0] === 'palette' && !!keys[1]?.match(/(mode|contrastThreshold|tonalOffset)/)) || + // `theme.states` holds ready-to-use STYLE objects keyed by state name + // (`hover`, `selectedHover`, `disabled`). Hoisting them to `:root` vars would + // emit one var per state-property pair and resolve nowhere useful. This skips + // the generated OUTPUT only — `palette.states`, where a theme may put the + // per-scheme input levers, is a different node and is still emitted. + keys[0] === 'states' ); } diff --git a/packages/mui-material/src/styles/stateGenerators.ts b/packages/mui-material/src/styles/stateGenerators.ts new file mode 100644 index 00000000000000..d733c2235f921c --- /dev/null +++ b/packages/mui-material/src/styles/stateGenerators.ts @@ -0,0 +1,173 @@ +/** + * State generators — the two CSS backends Material UI ships. + * + * A generator is a function the theme author names explicitly, once per palette + * colour, in `enhanceColorStates`. There is no implicit default: a colour with no + * generator keeps today's styles, so adoption is per colour and provably + * zero-diff for the rest. + * + * enhanceColorStates(theme, { + * default: colorMix({ overlayStep: '5%' }), + * primary: colorMix({ step: '4.4%' }), + * warning: relativeColor({ lightness: 0.072, chroma: 0.014, hue: 4.15 }), + * }) + * + * Naming the backend at the call site is deliberate: `relativeColor` needs + * Chrome 119 / Firefox 128, above Material UI's browserslist floor, so a reader + * can see which colours carry that requirement instead of discovering it in + * production. Everything else stays on `colorMix`, which is within targets. + * + * Both return the same `ColorStates` shape, so nothing downstream — the gates, + * the shared component layer, `theme.states` — can tell them apart, and a third + * party can ship a generator of its own without Material UI knowing. + */ + +import type { ColorStates, DisabledStyle, GeneratorContext, StateGenerator } from './createStates'; + +/** Steps from rest, per state. Shared by both generators. */ +export interface StateLevels { + hover?: number | undefined; + active?: number | undefined; + selected?: number | undefined; +} + +const LEVELS = { hover: 1, active: 2, selected: 1 }; +const DISABLED: DisabledStyle = { opacity: 0.5, boxShadow: 'none' }; + +function resolveLevels(levels: StateLevels | undefined) { + return { ...LEVELS, ...levels }; +} + +export interface ColorMixOptions { + /** + * Distance travelled per level, e.g. `'4.5%'`. Required — there is no house + * magnitude, because the right value is a property of the design, not of + * Material UI. Any CSS value, so a `var()` reference may vary it per scheme. + */ + step?: string | undefined; + /** Alpha per level for the colour-independent overlay ramp (the `default` key). */ + overlayStep?: string | undefined; + /** + * Where the ramp travels TO. Defaults to `palette.common.onBackground`, which + * flips per colour scheme (#000 light / #fff dark) and so keeps one generated + * expression correct in both. Point it at a colour's own anchor + * (`var(--mui-palette-warning-main)`) for tinted surfaces, which ramp by + * gaining chroma rather than losing it. + */ + target?: string | undefined; + levels?: StateLevels | undefined; + disabled?: DisabledStyle | undefined; +} + +/** + * Ramp with `color-mix()`. Within Material UI's browserslist targets today. + * + * `in oklab`, not oklch: with a neutral target the two are mathematically + * identical, but a tinted target makes oklch rotate hue — a red button drifts + * purple — while oklab desaturates and keeps the colour's identity. + */ +export function colorMix(options: ColorMixOptions = {}): StateGenerator { + const { step, overlayStep, target, levels, disabled = DISABLED } = options; + const lv = resolveLevels(levels); + + return ({ color, theme }: GeneratorContext): ColorStates => { + const palette = ((theme as any).vars || theme).palette; + // `common.onBackground` only exists on `cssVariables` themes. On a plain + // `createTheme({ palette })` it is absent, and an undefined pole would emit + // `color-mix(in oklab, C, undefined 4%)` — invalid CSS that browsers drop + // silently, leaving the element transparent. Fall back to the value that + // token carries anyway: black on a light ground, white on a dark one. + const pole = + target ?? palette.common?.onBackground ?? (theme.palette?.mode === 'dark' ? '#fff' : '#000'); + const times = (n: number, unit: string) => (n === 1 ? unit : `calc(${n} * ${unit})`); + const mix = (base: string, to: string, amount: string) => + `color-mix(in oklab, ${base}, ${to} ${amount})`; + + // No colour: the `default` key — a ghost ramp that tints whatever sits under + // it. It never references a palette colour, which is why one entry serves a + // text Button and a list row alike. + if (!color) { + const overlay = (level: number) => ({ + backgroundColor: mix('transparent', pole, times(level, overlayStep ?? '5%')), + }); + return { + hover: overlay(lv.hover), + active: overlay(lv.active), + selected: overlay(lv.selected), + selectedHover: overlay(lv.selected + lv.hover), + disabled, + }; + } + + if (!step) { + throw new Error( + 'MUI: colorMix() needs a `step` for a palette colour, e.g. colorMix({ step: "4.5%" }).', + ); + } + // The border travels a different axis: from a translucent main TOWARD the + // opaque main, so one step lands exactly where an outlined hover does today. + // `color-mix` clamps past 100%, so deeper levels simply rest there. + const forLevel = (level: number) => ({ + backgroundColor: mix(color, pole, times(level, step)), + borderColor: mix(theme.alpha(color, 0.5), color, times(level, '100%')), + }); + return { + hover: forLevel(lv.hover), + active: forLevel(lv.active), + selected: forLevel(lv.selected), + selectedHover: forLevel(lv.selected + lv.hover), + disabled, + }; + }; +} + +export interface RelativeColorOptions { + /** Lightness added per level, in oklch units (0–1). Negative darkens. */ + lightness: number; + /** Chroma added per level. Positive saturates — which `colorMix` cannot do. */ + chroma?: number | undefined; + /** + * Hue rotated per level, in degrees. Amber is the case that needs it: it loses + * chroma steeply as lightness drops, so a straight ramp reads as brown unless + * the hue is carried along. + */ + hue?: number | undefined; + levels?: StateLevels | undefined; + disabled?: DisabledStyle | undefined; +} + +/** + * Ramp with CSS relative colour syntax — exact, and the only backend here that + * can raise chroma or rotate hue. + * + * REQUIRES Chrome 119 / Firefox 128, ABOVE Material UI's browserslist floor + * (Chrome 117 / Firefox 121). Naming it opts a single palette colour into that + * floor; every other colour stays on `colorMix`. + */ +export function relativeColor(options: RelativeColorOptions): StateGenerator { + const { lightness, chroma = 0, hue = 0, levels, disabled = DISABLED } = options; + const lv = resolveLevels(levels); + + return ({ color, name }: GeneratorContext): ColorStates => { + if (!color) { + throw new Error( + `MUI: relativeColor() derives from a palette colour and cannot be used for \`${name}\`, ` + + 'which has none. Use colorMix() for the colour-independent ramp.', + ); + } + const at = (level: number) => { + const l = `calc(l + ${(lightness * level).toFixed(5)})`; + const c = chroma ? `calc(c + ${(chroma * level).toFixed(5)})` : 'c'; + const h = hue ? `calc(h + ${(hue * level).toFixed(3)})` : 'h'; + return `oklch(from ${color} ${l} ${c} ${h})`; + }; + const forLevel = (level: number) => ({ backgroundColor: at(level), borderColor: at(level) }); + return { + hover: forLevel(lv.hover), + active: forLevel(lv.active), + selected: forLevel(lv.selected), + selectedHover: forLevel(lv.selected + lv.hover), + disabled, + }; + }; +}