@neo-design/mui 1.0.0-rc.4 → 1.0.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,14 +12,49 @@ entry point.
12
12
  | `@neo-design/mui` | the theme and the `Neo*` components — [`ui/index.mjs`](ui/index.mjs), with its types in [`ui/index.d.ts`](ui/index.d.ts) |
13
13
  | `@neo-design/mui/theme` | the theme only — [`theme/index.mjs`](theme/index.mjs), with its types in [`theme/index.d.ts`](theme/index.d.ts) |
14
14
 
15
- ```js
16
- import { ThemeProvider } from '@mui/material/styles';
17
- import { theme, NeoButton } from '@neo-design/mui';
18
- import '@neo-design/foundations/tokens.css';
19
- import '@neo-design/foundations/foundations.css';
15
+ ## Install
16
+
17
+ While the version is a release candidate, install it with the `next` tag, together with the font:
18
+
19
+ ```sh
20
+ npm i @neo-design/foundations@next @neo-design/mui@next @fontsource/noto-sans
21
+ ```
22
+
23
+ npm brings React, Material UI and Emotion as peers, in the versions this package supports —the `peerDependencies`
24
+ of [`package.json`](package.json)—. **With pnpm**, which does not let your app import a peer it did not install
25
+ itself, name them:
26
+
27
+ ```sh
28
+ pnpm add @neo-design/foundations@next @neo-design/mui@next @fontsource/noto-sans \
29
+ @mui/material@6 @emotion/react @emotion/styled react react-dom
30
+ ```
31
+
32
+ ## Mount it once
33
+
34
+ ```jsx
35
+ import '@fontsource/noto-sans/400.css';
36
+ import '@fontsource/noto-sans/500.css';
37
+ import '@fontsource/noto-sans/700.css';
38
+ import { ThemeProvider, CssBaseline } from '@mui/material';
39
+ import { theme } from '@neo-design/mui';
40
+ import { sprite } from '@neo-design/foundations/sprite';
41
+
42
+ export function App({ children }) {
43
+ return (
44
+ <ThemeProvider theme={theme}>
45
+ <CssBaseline /> {/* required: the CSS variables and the base type */}
46
+ <div hidden dangerouslySetInnerHTML={{ __html: sprite }} /> {/* the icons, injected once */}
47
+ {children}
48
+ </ThemeProvider>
49
+ );
50
+ }
20
51
  ```
21
52
 
22
- The React, Material UI and Emotion versions it works with are in
23
- [`package.json`](package.json), as `peerDependencies`.
53
+ Without `CssBaseline` the components lose their colors, heights and shapes, and without the font the app falls back
54
+ to the browser's. **The whole guide** —what to check once it is mounted, Next.js, the plain CSS layer and the
55
+ component pages— is in [Integration](https://github.com/jzamorano-ui/neo-ds/blob/main/docs/dev/INTEGRATION.md), and what an
56
+ AI agent needs to build with Neo, in [`llms.txt`](https://github.com/jzamorano-ui/neo-ds/blob/main/llms.txt).
24
57
 
25
- **The `Neo*` wrappers are optional**: an app that already uses MUI is dressed just by mounting the theme.
58
+ **The `Neo*` wrappers are optional**, with four exceptions: an app that already uses MUI is dressed by mounting the
59
+ theme, except `TextField`, `TextField multiline`, `Select` and `Autocomplete`, where MUI draws the label inside the
60
+ border. There you use `NeoTextField`, `NeoTextArea`, `NeoSelect` and `NeoCombobox`.
package/package.json CHANGED
@@ -1,7 +1,13 @@
1
1
  {
2
2
  "name": "@neo-design/mui",
3
- "version": "1.0.0-rc.4",
3
+ "version": "1.0.0-rc.6",
4
4
  "description": "Neo — the Material UI theme and the Neo* React components, on top of @neo-design/foundations.",
5
+ "homepage": "https://github.com/jzamorano-ui/neo-ds/blob/main/docs/dev/INTEGRATION.md",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/jzamorano-ui/neo-ds.git",
9
+ "directory": "packages/mui"
10
+ },
5
11
  "type": "module",
6
12
  "types": "./ui/index.d.ts",
7
13
  "sideEffects": [
@@ -23,7 +29,7 @@
23
29
  "theme"
24
30
  ],
25
31
  "dependencies": {
26
- "@neo-design/foundations": "1.0.0-rc.4"
32
+ "@neo-design/foundations": "1.0.0-rc.6"
27
33
  },
28
34
  "peerDependencies": {
29
35
  "@emotion/react": "^11",
@@ -126,6 +126,7 @@ export const components = {
126
126
  variants: [
127
127
  { props: { variant: 'text' }, style: { '&[data-surface="inverse"]': ghostInverse } },
128
128
  { props: { variant: 'outlined' }, style: { '&[data-surface="inverse"]': ghostInverse } },
129
+ { props: { variant: 'outlined' }, style: { border: 0, '&:hover': { border: 0 }, '&.Mui-disabled': { border: 0 } } },
129
130
  { props: { variant: 'contained' }, style: { '&[data-surface="inverse"]': { backgroundColor: 'var(--fill--primary--inverse--default)', ...withIcon('--text--base--default'), '&:hover': { backgroundColor: 'var(--fill--primary--inverse--hover)' }, '&:active': { backgroundColor: 'var(--fill--primary--inverse--active)' } } } },
130
131
  { props: { variant: 'contained', color: 'secondary' }, style: { '&[data-surface="inverse"]': { backgroundColor: 'var(--fill--secondary--inverse--default)', ...withIcon('--text--base--contrast'), boxShadow: 'inset 0 0 0 var(--neo-stroke-xs) var(--border--base--contrast)', '&:hover': { backgroundColor: 'var(--fill--secondary--inverse--hover)' }, '&:active': { backgroundColor: 'var(--fill--secondary--inverse--active)' } } } },
131
132
  { props: { variant: 'contained', color: 'primary' }, style: contained(FILL.primary) },
@@ -247,6 +248,7 @@ export const components = {
247
248
  styleOverrides: {
248
249
  paper: ({ theme }) => ({
249
250
  alignItems: 'flex-start',
251
+ '&:focus': { outline: 0 },
250
252
  '& > .MuiDialogTitle-root, & > .MuiDialogContent-root, & > .MuiDialogActions-root': { alignSelf: 'stretch' },
251
253
  backgroundColor: 'var(--surface--base--default)', borderRadius: 'var(--neo-radius-xl)',
252
254
  boxShadow: 'none', backgroundImage: 'none',
@@ -707,7 +709,6 @@ export const components = {
707
709
  },
708
710
 
709
711
  MuiCircularProgress: {
710
- defaultProps: { 'aria-hidden': true },
711
712
  styleOverrides: {
712
713
  root: {
713
714
  color: 'var(--icon--base--default)', width: 'var(--neo-icon-size-md-rem) !important', height: 'var(--neo-icon-size-md-rem) !important', flexShrink: 0,
@@ -769,7 +770,15 @@ export const components = {
769
770
  styleOverrides: {
770
771
  root: ({ theme }) => ({
771
772
  display: 'flex', flexDirection: 'column', gap: 'var(--neo-space-xs-rem)',
772
- '& .field__label-row, & .select__label-row, & .combobox__label-row': { display: 'flex', alignItems: 'center', gap: 'var(--neo-space-xs-rem)' },
773
+ '& .field__label-row, & .select__label-row, & .combobox__label-row': {
774
+ display: 'block',
775
+ '& label': { display: 'inline' },
776
+ '& > :not(label):not(span)': { display: 'inline-flex', verticalAlign: 'top', marginBlock: 'calc((var(--neo-type-body-lg-medium-line-height-rem) - var(--neo-icon-size-xs-rem)) / 2)', marginLeft: 'var(--neo-space-xs-rem)' },
777
+ },
778
+ '& .field__label-tail, & .select__label-tail, & .combobox__label-tail': {
779
+ whiteSpace: 'nowrap',
780
+ '& > :not(label)': { display: 'inline-flex', verticalAlign: 'top', marginBlock: 'calc((var(--neo-type-body-lg-medium-line-height-rem) - var(--neo-icon-size-xs-rem)) / 2)', marginLeft: 'var(--neo-space-xs-rem)' },
781
+ },
773
782
  '& .field__footer': { display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: 'var(--neo-space-sm-rem)' },
774
783
  '& .field__footer .MuiFormHelperText-root': { margin: 0, flex: 1 },
775
784
  '& .field__footer .field__counter': { marginLeft: 'auto' },
@@ -799,7 +808,6 @@ export const components = {
799
808
  styleOverrides: {
800
809
  root: ({ theme }) => ({
801
810
  ...theme.typography.bodyLgMedium, color: 'var(--text--base--default)',
802
- whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0,
803
811
  '&.Mui-focused': { color: 'var(--text--base--default)' },
804
812
  '&&.Mui-error': { color: 'var(--text--semantic--error)' },
805
813
  '&.Mui-disabled': { color: 'var(--text--base--disabled)' },
@@ -1,5 +1,5 @@
1
1
  // packages/mui/theme/custom-css.mjs — emitted without comments. The reasoning behind each decision lives in Neo's source repository.
2
- export const HOJAS_PLANAS = "/* icons.css — entregado por el theme; su fuente es packages/foundations/icons/icons.css */\n/* Neo — iconos\n Requiere cargados primero: packages/foundations/tokens/neo-color.css + packages/foundations/tokens/neo-foundations.css (definen --icon--* y --neo-icon-size-*).\n\n system → el SVG usa currentColor; el color por defecto lo da .icon (abajo).\n semantic → el SVG ya trae su color bindeado al token semántico (var(--icon--semantic--*)).\n No requiere clase: ya sale con su color y es themeable.\n brand → multicolor propio (tokens icon/base/brand-*); color/currentColor no aplica, solo tamaño. */\n\n.icon { display: inline-flex; flex-shrink: 0; width: var(--neo-icon-size-md); height: var(--neo-icon-size-md); }\n.icon--xs { width: var(--neo-icon-size-xs); height: var(--neo-icon-size-xs); }\n.icon--sm { width: var(--neo-icon-size-sm); height: var(--neo-icon-size-sm); }\n.icon--md { width: var(--neo-icon-size-md); height: var(--neo-icon-size-md); }\n.icon--lg { width: var(--neo-icon-size-lg); height: var(--neo-icon-size-lg); }\n.icon--xl { width: var(--neo-icon-size-xl); height: var(--neo-icon-size-xl); }\n.icon--2xl { width: var(--neo-icon-size-2xl); height: var(--neo-icon-size-2xl); }\n.icon--3xl { width: var(--neo-icon-size-3xl); height: var(--neo-icon-size-3xl); }\n/* Las tres grandes FALTABAN, con su token emitido desde siempre: la escala de icon-size llega a 4xl\n y las clases se cortaban en xl, asi que un icono de 48, 56 o 64 habia que dimensionarlo a mano con\n un style suelto. Lo pago la pantalla piloto el 2026-09-06 —su icono de marca de 64— y es un hueco\n de FRONTERA: quien consume no lo puede arreglar sin salirse del sistema.\n (SIN BACKTICKS: este bloque vive dentro del template literal que emite la hoja.) */\n.icon--4xl { width: var(--neo-icon-size-4xl); height: var(--neo-icon-size-4xl); }\n\n/* system: color por defecto (currentColor toma este valor) */\n.icon { color: var(--icon--base--default); }\n.icon--secondary { color: var(--icon--base--secondary); }\n.icon--disabled { color: var(--icon--base--disabled); }\n.icon--inverse { color: var(--icon--base--contrast); }\n\n/* banner.css — entregado por el theme; su fuente es packages/foundations/css/banner.css */\n/* banner.css — Neo\n Spec: banner.md\n Superficie promocional. La imagen es FONDO (cover, se recorta); el ancho lo pone el contenedor;\n el alto lo dicta el size.\n\n LA VERSIÓN LA ELIGE EL ANCHO, NO UNA PROP. Hasta el 2026-09-02 esta hoja tenía diez reglas colgando\n de `.banner--desktop` / `.banner--mobile` y ni una `@media`: la versión la elegía quien escribía el\n marcado, y por defecto salía escritorio — así que un banner en una columna angosta se dibujaba\n ancho salvo que alguien se acordara. Ahora corta solo en 720px, el mismo punto donde corta\n `modal-fullscreen`. Los dos masters se solapan entre 720 y 735, así que el valor está dentro de lo\n que el diseño dibuja.\n\n AHORA MIRA SU CONTENEDOR, NO LA PANTALLA (2026-09-03), que es lo que C9 pide de verdad. Una\n `@media` mide el viewport, así que un banner metido en una columna de 624px dentro de una pantalla\n de 992 se dibujaba ancho — el caso que C9 describe con todas las letras. Una container query se\n resuelve contra un ANCESTRO (un contenedor no se consulta a sí mismo, medido en el navegador el\n 2026-09-02), así que el componente trae su propio envoltorio: `.banner-shell`.\n\n Y LA BASE PASÓ A SER LA ANGOSTA, que es el cambio que hace segura la falta del envoltorio. Con\n `@media` el caso base era escritorio y funcionaba igual sin nadie que hiciera nada; con\n `@container`, si no hay contenedor NINGUNA consulta calza y el componente se queda en su base. Con\n escritorio de base, un banner sin envoltorio volvía al defecto exacto que esta hoja arregló el\n 2026-09-02: ancho en una columna angosta. Con la angosta de base, lo peor que pasa es que se vea\n móvil donde había lugar para más — visible, y no roto.\n\n LA TIPOGRAFÍA SIGUE SUBIENDO Y NO BAJANDO: solo `large` en ancho sube de escala, así que va en el\n bloque `min-width` y nunca hay una regla que devuelva otra a su base. */\n\n/* EL ENVOLTORIO ES DEL COMPONENTE, no del consumidor, y por eso viaja en esta hoja y lo dibuja el\n wrapper de React. Pedirle al que usa el banner que declare `container-type` en el padre deja el\n comportamiento dependiendo de que se acuerde — que es el mismo defecto de la prop `viewport` que\n se sacó el 2026-09-02, movido un nivel más arriba. */\n.banner-shell {\n container-type: inline-size;\n container-name: banner;\n}\n\n.banner {\n display: grid;\n grid-template-columns: 1fr; /* la base es la ANGOSTA: contenido apilado sobre media */\n grid-template-rows: auto 1fr;\n overflow: hidden;\n position: relative;\n border-radius: var(--neo-radius-lg);\n background: var(--fill--base--default);\n}\n\n/* QUÉ FILA ES CUÁL, Y NO LO DECIDE EL ORDEN DEL MARCADO. La grilla angosta tiene una fila que\n ABRAZA y otra que se lleva el resto (`auto 1fr`), y el wrapper dibuja `media` ANTES que `content`,\n así que la colocación automática le daba la fila `auto` a la IMAGEN: se quedaba con lo que midiera\n su contenido —64px medidos en el visor— y el contenido se llevaba los 440 restantes.\n MEDIDO el 2026-09-04 sobre las variantes móviles apiladas del master: es al revés. `datos` es\n `vs=HUG` —281 en large, 217 en medium— y la imagen se lleva lo que sobra: 223 en large. La fila que\n abraza es la del CONTENIDO.\n SE COLOCA EXPLÍCITO en vez de reordenar, porque `order` también cambiaría de lado la imagen en la\n versión ancha, que es de dos columnas y ya está bien. `small` no entra: nunca apila. */\n.banner:not(.banner--small) .banner__content { grid-row: 1; }\n/* CUANDO LA FOTO ES EL FONDO NO ES UNA FILA, y omitir esta exclusión fue una regresión mía del\n 2026-09-04: al colocar la media en `grid-row: 2` para que el contenido se quedara con la fila que\n abraza, se le dio también a la versión `full-photo` — donde la media está `position: absolute` con\n `inset: 0`. Un hijo absoluto de una grilla CON posición de grilla definida toma como bloque\n contenedor SU ÁREA, no el contenedor entero: la foto arrancaba donde empieza la fila 2 y medía lo\n que esa fila, en vez de cubrir el banner. MEDIDO en Chrome: con contenido mínimo la foto salía en\n y=108 y 396 de alto dentro de un banner de 504 —el desfase es exactamente el alto de la fila de\n contenido—. El texto va en blanco sobre la foto, así que el efecto era texto blanco sobre fondo\n blanco. La regla de `full-photo` no podía ganar: es (0,2,0) contra los (0,3,0) de acá.\n NINGUNA STORY LO MOSTRABA: no hay combinación `device=mobile` × `full-photo=true`, y por eso el\n visor daba cero con el defecto puesto. Está anotado en la cola. */\n.banner:not(.banner--small):not(.banner--full-photo) .banner__media { grid-row: 2; }\n\n/* alto por size — los de la versión angosta, que es el caso base */\n.banner--large { height: 504px; }\n.banner--medium { height: 340px; }\n.banner--small { height: 148px; }\n\n/* `small` NUNCA APILA, ni angosto ni ancho: es una franja de una fila en las dos versiones del\n master. Iba suelta antes del reordenamiento y sigue suelta — meterla en el bloque ancho la haría\n apilar en angosto, que es lo contrario de lo que el master dibuja.\n LA FILA SE ACOTA A LA CAJA, y sin eso el alto fijo no se respetaba: con `none` la fila se\n auto-dimensiona al contenido, así que crecía a 108 dentro de una caja de 90 y los dos hijos\n desbordaban —medido en Chrome el 2026-09-04: `.banner` 90 de alto con `grid-template-rows: 108px`,\n y `overflow: hidden` tapándolo—. `minmax(0, 1fr)` hace que la fila SEA la caja. */\n.banner--small { grid-template-rows: minmax(0, 1fr); }\n\n/* EL PISO DE LA COLUMNA DE DATOS Y LA IMAGEN FIJA, como los dibuja el master (2026-09-15). En las\n variantes apiladas `datos` abraza su contenido con un PISO —281 en large, 217 en medium— y la\n imagen es FIJA —223 y 127— dentro de una raíz FIJA que recorta (`clipsContent`): 281 + 223 = 504.\n Hasta hoy la imagen se llevaba «lo que sobra» (`1fr`), así que a 320px, cuando el texto ocupa una\n línea más, la imagen se ACHICABA 20px y el visor lo acusaba contra los 223 del contrato. Ahora hace\n lo del master: la imagen conserva su alto y la raíz recorta lo que desborda, que es su pie. A 375\n se ve idéntico. El piso lo declara el contrato (`data.boxByAxis`, `minHeight`). */\n.banner--large:not(.banner--full-photo) { grid-template-rows: auto 223px; }\n.banner--medium:not(.banner--full-photo) { grid-template-rows: auto 127px; }\n.banner--large:not(.banner--full-photo) .banner__content { min-height: 281px; }\n.banner--medium:not(.banner--full-photo) .banner__content { min-height: 217px; }\n\n/* ── ancho (contenedor ≥ 720px): la versión de escritorio del master ── */\n@container banner (min-width: 720px) {\n .banner { grid-template-columns: 1fr 1fr; grid-template-rows: none; }\n /* En ancho son COLUMNAS: la colocación por fila de la versión apilada se deshace, o cada mitad\n caería en una fila implícita propia y volvería a apilar.\n EL SELECTOR REPITE `:not(.banner--small)` Y NO ES REDUNDANCIA — es lo que hace que la regla\n GANE. Una consulta de contenedor NO APORTA ESPECIFICIDAD: estar dentro de `@container` no le\n suma nada al selector. Así que `.banner__media` (una clase) perdía contra\n `.banner:not(.banner--small) .banner__media` (tres) de veinte líneas más arriba, y el reset no\n se aplicaba nunca. MEDIDO el 2026-09-04 en las 12 variantes: con el contenedor en 734px la\n consulta SÍ calzaba y `grid-template-columns` pasaba a `367px 367px` —misma especificidad entre\n `.banner` y `.banner`, gana la última— pero `media` se quedaba en `grid-row: 2` y las dos\n mitades salían con la MISMA `x`: la imagen debajo del texto, en la primera columna, en\n `desktop·large` y `desktop·medium`. Es el defecto que el owner reportó a ojo el mismo día\n —«los slots están a la izquierda y van a la derecha»— y ningún gate lo veía porque los dos\n arneses comparan la CAJA de cada parte y las dos cajas estaban bien: lo que estaba mal era\n dónde. `small` no lo sufría, y por eso parecía errático: su `:not()` la excluye de la regla base.\n La lección va con nombre: una regla dentro de `@container` o `@media` compite por especificidad\n como cualquier otra.\n Y SE COLOCA EXPLÍCITO EN VEZ DE DEVOLVER A `auto`, por lo mismo que ya decía el bloque de la\n versión angosta veinte líneas arriba: el wrapper dibuja `media` ANTES que `content`, así que la\n colocación automática le da la primera columna a la IMAGEN y el resultado sale espejado\n respecto del master —el texto a la derecha—. Medido: con `grid-row: auto` las dos mitades\n quedaban lado a lado pero al revés. En `full-photo` la imagen sale del flujo (`position:\n absolute`) y lo que importa es que el TEXTO quede en la columna 1: el gradiente oscurece la\n izquierda y el propio archivo lo mide más abajo —a mitad de ancho el contraste cae a 2.43:1—,\n así que el texto en la columna 2 sería un defecto de accesibilidad, no solo de espejo. */\n .banner:not(.banner--small) .banner__content { grid-column: 1; grid-row: 1; }\n /* LA MEDIA NO SE COLOCA EN `full-photo`, Y ES LA MITAD DE LA LECCIÓN DE ARRIBA QUE FALTABA.\n Un hijo de grid con `position: absolute` Y posición de grid DEFINIDA toma su ÁREA DE GRID como\n bloque contenedor, no el contenedor entero. Con `grid-column: 2` el `inset: 0` de `full-photo`\n dejaba de cubrir el banner y cubría solo la columna derecha: la foto quedaba al lado del texto\n en vez de detrás, y el texto —que en esta variante es `--text--base--contrast`— quedaba BLANCO\n SOBRE FONDO BLANCO. Invisible, no corrido.\n La regla de `grid-row` veinte líneas arriba ya excluía `full-photo`; ésta se escribió el mismo\n día sin la exclusión, con el comentario de al lado diciendo que la imagen sale del flujo.\n El contenido SÍ se sigue colocando: el texto tiene que quedar en la columna 1 porque el\n gradiente oscurece la izquierda, y eso vale igual con la foto de fondo.\n Sin posición definida el bloque contenedor vuelve a ser el padding-box del contenedor de grid,\n que es lo que `inset: 0` necesita. Medido contra el master, 2026-09-04. */\n .banner:not(.banner--small):not(.banner--full-photo) .banner__media { grid-column: 2; grid-row: 1; }\n .banner--large { height: 374px; }\n .banner--medium { height: 230px; }\n .banner--small { height: 90px; }\n /* Y EN ANCHO SE DESHACEN: son columnas, la imagen llena su mitad y el piso de `datos` no aplica\n —el master lo dibuja FILL dentro de la raíz fija—. Mismo selector que la regla angosta, así que\n gana por orden: una consulta de contenedor no suma especificidad. */\n .banner--large:not(.banner--full-photo),\n .banner--medium:not(.banner--full-photo) { grid-template-rows: none; }\n .banner--large:not(.banner--full-photo) .banner__content,\n .banner--medium:not(.banner--full-photo) .banner__content { min-height: auto; }\n}\n\n/* ── contenido ── */\n.banner__content {\n display: flex;\n flex-direction: column;\n justify-content: center;\n /* EL EJE CRUZADO, y faltaba entero. MEDIDO el 2026-09-04 sobre las 12 variantes del master:\n `datos` tiene `counterAxisAlignItems=MIN` en las doce. Sin esta línea `align-items` resolvía a\n `normal` —o sea `stretch`— y TODO hijo se estiraba al ancho de la columna, incluida la acción,\n que el master deja HUG en ocho de las doce. Es la mitad del defecto que el owner vio como «los\n slots no abarcan lo que deberían»: nadie lo veía porque el contrato no declaraba `disposicion`\n en ninguna parte y no había con qué comparar.\n LOS TEXTOS SÍ SE ESTIRAN, y por eso lo dicen ellos abajo: en el master son `hs=FILL`. En Figma\n el `MIN` del padre no los encoge —un hijo FILL llena igual—; en CSS `flex-start` los encogería,\n así que la diferencia entre las dos capas se paga acá, una vez, y no en cada hijo. */\n align-items: flex-start;\n gap: var(--neo-space-sm-rem);\n /* EL PADDING ES EL DE LA VERSIÓN ANGOSTA, y decirlo importa: 32 arriba (2xl) · 24 a los lados\n (xl) · 16 abajo (lg). La imagen queda pegada al borde inferior y el bloque respira más arriba\n que abajo.\n RE-MEDIDO por bridge el 2026-09-04 sobre las seis variantes sin foto: esos tres valores son los\n de `device=mobile` en large y medium —exactamente [32, 24, 16, 24]— y hasta hoy los aplicaba\n también la versión ANCHA, que el master mide con 32 en los cuatro lados. La medición de\n 2026-07-28 no estaba mal: estaba tomada de una sola de las dos versiones, y esta hoja la usaba\n para las dos. El bloque `@container` de abajo pone la ancha. Se destapó comparando el padding\n dibujado en el navegador contra el que el contrato declara para cada talla. */\n padding-top: var(--neo-space-2xl-rem);\n padding-inline: var(--neo-space-xl-rem);\n padding-bottom: var(--neo-space-lg-rem);\n min-width: 0;\n}\n\n/* EL ÍCONO SOLO EXISTE EN `large`, y hasta el 2026-09-04 la hoja lo dibujaba en las tres tallas.\n MEDIDO sobre las 12 variantes del master: `icon/brand` aparece en 4, y las cuatro son `size=large`.\n El hecho estaba escrito en el `_nota` del contrato desde siempre y ninguna capa lo hacía cumplir,\n así que un banner `medium` con ícono se dibujaba con un ícono que el master no tiene. Hoy lo\n declara `anatomy.icon.presenciaPorEje`, y se compara en las dos direcciones contra lo dibujado.\n SE APAGA EN LA HOJA Y NO EN EL WRAPPER a propósito: la prop sigue existiendo —el consumidor no\n tiene que saber en qué talla está para pasar el ícono— y las dos capas, la plana y el theme,\n heredan la regla de un solo lugar. */\n.banner:not(.banner--large) .banner__icon { display: none; }\n\n\n.banner__icon {\n display: flex;\n align-items: center;\n justify-content: center;\n width: var(--neo-icon-size-2xl-rem);\n height: var(--neo-icon-size-2xl-rem);\n flex-shrink: 0;\n}\n\n/* EL GLIFO LLENA SU ENVOLTORIO. El wrapper mete el ícono del CONSUMIDOR dentro de este div —no se\n le puede pedir una clase a un nodo que trae otro— y lo dimensiona acá; pero `icons.css` le fija a\n `.icon` sus propios 24, así que el envoltorio medía 40 y el dibujo 24, arriba a la izquierda.\n El mismo defecto se midió en `empty-state` el 2026-09-04 y se corrige igual. `.field__icon` ya\n tenía esta regla desde el ⓘ: era la tercera copia del mismo hecho con dos de ellas incompletas. */\n.banner__icon > svg {\n width: 100%;\n height: 100%;\n}\n\n/* LOS TRES TEXTOS SON `hs=FILL` EN LAS 12 VARIANTES —medido el 2026-09-04—, así que llenan el ancho\n de la columna aunque el padre alinee al inicio. En la fila de `small` ancho NO: ahí el master los\n mide `vs=HUG` y el estirado sería vertical, y por eso ese bloque los devuelve a `auto`. */\n/* Y NO SE ENCOGEN (2026-09-21). MEDIDO en Chrome con el paquete instalado, a 375 y con el texto en\n los topes que el MASTER declara —titulo 2 lineas, subtitulo 1, texto 3—: el titulo pedia 56 de\n alto y la caja le daba 51, el subtitulo 26 contra 24. `.banner__content` es una columna flex con\n piso de 281, asi que sus hijos se ENCOGIAN, y un bloque con `-webkit-line-clamp` que se encoge no\n recorta con puntos suspensivos: corta el glifo POR LA MITAD. Se veia en la captura antes de\n medirlo, y ninguno de los dos anchos de escritorio lo mostraba.\n EL MASTER HACE LO CONTRARIO: sus capas de texto tienen `textAutoResize: HEIGHT` con\n `textTruncation: ENDING`, o sea que CRECEN hasta su tope y la raiz FIJA recorta lo que sobre por\n el pie. `flex-shrink: 0` es esa conducta en CSS, y es la misma linea que la caja del icono ya\n tenia doce reglas mas arriba por el mismo motivo. */\n.banner__title,\n.banner__subtitle,\n.banner__description { align-self: stretch; flex-shrink: 0; }\n\n/* LA BAJADA CRECE EN DOS VARIANTES DE DOCE, y acotarlo es todo el punto de esta regla.\n El master, leído capa por capa el 2026-09-08 sobre las doce variantes de `masters-geometria`:\n\n description.vs = FILL → 2 de 12: mobile+small, con foto y sin ella\n description.vs = HUG → 10 de 12: LAS SEIS DE DESKTOP y los cuatro mobile restantes\n datos.pri = CENTER → 10 de 12: el grupo va CENTRADO, no repartido ni pegado al fondo\n\n O sea que la bajada solo toma el espacio libre en la columna ANGOSTA y CHICA. En las demás el\n grupo —ícono, título, bajada y acción— se centra junto, y el botón es parte de ese grupo.\n\n ESTA REGLA ESTUVO SIN ACOTAR MEDIO DIA Y ROMPIO ESCRITORIO. La versión anterior era\n `.banner__description { flex-grow: 1 }` a secas, escrita el mismo 2026-09-08 con este\n razonamiento: «`.banner--small` declara `flex: 1` desde su propio bloque, la variante angosta lo\n tenía y la base no» — leí una regla ACOTADA como un olvido y la generalicé. Era correcta. Dos\n errores encadenados, y el segundo es el que hay que recordar:\n\n 1. La regla de `.banner--small` que tomé como precedente vive dentro de\n `@container (min-width: 720px)` —la rama ANCHA, donde `small` es horizontal—, así que ese\n `flex: 1` crece DE LADO. Nunca fue el mismo eje que el que yo estaba escribiendo.\n 2. La medición que la respaldó —«baja de 18 diferencias a 3»— salió en verde igual, porque\n comparaba el `crece` que el CONTRATO declara una sola vez contra el dibujo, sin preguntar\n en qué variante el master lo declara. Un cero honesto sobre la pregunta equivocada: el\n contrato no distingue las doce, y la comparación heredó esa ceguera.\n\n LO ENCONTRO EL OWNER MIRANDO, no un gate: «el botón debe ir en el grupo del contenido, no\n alineado abajo; lo ajustaste para que quedara bien en mobile pero rompiste la desktop». Es\n textualmente la forma del defecto del CTA del 2026-09-07 —una regla de una rama aplicada a las\n dos— repetida por tercera vez en dos días, y las tres veces la cazó una persona. */\n.banner--small .banner__description { flex-grow: 1; }\n\n.banner__title {\n margin: 0 0 var(--neo-space-xs-rem);\n color: var(--text--base--default);\n font-size: var(--neo-type-title-md-bold-size-rem);\n font-weight: var(--neo-type-title-md-bold-weight);\n line-height: var(--neo-type-title-md-bold-line-height-rem);\n /* CORTA A 2 LÍNEAS, y no es una decisión de esta hoja: el master lo dibuja así\n (`banner·title.tipo.lineas = 2`, medido en el master el 2026-09-03). Sin la regla el texto\n ENVUELVE: medido en Chrome, 10 líneas y 280 px de alto donde el master dibuja 2.\n El andamio de tres declaraciones es el que\n `-webkit-line-clamp` necesita; es el mismo que `banner__description` ya usaba. */\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n overflow: hidden;\n}\n\n/* desktop large sube la jerarquía tipográfica */\n@container banner (min-width: 720px) {\n .banner--large .banner__title {\n font-size: var(--neo-type-title-lg-bold-size-rem);\n line-height: var(--neo-type-title-lg-bold-line-height-rem);\n }\n}\n\n.banner__subtitle {\n margin: 0;\n color: var(--text--base--default);\n font-size: var(--neo-type-title-sm-bold-size-rem);\n font-weight: var(--neo-type-title-sm-bold-weight);\n line-height: var(--neo-type-title-sm-bold-line-height-rem);\n /* CORTA A UNA LÍNEA CON PUNTOS SUSPENSIVOS, y lo dibuja así el master\n (`banner·subtitle.tipo.lineas = 1`, medido en el master el 2026-09-03). Sin la regla el texto\n ENVUELVE: medido en Chrome, 10 líneas y 260 px de alto donde el master dibuja una.\n Para UNA línea el recurso es `text-overflow`, no\n `-webkit-line-clamp`: no toca el `display` y por eso no pisa ningún `flex`. Es el mismo idioma\n que `menu__label` y `select__value` ya usaban. */\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n@container banner (min-width: 720px) {\n .banner--large .banner__subtitle {\n font-size: var(--neo-type-title-md-bold-size-rem);\n line-height: var(--neo-type-title-md-bold-line-height-rem);\n }\n}\n\n.banner__description {\n margin: 0;\n color: var(--text--base--default);\n font-size: var(--neo-type-body-md-regular-size-rem);\n font-weight: var(--neo-type-body-md-regular-weight);\n line-height: var(--neo-type-body-md-regular-line-height-rem);\n /* el banner no crece con el texto: trunca (extensiones máximas en el spec) */\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 3;\n overflow: hidden;\n}\n\n@container banner (min-width: 720px) {\n .banner--large .banner__description {\n font-size: var(--neo-type-body-lg-regular-size-rem);\n line-height: var(--neo-type-body-lg-regular-line-height-rem);\n }\n}\n\n.banner__actions {\n display: flex;\n gap: var(--neo-space-md-rem);\n padding-top: var(--neo-space-sm-rem);\n /* `counterAxisAlignItems=CENTER` en las 12 variantes, medido el 2026-09-04. Sin esto resolvía a\n `normal` y el CTA se estiraba en vertical dentro de la fila. */\n align-items: center;\n}\n\n/* CUÁNDO LA FILA DE ACCIÓN SE ESTIRA. MEDIDO el 2026-09-04 sobre las 12: `actions` es `hs=FILL` en\n cuatro —`desktop·large` sin foto, `mobile·large` con y sin foto, y `mobile·medium` con foto— y HUG\n en las otras ocho. Las cuatro NO comparten un eje, así que no hay regla que derivarlas: se\n enumeran, igual que su `ajustePorEje` en el contrato.\n QUE ESTO CAMBIE POCO PÍXEL ES CIERTO Y NO ES MOTIVO PARA NO ESCRIBIRLO: mientras el CTA de adentro\n sea HUG, una fila estirada y una que abraza dibujan lo mismo. La diferencia aparece cuando el\n botón también llena, que es un hecho de OTRO componente —quién impone la talla de lo que se\n compone— y no se decide acá. Que el master no siga una regla en esas cuatro está anotado en la\n cola: normalizarlo es del owner, y el código espeja mientras tanto. */\n.banner--large .banner__actions { align-self: stretch; }\n/* Y `medium` CON FOTO también, que es la cuarta de las que el master estira y la única que no cae\n bajo la regla de arriba. Sin ella el CTA se abrazaba a su etiqueta en la única variante donde el\n master lo manda a todo el ancho — el defecto que el QA visual reportó, y que ninguna story\n dibujaba porque es el cruce `device × full-photo`. */\n.banner--medium.banner--full-photo .banner__actions { align-self: stretch; }\n\n/* Y EL BOTON TAMBIEN LLENA, en DOS celdas y no en «mobile» (2026-09-07). Es el hecho que el bloque\n de arriba nombraba y dejaba pendiente —«la diferencia aparece cuando el boton tambien llena»— y\n ahora esta medido, en las 12 variantes y por dos metodos que coinciden: la foto de geometria y\n una corrida de bridge en vivo.\n\n DONDE LLENA: mobile · full-photo · large → actions 295 FILL · boton 295 FILL\n mobile · full-photo · medium → actions 295 FILL · boton 295 FILL\n DONDE NO: las otras diez. El boton abraza en 116 o 123.\n\n LAS DOS TRAMPAS DE ESTE HECHO, que no se ven si uno lo enuncia como «en mobile el CTA va al ancho»:\n · `size=small` NUNCA llena, ni con foto ni sin ella. La regla no es por dispositivo.\n · `actions` y el BOTON no siempre coinciden: en `mobile · large · SIN foto` la fila llena (295) y\n el boton abraza (123). Medir el contenedor y concluir sobre el boton da lo contrario justo ahi.\n\n SE DECLARA EN LA FILA Y NO EN EL HIJO, y las dos razones importan. Esta hoja la consumen las dos\n capas —la plana pone `.btn` y React monta `.MuiButton-root`— asi que nombrar la clase del boton\n ataria la hoja a un framework y romperia la regla 6b; y un `> *` dibuja una caja que no pertenece a\n ninguna parte declarada — con razon, porque la capa `button` del master NO esta en la anatomia del\n contrato. `.banner__actions` SI esta: es la parte `action`.\n Una fila en `grid` estira a su hijo por defecto, asi que el hecho del master —el hijo llena— se\n dice como disposicion de la fila, que es de quien el contrato ya habla. Vale porque el CTA es UNO\n SOLO: lo fija C1 y lo gatea `conducta`. Con dos hijos grid y flex no dibujarian lo mismo.\n LO QUE ESTO NO CIERRA, dicho para que no se lea como completo: el master mide ese boton en 295,\n 123 y 116 segun la celda, y ningun arnes lo vigila porque la parte no existe en el contrato.\n Declararla es del tramo 2, y es el mismo caso que el `cta` de `modal-fullscreen`. */\n.banner--full-photo.banner--large .banner__actions,\n.banner--full-photo.banner--medium .banner__actions { display: grid; }\n\n/* Y EL ENVOLTORIO NO CUENTA COMO CAJA (2026-09-07, segunda vuelta). La fila en `grid` estira a su\n HIJO DIRECTO, y en React ese hijo no siempre es el boton: la muestra lo entrega dentro de un\n `<div>` sin clase, asi que el envoltorio se estiraba a 295 y el boton se quedaba en 117. Medido en\n Chrome: `div.banner__actions[396] > div[396] > button[94]`.\n `display: contents` saca a ese envoltorio del arbol de cajas — que es lo que el master dice, porque\n ahi `actions` contiene al `button` y no hay nodo intermedio— y el boton pasa a ser el hijo de la\n grilla. La capa plana no tiene envoltorio, asi que no cambia nada.\n SE EXCLUYEN `button` y `a` A PROPOSITO: si quien lo usa pasa el boton directo, ese `> *` seria el\n boton mismo, y `display: contents` sobre un boton le borra la caja. La regla tiene que valer para\n el envoltorio y ser inofensiva cuando no hay ninguno. */\n.banner--full-photo.banner--large .banner__actions > :not(button):not(a),\n.banner--full-photo.banner--medium .banner__actions > :not(button):not(a) { display: contents; }\n\n/* ── media: la imagen llena la caja y se recorta ── */\n/* LA IMAGEN SE RECORTA, Y ESO LO DECLARA EL MASTER (2026-09-05). MEDIDO por bridge sobre las DOCE\n variantes: las doce traen `clipsContent: true`, o sea que el marco de la imagen recorta siempre.\n Acá había `min-width: 0` y no su hermano vertical, que es la trampa clásica de grid y flex: un\n ítem trae `min-height: auto`, así que NO puede encogerse bajo su contenido — y una imagen impone\n su alto intrínseco. MEDIDO en Chrome: el ejemplo es 800×600, y a 639 de ancho pedía 479 de alto\n dentro de un banner de 374. Desbordaba en 5 de 12 casos, hasta 249px, y solo con `full-photo=false`\n —con foto completa la imagen ES el banner y encaja sola—. Lo reportó el owner: «revisar slot, en\n algunas size se desborda».\n `overflow: hidden` espeja el `clipsContent` del master; `min-height: 0` es lo que permite que el\n `height: 100%` de la imagen tenga contra qué resolverse. */\n.banner__media { position: relative; min-width: 0; min-height: 0; overflow: hidden; }\n.banner__media img { width: 100%; height: 100%; object-fit: cover; display: block; }\n\n/* SMALL: IMAGEN A UN COSTADO, CON EL ANCHO Y EL LADO QUE EL MASTER DIBUJA.\n MEDIDO por bridge el 2026-09-04 sobre el master 40003685:37499: en `small` el reparto NO es libre\n —`datos` 235 e `image` 108 en angosto, `datos` 900 e `image` 280 en ancho— y `datos` va a la\n IZQUIERDA con la imagen a la derecha, en las dos versiones.\n ACÁ HABÍA `1fr auto`, y eso hacía dos cosas mal a la vez. El `auto` dejaba que la imagen se\n quedara con lo que quisiera —medido en el visor: 609.7 contra los 280 del master— y como el\n wrapper dibuja `media` ANTES que `content`, la columna `1fr` era la de la imagen: salía a la\n IZQUIERDA, espejada respecto del master. El propio contrato lo decía sin que nadie lo comparara\n («`right` primero porque es la que la capa plana dibuja sin clase»); dibujaba la otra. */\n.banner--small { grid-template-columns: 1fr 108px; }\n.banner--small .banner__media { order: 1; }\n.banner--small .banner__content {\n /* el master mide 16 arriba y abajo, 12 a los lados, en la versión angosta */\n padding: var(--neo-space-lg-rem) var(--neo-space-md-rem);\n}\n.banner--small.banner--image-left { grid-template-columns: 108px 1fr; }\n.banner--small.banner--image-left .banner__media { order: -1; }\n\n/* ── la versión ANCHA de lo que se define arriba ─────────────────────────────────────────────────\n VA ACÁ Y NO EN EL PRIMER BLOQUE `@container`, y el orden no es cosmético: estas reglas PISAN a las\n base, y una regla de la misma especificidad solo gana si viene después. Escritas arriba, las\n longhands de `.banner__content` y el reparto de `.banner--small` —que se declaran más abajo— las\n anulaban en silencio: el alto sí cambiaba con el contenedor y el padding y las columnas no. Medido\n en el navegador el 2026-09-04, que es lo que hizo visible la diferencia entre las dos mitades\n de la misma consulta. */\n@container banner (min-width: 720px) {\n /* 32 en los CUATRO lados, medido en `device=desktop` large y medium. La regla base pone los de la\n versión angosta (32 arriba · 24 a los lados · 16 abajo) y sin esto los aplicaba también acá. */\n .banner__content { padding: var(--neo-space-2xl-rem); }\n /* `small` no reparte a la mitad en ninguna de las dos versiones: 280 de imagen y el resto de datos\n —900 de 1180—, y su padding es 16 arriba y abajo con 32 a los lados. */\n .banner--small { grid-template-columns: 1fr 280px; }\n /* EN ANCHO, `small` PONE SU TEXTO Y SU ACCIÓN EN UNA FILA, y esa es la diferencia que hacía que no\n entrara en 90 de alto. MEDIDO por bridge el 2026-09-04 sobre `device=desktop, size=small`: dentro\n de `datos` hay UN `content-row` de 836×32, HORIZONTAL, gap 24, centrado y repartido a los\n extremos, con el texto FILL y las acciones HUG. En angosto no: ahí `datos` es VERTICAL con la\n bajada arriba y la acción abajo. Apilado, el contenido pedía 108 y la caja da 90. */\n .banner--small .banner__content {\n padding: var(--neo-space-lg-rem) var(--neo-space-2xl-rem);\n flex-direction: row;\n align-items: center;\n /* `primaryAxisAlignItems=MAX`, medido: el master empuja al FIN, no reparte a los extremos. Con la\n bajada en `flex: 1` las dos dibujan el mismo píxel, y por eso el error sobrevivió; se escribe\n el que el master tiene. */\n justify-content: flex-end;\n gap: var(--neo-space-xl-rem);\n }\n /* En ancho, `large` CON foto abraza: es la única de las cuatro `large` que el master mide HUG.\n Y `medium` CON foto TAMBIÉN, y su deshecho faltaba (2026-09-07). El `align-self: stretch` de la\n rama angosta —el que manda el CTA a todo el ancho en móvil— no se deshacía para `medium`, así que\n en escritorio la fila se estiraba a 526 donde el master mide 116 HUG.\n NO SE VEÍA HASTA HOY, y el motivo importa: mientras el ejemplo envolvía su acción en un `<div>`,\n la clase `banner__actions` la llevaba ese `<div>` y el botón adentro no se enteraba. Al sacar el\n envoltorio —que es lo que arregló el CTA que el owner reportó tres veces— la clase pasó al botón\n y el estirado quedó a la vista. Es la bomba de tiempo que la otra sesión había nombrado sobre\n esta misma regla: «hoy no se ve porque el botón abraza, pero se vería en cuanto llenara».\n Medido en las 12 celdas, por container query y no por viewport. */\n .banner--large.banner--full-photo .banner__actions,\n .banner--medium.banner--full-photo .banner__actions { align-self: flex-start; }\n /* Y EL BOTON VUELVE A ABRAZAR: en escritorio el master lo mide HUG en las TRES tallas con foto\n —123/116/116—, asi que el `width: 100%` de la base es de movil y solo de movil. Sin este\n deshecho, `medium` CON foto lo dibujaria al ancho en escritorio: su fila ya viene estirada desde\n la base y nadie la deshace aca. Hoy eso no se ve porque el boton abraza — se veria recien al\n hacerlo llenar, que es lo que acaba de cambiar. */\n .banner--full-photo.banner--large .banner__actions,\n .banner--full-photo.banner--medium .banner__actions { display: flex; }\n .banner--full-photo.banner--large .banner__actions > :not(button):not(a),\n .banner--full-photo.banner--medium .banner__actions > :not(button):not(a) { display: block; }\n /* La fila de `small` mide `vs=HUG` en el master, no estirada: se deshace el `stretch` de arriba. */\n .banner--small .banner__title,\n .banner--small .banner__subtitle,\n .banner--small .banner__description { align-self: auto; }\n /* `min-height: 0` JUNTO AL `flex: 1`, y su ausencia recortaba el texto A MITAD DE LINEA. Un item de\n flex no encoge por debajo de su contenido salvo que se le permita —`min-height` resuelve a `auto`—,\n asi que la bajada se quedaba con su alto natural, empujaba a las acciones fuera de la caja y el\n boton terminaba DIBUJADO ENCIMA del texto. Se ve en `small` con foto y sin ella.\n NINGUNA MEDICION LO CAZO, y por eso se anota: los arneses comparan anchos, altos y distancias al\n fondo — ninguno pregunta si dos cajas se pisan. Lo encontro una CAPTURA, la primera que se saco\n de este componente en toda la jornada.\n El `line-clamp: 3` se queda: es el que hace que la bajada no crezca. Lo que faltaba era dejarla\n encoger para que el clamp actue dentro de la caja y no fuera. */\n .banner--small .banner__description { flex: 1; min-height: 0; }\n .banner--small .banner__actions { flex: none; align-self: auto; }\n .banner--small.banner--image-left { grid-template-columns: 280px 1fr; }\n}\n\n/* ── full photo: imagen de fondo + overlay + texto en contraste ── */\n.banner--full-photo .banner__media {\n position: absolute;\n inset: 0;\n}\n\n/* overlay: gradiente negro 70%→0 — constante de diseño, no se tokeniza (los gradientes no se tokenizan).\n El gradiente hace DOS cosas a la vez, y por eso se apaga hacia la derecha: oscurece la zona donde\n se ubica el texto, y deja ver la foto en el resto. Es intencional, no un descuido.\n WCAG 1.4.3: el texto blanco cumple AA mientras quede en el tercio oscuro — a 0.7 da 8.45:1 sobre\n una foto clara. Nunca quitarlo ni bajarle la opacidad, y **el texto no debe pasar de la mitad**:\n más allá el overlay ya no alcanza (a mitad de ancho, 2.43:1). Importa sobre todo en `--mobile`,\n donde el grid es de una sola columna y el contenido puede cruzar todo el ancho. */\n.banner--full-photo .banner__media::after {\n content: \"\";\n position: absolute;\n inset: 0;\n background: linear-gradient(90deg, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0) 100%);\n}\n\n.banner--full-photo .banner__content { position: relative; z-index: 1; }\n\n/* ── LA VERSIÓN ANGOSTA CON FOTO, que hasta el 2026-09-04 no existía como reglas ─────────────────\n MEDIDO sobre las seis variantes `full-photo` del master. Con la foto de fondo el bloque de\n contenido deja de apoyarse en el borde de abajo, así que su relleno vertical pasa a ser SIMÉTRICO\n —32 arriba y 32 abajo, contra 32/16 sin foto— y el reparto vertical cambia: `large` arranca\n ARRIBA y `medium` reparte a los EXTREMOS, en vez de centrar.\n NINGUNA STORY LAS DIBUJABA: `device` no es una prop, así que su cruce con `full-photo` no tenía\n ejemplo, y estas cuatro reglas faltaban sin que nada las reclamara. Las destapó la MATRIZ al\n empezar a medirse celda por celda —sus celdas miden ~296px, o sea por debajo del corte— y esa es\n la respuesta al hueco de combinaciones para este componente: la matriz YA los cruza. */\n/* `small` NO: ahí el master mantiene sus 16 abajo con foto y sin ella. */\n.banner--full-photo:not(.banner--small) .banner__content { padding-bottom: var(--neo-space-2xl-rem); }\n.banner--large.banner--full-photo .banner__content { justify-content: flex-start; }\n/* Y EL BLOQUE TIENE QUE LLENAR EL ALTO, o el reparto no reparte nada (2026-09-07). El\n `space-between` de abajo estaba puesto desde el 2026-09-04 y NO movia el boton: medido en la\n celda `mobile · medium · full-photo`, el boton quedaba a **130 px del fondo** del banner y en\n `large` a **238**, donde el master lo pone a 32 —su relleno inferior—. La causa es que\n `.banner__content` abrazaba su contenido en vez de estirarse, asi que no habia espacio libre que\n distribuir: `space-between` sobre una caja que mide lo que miden sus hijos no hace nada.\n EL MASTER LO CONSIGUE POR DOS CAMINOS y los dos terminan igual: en `medium`, `datos` es\n `SPACE_BETWEEN` con `vs=FILL`; en `large`, `datos` empaqueta arriba pero su `Content Container` es\n `vs=FILL`, asi que estira y empuja las acciones al fondo. Aca se realizan con lo mismo —el bloque\n llena y reparte a los extremos—, porque con exactamente dos hijos el resultado es el mismo y una\n sola regla es mas facil de no romper que dos.\n Lo pidio el owner con estas palabras: «el contenido de arriba debe empujar el boton hasta el final\n del banner: titulo, bajada, espacio libre, y el boton abajo». */\n/* CON FOTO, LA GRILLA ES DE UNA SOLA FILA — y ésta es la causa real, que costó dos intentos.\n La base declara `grid-template-rows: auto 1fr`: el contenido arriba y la media abajo. Con foto la\n media pasa a `position: absolute; inset: 0`, o sea que SALE del flujo… **pero su fila se queda**.\n El contenido ocupaba la primera (`auto`, o sea que abrazaba) y el `1fr` vacío se comía el resto:\n medido, `.banner__content` 242 de un banner de 340, y 298 de 504. Por eso el `space-between` no\n movía nada —no había espacio libre DENTRO del bloque, estaba todo afuera— y por eso `height: 100%`\n tampoco: el 100% era el de la fila, no el del banner.\n Con una sola fila el contenido la ocupa entera y ahí sí reparte. */\n.banner--full-photo:not(.banner--small) { grid-template-rows: minmax(0, 1fr); }\n/* EL HUECO VA ANTES DEL BOTON, NO ENTRE TODOS. `space-between` reparte el espacio libre entre TODOS\n los hijos, y aca son cinco —icono, titulo, subtitulo, descripcion y acciones—: medido, quedaban a\n 59, 64, 59 y 60 px de distancia entre si, o sea el texto desparramado por toda la foto. El owner lo\n pidio al reves y con estas palabras: «titulo, bajada, espacio libre, y el boton abajo» — el\n contenido JUNTO arriba y el hueco entero antes del boton.\n `margin-top: auto` en las acciones hace exactamente eso: los textos se apilan con su `gap` normal y\n el sobrante se acumula en un solo lugar. Es tambien lo que el master dibuja: su `Content Container`\n agrupa los textos y ESTIRA, asi que el hueco queda entre ese bloque y las acciones, no adentro. */\n.banner--large.banner--full-photo .banner__content,\n.banner--medium.banner--full-photo .banner__content { justify-content: flex-start; }\n.banner--large.banner--full-photo .banner__actions,\n.banner--medium.banner--full-photo .banner__actions { margin-top: auto; }\n/* Y la fila de acción se apoya abajo en `large` con foto: `counterAxisAlignItems=MAX`, la única de\n las doce variantes donde el master lo hace. */\n.banner--large.banner--full-photo .banner__actions { align-items: flex-end; }\n.banner--full-photo .banner__title,\n.banner--full-photo .banner__subtitle,\n.banner--full-photo .banner__description { color: var(--text--base--contrast); }\n\n/* ── LA VERSIÓN ANCHA DE LO QUE SE DEFINIÓ CON FOTO ──────────────────────────────────────────────\n VA AL FINAL, y el orden es el arreglo: las reglas de arriba son de la versión ANGOSTA y están\n escritas DESPUÉS del primer bloque `@container`, así que se colaban a la ancha —una regla de la\n misma especificidad solo pierde si la otra viene después—. Es la misma trampa que esta hoja ya\n pagó dos veces hoy, en las dos direcciones.\n MEDIDO: las seis variantes de escritorio centran su contenido y su fila de acción, con foto y sin\n ella. El master solo mueve el reparto en la versión angosta. */\n@container banner (min-width: 720px) {\n .banner--large.banner--full-photo .banner__content,\n /* Y EN ANCHO EL BOTON VUELVE A AGRUPARSE CON EL CONTENIDO. El owner lo reporto: «moviste el boton de\n la version desktop hacia abajo, y las dos variantes tienen comportamientos diferentes — en desktop\n el boton SI esta agrupado al contenido, solo en mobile pasa a alinearse abajo». Tenia razon y era\n una regresion mia: el `margin-top: auto` que empuja la accion al fondo en movil no estaba acotado\n a la rama angosta, asi que se colaba a escritorio.\n MEDIDO en el master sobre las tres variantes `desktop · full-photo`: `datos` es `VERTICAL` con\n `primaryAxisAlignItems: CENTER` y `vs: FILL` en las tres — o sea el bloque entero CENTRADO, con\n las acciones pegadas al contenido (a 67 px del fondo en `large`, 30 en `medium`, 29 en `small`),\n no ancladas abajo. Se deshacen las dos mitades: el reparto y el margen. */\n .banner--large.banner--full-photo .banner__content,\n .banner--medium.banner--full-photo .banner__content { justify-content: center; }\n .banner--large.banner--full-photo .banner__actions,\n .banner--medium.banner--full-photo .banner__actions { margin-top: 0; }\n .banner--large.banner--full-photo .banner__actions { align-items: center; }\n}\n\n/* empty-state.css — entregado por el theme; su fuente es packages/foundations/css/empty-state.css */\n/* empty-state.css — Neo\n Spec: empty-state.md\n Ocupa el lugar de un contenido que no existe y dice qué se puede hacer. No tiene base de MUI que\n vestir (`composicion.requerida`), así que ESTA ES SU ÚNICA CAPA DE ESTILO: no hay `styleOverrides`\n que la duplique, y lo que no esté acá no lo dibuja nadie.\n\n LA VERSIÓN LA ELIGE EL ANCHO DISPONIBLE, y el contrato lo declara como dato —no como prosa—:\n `ejesNoExpuestos.device.realizacion` dice `container` · contenedor `empty-state` · base `mobile`,\n y `corte` dice 720. Esta hoja no elige el mecanismo: lo obedece, y una prueba compara las dos.\n\n POR QUÉ `@container` Y NO `@media`: este bloque es FILL horizontal y vive dentro de paneles,\n columnas y modales de ancho arbitrario, así que el viewport no dice cuánto espacio tiene. Una\n `@media` lo dibujaría ancho dentro de una columna angosta de una pantalla grande, que es el caso\n exacto que C9 describe. Precedente y mismo motivo: `banner`.\n\n Y LA BASE ES LA ANGOSTA a propósito. Un contenedor no se consulta a sí mismo, así que sin el\n envoltorio ninguna consulta calza y el bloque se queda en su base: con la móvil de base, lo peor\n que pasa es que se vea angosto donde sobraba lugar — visible, y no roto.\n\n EL TEXTO SE CENTRA, y desde el 2026-09-03 es un dato medido y no un hueco: `textAlignHorizontal`\n de `title` y `description` es CENTER en las dos variantes del master (40004417:25762, leído por\n bridge). NO se deriva del padre — las tres capas son FILL, así que el `alinea: centro` del\n auto-layout centra la CAJA y no los glifos; son dos hechos distintos y hacían falta los dos. El\n contrato lo declara en `tipo.alinea`, que es la ranura que faltaba. */\n\n/* EL ENVOLTORIO ES DEL COMPONENTE, no del consumidor, y por eso viaja en esta hoja y lo dibuja el\n wrapper de React. Pedirle a quien usa el bloque que declare `container-type` en el padre deja el\n comportamiento dependiendo de que se acuerde. Mismo caso que `.banner-shell`. */\n.empty-state-shell {\n container-type: inline-size;\n container-name: empty-state;\n}\n\n/* ── la raíz: es una TARJETA — pinta su propio fondo y su radio ── */\n.empty-state {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n gap: var(--neo-space-lg-rem);\n padding-block: var(--neo-space-xl-rem);\n padding-inline: var(--neo-space-xl-rem);\n border-radius: var(--neo-radius-md);\n background: var(--surface--base--default);\n}\n\n/* El ANCHO no lo fija (C10): es FILL y lo pone el contenedor. */\n\n@container empty-state (min-width: 720px) {\n .empty-state {\n gap: var(--neo-space-xl-rem);\n padding-block: var(--neo-space-3xl-rem);\n padding-inline: var(--neo-space-2xl-rem);\n }\n}\n\n/* ── el cuerpo: la pastilla y los dos textos ──\n El 480 es un TOPE DE MEDIDA DE LÍNEA, no un ancho de componente: sin él el texto se estira a 1528\n en las pantallas anchas y deja de leerse. No contradice C10 — el ancho del bloque sigue siendo del\n contenedor. */\n.empty-state__body {\n display: flex;\n flex-direction: column;\n justify-content: flex-start;\n align-items: center;\n gap: var(--neo-space-md-rem);\n width: 100%;\n max-width: 480px;\n}\n\n@container empty-state (min-width: 720px) {\n .empty-state__body { gap: var(--neo-space-lg-rem); }\n}\n\n/* ── la pastilla del ícono ──\n La medida NO es suelta: 24 de ícono + 12 de padding por lado = 48, y 32 + 16 + 16 = 64. Escala\n con el ícono, y por eso el radio escala con ella (12 → 16). `box-sizing: border-box` viene de\n `index.css`, así que el 48 INCLUYE el padding, como en Figma. */\n.empty-state__icon-box {\n display: flex;\n flex-direction: row;\n justify-content: flex-start;\n align-items: center;\n gap: var(--neo-space-none-rem);\n width: 48px;\n height: 48px;\n padding-block: var(--neo-space-md-rem);\n padding-inline: var(--neo-space-md-rem);\n border-radius: var(--neo-radius-md);\n background: var(--fill--base--light);\n flex-shrink: 0;\n}\n\n@container empty-state (min-width: 720px) {\n .empty-state__icon-box {\n width: 64px;\n height: 64px;\n padding-block: var(--neo-space-lg-rem);\n padding-inline: var(--neo-space-lg-rem);\n border-radius: var(--neo-radius-lg);\n }\n}\n\n/* El ícono lo elige el consumidor, y sale de `icon/system` — nunca `semantic` ni `brand`.\n `color` además de `fill`: los íconos system traen `fill=\"currentColor\"` en su path, así que una\n regla con solo `fill` no pinta nada.\n\n LA CLASE VA SOBRE EL `<svg>`, que es lo que la ficha enseña. En React va sobre un `<div>` que lo\n envuelve, porque el nodo lo trae quien consume y no se le puede agregar una clase — por eso ese\n envoltorio es un `div` y no un `span`: uno inline ignoraría estas medidas. */\n.empty-state__icon {\n display: flex;\n align-items: center;\n justify-content: center;\n width: var(--neo-icon-size-md-rem);\n height: var(--neo-icon-size-md-rem);\n color: var(--icon--base--default);\n fill: var(--icon--base--default);\n flex-shrink: 0;\n}\n\n/* EL GLIFO LLENA SU ENVOLTORIO, y sin esta regla el envoltorio medía bien y el dibujo no.\n `icons.css` le fija a `.icon` un tamaño propio —`--neo-icon-size-md`, 24— y el consumidor entrega\n ese nodo tal cual. En móvil coincide por casualidad (24 en una caja de 24) pero en desktop el\n envoltorio pasa a 32 y el glifo se queda en 24: queda arriba a la izquierda, y la pastilla de 64\n con padding 16 lo muestra DESCENTRADO. Medido en Chrome el 2026-09-04 y reportado por el owner\n mirando Storybook — dos veces, porque la primera corrección fue a otro lado.\n El master no tiene este problema porque ahí el ícono ES la instancia: mide 32 en desktop y 24 en\n mobile, y son las dos cifras que el snapshot de geometría guarda. Acá el tamaño lo pone el\n envoltorio —que es el que sabe del contenedor— y el glifo lo obedece. */\n.empty-state__icon > svg {\n width: 100%;\n height: 100%;\n}\n\n@container empty-state (min-width: 720px) {\n .empty-state__icon {\n width: var(--neo-icon-size-lg-rem);\n height: var(--neo-icon-size-lg-rem);\n }\n}\n\n/* ── el texto: título y bajada van PEGADOS a propósito — el interlineado ya los separa ── */\n.empty-state__content {\n display: flex;\n flex-direction: column;\n justify-content: flex-start;\n align-items: center;\n gap: var(--neo-space-none-rem);\n width: 100%;\n}\n\n/* El NIVEL del encabezado lo pone la pantalla, no el componente: el mismo bloque es `h2` en una\n página y `h3` dentro de una sección. Por eso la regla cuelga de la clase y no del elemento.\n El corte a una línea es del MASTER (`maxLines: 1`), no una decisión de esta hoja: está en\n `topes` del contrato. */\n.empty-state__title {\n text-align: center;\n margin: 0;\n color: var(--text--base--default);\n font-size: var(--neo-type-body-lg-bold-size-rem);\n font-weight: var(--neo-type-body-lg-bold-weight);\n line-height: var(--neo-type-body-lg-bold-line-height-rem);\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 1;\n overflow: hidden;\n}\n\n/* El master BAJA de escala en móvil: `Title/sm-Bold` 18/26 arriba, `Body/lg-Bold` 16/24 abajo. Los\n dos son estilos con NOMBRE del sistema; ninguno es una talla suelta. */\n@container empty-state (min-width: 720px) {\n .empty-state__title {\n font-size: var(--neo-type-title-sm-bold-size-rem);\n font-weight: var(--neo-type-title-sm-bold-weight);\n line-height: var(--neo-type-title-sm-bold-line-height-rem);\n }\n}\n\n/* La bajada es OBLIGATORIA: un título solo dice QUÉ falta y no QUÉ HACER. Corte a dos líneas por\n `maxLines: 2` del master. */\n.empty-state__description {\n text-align: center;\n margin: 0;\n color: var(--text--base--secondary);\n font-size: var(--neo-type-body-md-regular-size-rem);\n font-weight: var(--neo-type-body-md-regular-weight);\n line-height: var(--neo-type-body-md-regular-line-height-rem);\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n overflow: hidden;\n}\n\n@container empty-state (min-width: 720px) {\n .empty-state__description {\n font-size: var(--neo-type-body-lg-regular-size-rem);\n font-weight: var(--neo-type-body-lg-regular-weight);\n line-height: var(--neo-type-body-lg-regular-line-height-rem);\n }\n}\n\n/* ── las acciones ──\n Los dos botones son instancias de `button` que aporta el consumidor: los dibuja `button.css`, y\n esta hoja SOLO los ubica. El secundario va a la izquierda, el primario a la derecha.\n\n APILAN CUANDO NO ENTRAN, y el master no lo puede dibujar (no tiene media queries): con dos labels\n reales el grupo pasa de 360 y el bloque vive en columnas más angostas que eso. Se resuelve con\n `wrap` y NO con un segundo corte: un número en píxeles acá sería una medida que ninguna capa\n declara, y `wrap` apila exactamente cuando dejan de entrar, que es la condición real. */\n.empty-state__actions {\n display: flex;\n flex-direction: row;\n justify-content: flex-start;\n align-items: flex-start;\n gap: var(--neo-space-sm-rem);\n flex-wrap: wrap;\n}\n";
2
+ export const HOJAS_PLANAS = ".icon { display: inline-flex; flex-shrink: 0; width: var(--neo-icon-size-md); height: var(--neo-icon-size-md); }\n.icon--xs { width: var(--neo-icon-size-xs); height: var(--neo-icon-size-xs); }\n.icon--sm { width: var(--neo-icon-size-sm); height: var(--neo-icon-size-sm); }\n.icon--md { width: var(--neo-icon-size-md); height: var(--neo-icon-size-md); }\n.icon--lg { width: var(--neo-icon-size-lg); height: var(--neo-icon-size-lg); }\n.icon--xl { width: var(--neo-icon-size-xl); height: var(--neo-icon-size-xl); }\n.icon--2xl { width: var(--neo-icon-size-2xl); height: var(--neo-icon-size-2xl); }\n.icon--3xl { width: var(--neo-icon-size-3xl); height: var(--neo-icon-size-3xl); }\n.icon--4xl { width: var(--neo-icon-size-4xl); height: var(--neo-icon-size-4xl); }\n\n.icon { color: var(--icon--base--default); }\n.icon--secondary { color: var(--icon--base--secondary); }\n.icon--disabled { color: var(--icon--base--disabled); }\n.icon--inverse { color: var(--icon--base--contrast); }\n.banner-shell {\n container-type: inline-size;\n container-name: banner;\n}\n\n.banner {\n display: grid;\n grid-template-columns: 1fr;\n grid-template-rows: auto 1fr;\n overflow: hidden;\n position: relative;\n border-radius: var(--neo-radius-lg);\n background: var(--fill--base--default);\n}\n\n.banner:not(.banner--small) .banner__content { grid-row: 1; }\n.banner:not(.banner--small):not(.banner--full-photo) .banner__media { grid-row: 2; }\n\n.banner--large { height: 504px; }\n.banner--medium { height: 340px; }\n.banner--small { height: 148px; }\n\n.banner--small { grid-template-rows: minmax(0, 1fr); }\n\n.banner--large:not(.banner--full-photo) { grid-template-rows: auto 223px; }\n.banner--medium:not(.banner--full-photo) { grid-template-rows: auto 127px; }\n.banner--large:not(.banner--full-photo) .banner__content { min-height: 281px; }\n.banner--medium:not(.banner--full-photo) .banner__content { min-height: 217px; }\n\n@container banner (min-width: 720px) {\n .banner { grid-template-columns: 1fr 1fr; grid-template-rows: none; }\n .banner:not(.banner--small) .banner__content { grid-column: 1; grid-row: 1; }\n .banner:not(.banner--small):not(.banner--full-photo) .banner__media { grid-column: 2; grid-row: 1; }\n .banner--large { height: 374px; }\n .banner--medium { height: 230px; }\n .banner--small { height: 90px; }\n .banner--large:not(.banner--full-photo),\n .banner--medium:not(.banner--full-photo) { grid-template-rows: none; }\n .banner--large:not(.banner--full-photo) .banner__content,\n .banner--medium:not(.banner--full-photo) .banner__content { min-height: auto; }\n}\n\n.banner__content {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: flex-start;\n gap: var(--neo-space-sm-rem);\n padding-top: var(--neo-space-2xl-rem);\n padding-inline: var(--neo-space-xl-rem);\n padding-bottom: var(--neo-space-lg-rem);\n min-width: 0;\n}\n\n.banner:not(.banner--large) .banner__icon { display: none; }\n\n\n.banner__icon {\n display: flex;\n align-items: center;\n justify-content: center;\n width: var(--neo-icon-size-2xl-rem);\n height: var(--neo-icon-size-2xl-rem);\n flex-shrink: 0;\n}\n\n.banner__icon > svg {\n width: 100%;\n height: 100%;\n}\n\n.banner__title,\n.banner__subtitle,\n.banner__description { align-self: stretch; flex-shrink: 0; }\n\n.banner--small .banner__description { flex-grow: 1; }\n\n.banner__title {\n margin: 0 0 var(--neo-space-xs-rem);\n color: var(--text--base--default);\n font-size: var(--neo-type-title-md-bold-size-rem);\n font-weight: var(--neo-type-title-md-bold-weight);\n line-height: var(--neo-type-title-md-bold-line-height-rem);\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n overflow: hidden;\n}\n\n@container banner (min-width: 720px) {\n .banner--large .banner__title {\n font-size: var(--neo-type-title-lg-bold-size-rem);\n line-height: var(--neo-type-title-lg-bold-line-height-rem);\n }\n}\n\n.banner__subtitle {\n margin: 0;\n color: var(--text--base--default);\n font-size: var(--neo-type-title-sm-bold-size-rem);\n font-weight: var(--neo-type-title-sm-bold-weight);\n line-height: var(--neo-type-title-sm-bold-line-height-rem);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n@container banner (min-width: 720px) {\n .banner--large .banner__subtitle {\n font-size: var(--neo-type-title-md-bold-size-rem);\n line-height: var(--neo-type-title-md-bold-line-height-rem);\n }\n}\n\n.banner__description {\n margin: 0;\n color: var(--text--base--default);\n font-size: var(--neo-type-body-md-regular-size-rem);\n font-weight: var(--neo-type-body-md-regular-weight);\n line-height: var(--neo-type-body-md-regular-line-height-rem);\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 3;\n overflow: hidden;\n}\n\n@container banner (min-width: 720px) {\n .banner--large .banner__description {\n font-size: var(--neo-type-body-lg-regular-size-rem);\n line-height: var(--neo-type-body-lg-regular-line-height-rem);\n }\n}\n\n.banner__actions {\n display: flex;\n gap: var(--neo-space-md-rem);\n padding-top: var(--neo-space-sm-rem);\n align-items: center;\n}\n\n.banner--large .banner__actions { align-self: stretch; }\n.banner--medium.banner--full-photo .banner__actions { align-self: stretch; }\n\n.banner--full-photo.banner--large .banner__actions,\n.banner--full-photo.banner--medium .banner__actions { display: grid; }\n\n.banner--full-photo.banner--large .banner__actions > :not(button):not(a),\n.banner--full-photo.banner--medium .banner__actions > :not(button):not(a) { display: contents; }\n\n.banner__media { position: relative; min-width: 0; min-height: 0; overflow: hidden; }\n.banner__media img { width: 100%; height: 100%; object-fit: cover; display: block; }\n\n.banner--small { grid-template-columns: 1fr 108px; }\n.banner--small .banner__media { order: 1; }\n.banner--small .banner__content {\n padding: var(--neo-space-lg-rem) var(--neo-space-md-rem);\n}\n.banner--small.banner--image-left { grid-template-columns: 108px 1fr; }\n.banner--small.banner--image-left .banner__media { order: -1; }\n\n@container banner (min-width: 720px) {\n .banner__content { padding: var(--neo-space-2xl-rem); }\n .banner--small { grid-template-columns: 1fr 280px; }\n .banner--small .banner__content {\n padding: var(--neo-space-lg-rem) var(--neo-space-2xl-rem);\n flex-direction: row;\n align-items: center;\n justify-content: flex-end;\n gap: var(--neo-space-xl-rem);\n }\n .banner--large.banner--full-photo .banner__actions,\n .banner--medium.banner--full-photo .banner__actions { align-self: flex-start; }\n .banner--full-photo.banner--large .banner__actions,\n .banner--full-photo.banner--medium .banner__actions { display: flex; }\n .banner--full-photo.banner--large .banner__actions > :not(button):not(a),\n .banner--full-photo.banner--medium .banner__actions > :not(button):not(a) { display: block; }\n .banner--small .banner__title,\n .banner--small .banner__subtitle,\n .banner--small .banner__description { align-self: auto; }\n .banner--small .banner__description { flex: 1; min-height: 0; }\n .banner--small .banner__actions { flex: none; align-self: auto; }\n .banner--small.banner--image-left { grid-template-columns: 280px 1fr; }\n}\n\n.banner--full-photo .banner__media {\n position: absolute;\n inset: 0;\n}\n\n.banner--full-photo .banner__media::after {\n content: \"\";\n position: absolute;\n inset: 0;\n background: linear-gradient(90deg, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0) 100%);\n}\n\n.banner--full-photo .banner__content { position: relative; z-index: 1; }\n\n.banner--full-photo:not(.banner--small) .banner__content { padding-bottom: var(--neo-space-2xl-rem); }\n.banner--large.banner--full-photo .banner__content { justify-content: flex-start; }\n.banner--full-photo:not(.banner--small) { grid-template-rows: minmax(0, 1fr); }\n.banner--large.banner--full-photo .banner__content,\n.banner--medium.banner--full-photo .banner__content { justify-content: flex-start; }\n.banner--large.banner--full-photo .banner__actions,\n.banner--medium.banner--full-photo .banner__actions { margin-top: auto; }\n.banner--large.banner--full-photo .banner__actions { align-items: flex-end; }\n.banner--full-photo .banner__title,\n.banner--full-photo .banner__subtitle,\n.banner--full-photo .banner__description { color: var(--text--base--contrast); }\n\n@container banner (min-width: 720px) {\n .banner--large.banner--full-photo .banner__content,\n .banner--large.banner--full-photo .banner__content,\n .banner--medium.banner--full-photo .banner__content { justify-content: center; }\n .banner--large.banner--full-photo .banner__actions,\n .banner--medium.banner--full-photo .banner__actions { margin-top: 0; }\n .banner--large.banner--full-photo .banner__actions { align-items: center; }\n}\n.empty-state-shell {\n container-type: inline-size;\n container-name: empty-state;\n}\n\n.empty-state {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n gap: var(--neo-space-lg-rem);\n padding-block: var(--neo-space-xl-rem);\n padding-inline: var(--neo-space-xl-rem);\n border-radius: var(--neo-radius-md);\n background: var(--surface--base--default);\n}\n\n\n@container empty-state (min-width: 720px) {\n .empty-state {\n gap: var(--neo-space-xl-rem);\n padding-block: var(--neo-space-3xl-rem);\n padding-inline: var(--neo-space-2xl-rem);\n }\n}\n\n.empty-state__body {\n display: flex;\n flex-direction: column;\n justify-content: flex-start;\n align-items: center;\n gap: var(--neo-space-md-rem);\n width: 100%;\n max-width: 480px;\n}\n\n@container empty-state (min-width: 720px) {\n .empty-state__body { gap: var(--neo-space-lg-rem); }\n}\n\n.empty-state__icon-box {\n display: flex;\n flex-direction: row;\n justify-content: flex-start;\n align-items: center;\n gap: var(--neo-space-none-rem);\n width: 48px;\n height: 48px;\n padding-block: var(--neo-space-md-rem);\n padding-inline: var(--neo-space-md-rem);\n border-radius: var(--neo-radius-md);\n background: var(--fill--base--light);\n flex-shrink: 0;\n}\n\n@container empty-state (min-width: 720px) {\n .empty-state__icon-box {\n width: 64px;\n height: 64px;\n padding-block: var(--neo-space-lg-rem);\n padding-inline: var(--neo-space-lg-rem);\n border-radius: var(--neo-radius-lg);\n }\n}\n\n.empty-state__icon {\n display: flex;\n align-items: center;\n justify-content: center;\n width: var(--neo-icon-size-md-rem);\n height: var(--neo-icon-size-md-rem);\n color: var(--icon--base--default);\n fill: var(--icon--base--default);\n flex-shrink: 0;\n}\n\n.empty-state__icon > svg {\n width: 100%;\n height: 100%;\n}\n\n@container empty-state (min-width: 720px) {\n .empty-state__icon {\n width: var(--neo-icon-size-lg-rem);\n height: var(--neo-icon-size-lg-rem);\n }\n}\n\n.empty-state__content {\n display: flex;\n flex-direction: column;\n justify-content: flex-start;\n align-items: center;\n gap: var(--neo-space-none-rem);\n width: 100%;\n}\n\n.empty-state__title {\n text-align: center;\n margin: 0;\n color: var(--text--base--default);\n font-size: var(--neo-type-body-lg-bold-size-rem);\n font-weight: var(--neo-type-body-lg-bold-weight);\n line-height: var(--neo-type-body-lg-bold-line-height-rem);\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 1;\n overflow: hidden;\n}\n\n@container empty-state (min-width: 720px) {\n .empty-state__title {\n font-size: var(--neo-type-title-sm-bold-size-rem);\n font-weight: var(--neo-type-title-sm-bold-weight);\n line-height: var(--neo-type-title-sm-bold-line-height-rem);\n }\n}\n\n.empty-state__description {\n text-align: center;\n margin: 0;\n color: var(--text--base--secondary);\n font-size: var(--neo-type-body-md-regular-size-rem);\n font-weight: var(--neo-type-body-md-regular-weight);\n line-height: var(--neo-type-body-md-regular-line-height-rem);\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n overflow: hidden;\n}\n\n@container empty-state (min-width: 720px) {\n .empty-state__description {\n font-size: var(--neo-type-body-lg-regular-size-rem);\n font-weight: var(--neo-type-body-lg-regular-weight);\n line-height: var(--neo-type-body-lg-regular-line-height-rem);\n }\n}\n\n.empty-state__actions {\n display: flex;\n flex-direction: row;\n justify-content: flex-start;\n align-items: flex-start;\n gap: var(--neo-space-sm-rem);\n flex-wrap: wrap;\n}";
3
3
 
4
4
  const REGLAS = {
5
5
  '.aspect-ratio': {
package/theme/neo.d.ts CHANGED
@@ -1,6 +1,5 @@
1
- // theme/neo.d.ts — Neo's TypeScript types. Two things:
2
- // 1) AUGMENTATIONS over MUI (palette/custom props): without this `<Button color="brand">` would be a type error.
3
- // 2) The API of the `@neo-design/mui` package (theme, tokens and the accessible fields) — at the end of the file.
1
+ // theme/neo.d.ts — Neo's AUGMENTATIONS over MUI (palette/custom props): without this `<Button color="brand">` would
2
+ // be a type error. The package's own exports —the components, the theme and the tokens— are typed in ui/index.d.ts.
4
3
  // Note: `data-surface="inverse"` on Button does NOT need an augmentation (data-* attributes are always accepted).
5
4
  import '@mui/material/styles';
6
5
  import '@mui/material/Button';
@@ -92,14 +91,3 @@ declare module '@mui/material/Typography' {
92
91
  captionSmMedium: true; captionSmRegular: true;
93
92
  }
94
93
  }
95
-
96
- // ─────────────────────────────────────────────────────────────
97
- // The `@neo-design/mui` package API (what is imported from index.mjs)
98
- // ─────────────────────────────────────────────────────────────
99
- export declare const theme: Theme; // <ThemeProvider theme={theme}>
100
- export declare const themeOptions: ThemeOptions; // for createTheme(themeOptions) if you want to compose it
101
- export declare const color: Record<string, string>; // color['fill/primary/default'] → hex
102
- export declare const colorVars: Record<string, string>;// colorVars['fill/primary/default'] → 'var(--fill--primary--default)'
103
- export declare const scale: Record<string, Record<string, number>>; // scale.space.md, scale.radius.pill, …
104
- export declare function cssVar(name: string): string; // 'fill/primary/default' → '--fill--primary--default'
105
-
@@ -17,10 +17,12 @@ export const NeoAspectRatio = React.forwardRef(function NeoAspectRatio({ ratio =
17
17
  console.warn(`[Neo] AspectRatio: ratio '${ratio}' is not in the system — using 16/9. ` +
18
18
  `Available: ${Object.keys(RATIO_CLASS).join(', ')}`);
19
19
  }
20
+ const { sx, ...attrs } = rest;
21
+ if (sx !== undefined && process.env.NODE_ENV !== 'production') console.warn('[Neo] AspectRatio: sx does not apply — this component has no MUI base. Use className or style.');
20
22
  return h('div', {
21
23
  ref,
22
24
  className: ['aspect-ratio', ratioClass || RATIO_CLASS['16/9'], contain && 'aspect-ratio--contain', className]
23
25
  .filter(Boolean).join(' '),
24
- ...rest,
26
+ ...attrs,
25
27
  }, src ? h('img', { src, alt: alt ?? '' }) : children);
26
28
  });
package/ui/Banner.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  // packages/mui/ui/Banner.mjs — emitted without comments. The reasoning behind each decision lives in Neo's source repository.
3
3
  import React from 'react';
4
+ import { renderIcon } from './_icon.mjs';
4
5
  const h = React.createElement;
5
6
 
6
7
  const SIZES = ['small', 'medium', 'large'];
@@ -36,18 +37,20 @@ export const NeoBanner = React.forwardRef(function NeoBanner({
36
37
  return React.cloneElement(node, undefined, React.Children.map(node.props.children, styleAction));
37
38
  };
38
39
  const styledActions = styleAction(actions);
40
+ const { style, sx, ...attrs } = rest;
41
+ if (sx !== undefined && process.env.NODE_ENV !== 'production') console.warn('[Neo] Banner: sx does not apply — this component has no MUI base. Use className or style.');
39
42
 
40
- return h('div', { className: 'banner-shell', ref }, h('section', {
43
+ return h('div', { className: ['banner-shell', className].filter(Boolean).join(' '), style, ref }, h('section', {
41
44
  className: ['banner', `banner--${size}`, fullPhoto && 'banner--full-photo',
42
- mediaPosition === 'left' && 'banner--image-left', className]
45
+ mediaPosition === 'left' && 'banner--image-left']
43
46
  .filter(Boolean).join(' '),
44
47
  'aria-labelledby': titleId,
45
48
  'aria-label': titleId ? undefined : ariaLabel,
46
- ...rest,
49
+ ...attrs,
47
50
  },
48
51
  (src || media) ? h('div', { className: 'banner__media', key: 'm' }, src ? h('img', { src: src, alt: alt ?? '' }) : media) : null,
49
52
  h('div', { className: 'banner__content', key: 'c' },
50
- icon ? h('div', { className: 'banner__icon', 'aria-hidden': 'true', key: 'i' }, icon) : null,
53
+ icon ? h('div', { className: 'banner__icon', 'aria-hidden': 'true', key: 'i' }, renderIcon(icon)) : null,
51
54
  title ? h('h2', { className: 'banner__title', id: titleId, key: 't' }, title) : null,
52
55
  subtitle ? h('p', { className: 'banner__subtitle', key: 's' }, subtitle) : null,
53
56
  description ? h('p', { className: 'banner__description', key: 'd' }, description) : null,
package/ui/Button.mjs CHANGED
@@ -10,6 +10,7 @@ const warn = (m) => { if (process.env.NODE_ENV !== 'production') console.warn('[
10
10
 
11
11
  export const NeoButton = React.forwardRef(function NeoButton({ family = 'system', variant = 'primary', surface = 'default', size = 'large', label, iconLeft, iconRight, loading = false, ...rest }, ref) {
12
12
  const mui = {};
13
+ const garantias = {};
13
14
  mui.size = size;
14
15
  if (family === 'system' && variant === 'primary') { mui.variant = 'contained'; mui.color = 'primary'; }
15
16
  if (family === 'system' && variant === 'secondary') { mui.variant = 'contained'; mui.color = 'secondary'; }
@@ -20,12 +21,13 @@ export const NeoButton = React.forwardRef(function NeoButton({ family = 'system'
20
21
  if (surface === 'inverse') { mui["data-surface"] = 'inverse'; }
21
22
  if (loading) {
22
23
  mui.startIcon = h(NeoSpinner, { size: ({ large: 'medium', medium: 'medium', small: 'small' })[size] || 'medium', 'aria-hidden': 'true' });
23
- mui.disabled = true; mui["aria-busy"] = 'true';
24
+ garantias.disabled = true; garantias["aria-busy"] = 'true';
24
25
  } else {
25
26
  if (iconLeft) mui.startIcon = renderIcon(iconLeft);
26
27
  if (iconRight) mui.endIcon = renderIcon(iconRight);
27
28
  }
28
29
  if (family === 'brand' && size !== 'large') warn('button: with family=brand the master only offers size=large');
29
30
  if (family === 'brand' && surface !== 'default') warn('button: with family=brand the master only offers surface=default');
30
- return h(Button, { ...mui, ...rest, ref }, ...(label !== undefined ? [label] : []));
31
+ if (!(label || rest['children'] || (rest['aria-label'] || rest['aria-labelledby']))) warn('button: the button has no name: no `label`, no children and no `aria-label` — a screen reader announces it only as «button».');
32
+ return h(Button, { ...mui, ...rest, ...garantias, ref }, ...(label !== undefined ? [label] : []));
31
33
  });
package/ui/Checkbox.mjs CHANGED
@@ -16,15 +16,16 @@ export const NeoCheckbox = React.forwardRef(function NeoCheckbox({ label, checke
16
16
  nodo.current = n;
17
17
  if (typeof inputRef === 'function') inputRef(n); else if (inputRef) inputRef.current = n;
18
18
  }, [inputRef]);
19
+ const { className, style, sx, ...toControl } = rest;
19
20
  return h(FormControlLabel, {
20
21
  ref,
21
- htmlFor: fieldId, label, className: hideLabel ? 'neo-label-hidden' : undefined,
22
+ htmlFor: fieldId, label, className: [hideLabel ? 'neo-label-hidden' : null, className].filter(Boolean).join(' ') || undefined, style, sx,
22
23
  control: h(Checkbox, {
23
24
  id: fieldId, indeterminate, disabled, inputRef: alNodo,
24
25
  ...(checked !== undefined ? { checked } : {}),
25
26
  ...(defaultChecked !== undefined ? { defaultChecked } : {}),
26
27
  onChange,
27
- ...rest,
28
+ ...toControl,
28
29
  }),
29
30
  });
30
31
  });
package/ui/Combobox.mjs CHANGED
@@ -16,16 +16,23 @@ export const NeoCombobox = React.forwardRef(function NeoCombobox({ label, requir
16
16
  hasAccessibleName('NeoCombobox', label, rest);
17
17
  const keyboardFocus = useKeyboardFocus();
18
18
  const helpNode = helpIcon({ text: tooltip, name: tooltipLabel, label, comp: 'Combobox', prefix: 'combobox' });
19
- const labelNode = label ? h(FormLabel, { id: labelId, htmlFor: fieldId, required }, label) : null;
20
- return h(FormControl, { error, disabled, fullWidth: true, variant: 'outlined', onFocus: keyboardFocus.onFocus, onBlur: keyboardFocus.onBlur, onPointerDown: keyboardFocus.onPointerDown, ref },
21
- label && helpNode ? h('div', { className: 'combobox__label-row' }, labelNode, helpNode) : labelNode,
19
+ const { className, style, sx, ...toControl } = rest;
20
+ const cola = typeof label === 'string' && helpNode ? /^(?:(.*\S)\s+)?(\S+)\s*$/.exec(label) : null;
21
+ const labelTailId = cola ? `${labelId}-tail` : undefined;
22
+ const labelIds = labelTailId ? (cola[1] ? `${labelId} ${labelTailId}` : labelTailId) : labelId;
23
+ const labelNode = !label ? null : cola
24
+ ? [cola[1] ? h(FormLabel, { key: 'c', id: labelId, htmlFor: fieldId, required: false }, `${cola[1]} `) : null,
25
+ h('span', { key: 't', className: 'combobox__label-tail' }, h(FormLabel, { id: labelTailId, htmlFor: fieldId }, cola[2]), helpNode)]
26
+ : h(FormLabel, { id: labelId, htmlFor: fieldId }, label);
27
+ return h(FormControl, { error, disabled, required, fullWidth: true, variant: 'outlined', onFocus: keyboardFocus.onFocus, onBlur: keyboardFocus.onBlur, onPointerDown: keyboardFocus.onPointerDown, ref, className, style, sx },
28
+ label && helpNode ? h('div', { className: 'combobox__label-row' }, ...(cola ? labelNode : [labelNode, helpNode])) : labelNode,
22
29
  h(Autocomplete, {
23
- id: fieldId, fullWidth: true, blurOnSelect: true, options, disabled, readOnly,
30
+ id: fieldId, fullWidth: true, options, disabled, readOnly,
24
31
  clearIcon: renderIcon('system-error'),
25
32
  onChange,
26
33
  renderInput: (params) => h(TextField, {
27
- ...params, placeholder, error, disabled,
28
- inputProps: { ...params.inputProps, 'aria-describedby': helpId },
34
+ ...params, placeholder, error, disabled, required,
35
+ inputProps: { ...params.inputProps, 'aria-describedby': [helpId, rest['aria-describedby']].filter(Boolean).join(' ') || undefined },
29
36
  InputProps: {
30
37
  ...params.InputProps,
31
38
  className: [params.InputProps.className, keyboardFocus.active ? 'field--focus' : null].filter(Boolean).join(' '),
@@ -34,7 +41,9 @@ export const NeoCombobox = React.forwardRef(function NeoCombobox({ label, requir
34
41
  endAdornment: iconRight ? h(React.Fragment, null, h(InputAdornment, { position: 'end' }, renderIcon(iconRight, 'combobox__icon')), params.InputProps.endAdornment) : params.InputProps.endAdornment,
35
42
  },
36
43
  }),
37
- ...rest,
44
+ ...toControl,
45
+ ...(labelTailId ? { ListboxProps: { ...rest.ListboxProps, 'aria-labelledby': labelIds } } : {}),
46
+ 'aria-describedby': undefined,
38
47
  }),
39
48
  helperText ? h(FormHelperText, { id: helpId, key: error ? 'error' : 'helper', role: error ? 'alert' : undefined }, helperText) : null,
40
49
  );
package/ui/EmptyState.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  // packages/mui/ui/EmptyState.mjs — emitted without comments. The reasoning behind each decision lives in Neo's source repository.
3
3
  import React from 'react';
4
+ import { renderIcon } from './_icon.mjs';
4
5
  const h = React.createElement;
5
6
 
6
7
  const LEVELS = ['h2', 'h3', 'h4'];
@@ -14,16 +15,18 @@ export const NeoEmptyState = React.forwardRef(function NeoEmptyState({
14
15
  if (!title) warn('without a title the block does not say what is missing.');
15
16
  if (!description) warn('the description is required: a title only says WHAT is missing, not what to do.');
16
17
  if (!LEVELS.includes(headingLevel)) warn(`headingLevel '${headingLevel}' is not in the system: ${LEVELS.join(', ')}`);
18
+ const { style, sx, ...attrs } = rest;
19
+ if (sx !== undefined && process.env.NODE_ENV !== 'production') console.warn('[Neo] EmptyState: sx does not apply — this component has no MUI base. Use className or style.');
17
20
 
18
- return h('div', { className: 'empty-state-shell', ref }, h('div', {
19
- className: ['empty-state', className].filter(Boolean).join(' '),
21
+ return h('div', { className: ['empty-state-shell', className].filter(Boolean).join(' '), style, ref }, h('div', {
22
+ className: 'empty-state',
20
23
  role: 'status',
21
24
  'aria-live': 'polite',
22
- ...rest,
25
+ ...attrs,
23
26
  },
24
27
  h('div', { className: 'empty-state__body' },
25
28
  icon ? h('div', { className: 'empty-state__icon-box' },
26
- h('div', { className: 'empty-state__icon', 'aria-hidden': 'true' }, icon)) : null,
29
+ h('div', { className: 'empty-state__icon', 'aria-hidden': 'true' }, renderIcon(icon))) : null,
27
30
  h('div', { className: 'empty-state__content' },
28
31
  title ? h(LEVELS.includes(headingLevel) ? headingLevel : 'h2', { className: 'empty-state__title' }, title) : null,
29
32
  description ? h('p', { className: 'empty-state__description' }, description) : null)),
package/ui/Menu.mjs CHANGED
@@ -19,26 +19,31 @@ function mergeRefs(a, b) {
19
19
  }
20
20
 
21
21
  export const NeoMenu = React.forwardRef(function NeoMenu(rawProps, ref) {
22
- const { trigger, items = [], 'aria-label': ariaLabel, defaultOpen = false, ...rest } = withAliases('NeoMenu', rawProps);
22
+ const { trigger, items = [], 'aria-label': ariaLabel, defaultOpen = false, open: openProp, onClose: onCloseProp, ...rest } = withAliases('NeoMenu', rawProps);
23
23
  const role = 'menu';
24
24
  const auto = React.useId();
25
25
  const [anchor, setAnchor] = React.useState(null);
26
- const isOpen = Boolean(anchor);
26
+ const controlado = openProp !== undefined;
27
+ const isOpen = controlado ? Boolean(openProp) : Boolean(anchor);
28
+ const cerrar = (e, reason) => { if (!controlado) setAnchor(null); onCloseProp?.(e, reason); };
27
29
  const panelId = `neo-menu-${auto}`;
28
- const idTrigger = `neo-menu-${auto}-trigger`;
30
+ const idTrigger = trigger?.props?.id ?? `neo-menu-${auto}-trigger`;
29
31
 
30
32
  if (process.env.NODE_ENV !== 'production') {
31
33
  if (!trigger) console.warn('[Neo] Menu: `trigger` is missing — without a trigger the panel does not open.');
32
34
  if (!ariaLabel) console.warn('[Neo] Menu: `aria-label` is missing — the screen reader does not say what the menu is for.');
33
35
  }
34
36
 
35
- const triggerRef = mergeRefs(trigger?.ref ?? null, ref);
37
+ const r19 = Number(String(React.version).split('.')[0]) >= 19;
38
+ const triggerRef = mergeRefs(trigger ? (r19 ? trigger.props.ref : trigger.ref) ?? null : null, ref);
36
39
  const triggerNode = React.useRef(null);
40
+ const [nodoAncla, setNodoAncla] = React.useState(null);
37
41
  const storeNode = React.useCallback((n) => {
38
42
  triggerNode.current = n;
43
+ if (controlado) setNodoAncla(n);
39
44
  if (typeof triggerRef === 'function') triggerRef(n);
40
45
  else if (triggerRef) triggerRef.current = n;
41
- }, [triggerRef]);
46
+ }, [triggerRef, controlado]);
42
47
  React.useEffect(() => {
43
48
  if (defaultOpen && triggerNode.current) setAnchor(triggerNode.current);
44
49
  }, [defaultOpen]);
@@ -49,22 +54,22 @@ export const NeoMenu = React.forwardRef(function NeoMenu(rawProps, ref) {
49
54
  'aria-haspopup': role,
50
55
  'aria-expanded': isOpen ? 'true' : undefined,
51
56
  'aria-controls': isOpen ? panelId : undefined,
52
- onClick: (e) => { setAnchor(e.currentTarget); trigger.props.onClick?.(e); },
57
+ onClick: (e) => { if (!controlado) setAnchor(e.currentTarget); trigger.props.onClick?.(e); },
53
58
  });
54
59
 
55
60
  return h(React.Fragment, null,
56
61
  triggerElement,
57
62
  h(Menu, {
58
- id: panelId, anchorEl: anchor, open: isOpen, onClose: () => setAnchor(null),
59
- MenuListProps: { role, 'aria-labelledby': idTrigger, 'aria-label': ariaLabel, disabledItemsFocusable: true },
63
+ id: panelId, anchorEl: anchor || (controlado ? nodoAncla : null), open: isOpen, onClose: cerrar,
60
64
  ...rest,
65
+ MenuListProps: { ...rest.MenuListProps, role, ...(ariaLabel ? { 'aria-label': ariaLabel } : { 'aria-labelledby': idTrigger }), disabledItemsFocusable: true },
61
66
  }, ...items.flatMap((it, i) => (
62
67
  it.divider
63
68
  ? [h(Divider, { key: `d${i}` })]
64
69
  : [h(MenuItem, {
65
70
  key: it.value ?? i,
66
71
  disabled: it.disabled,
67
- onClick: () => { setAnchor(null); it.onSelect?.(it.value); },
72
+ onClick: () => { if (!controlado) setAnchor(null); it.onSelect?.(it.value); },
68
73
  },
69
74
  it.icon ? h(ListItemIcon, { key: 'i', 'aria-hidden': 'true' }, renderIcon(it.icon, 'menu-item__icon')) : null,
70
75
  h('span', { key: 'l', className: 'NeoMenu-label' }, it.label))]
@@ -3,10 +3,12 @@
3
3
  import React from 'react';
4
4
  import { withAliases } from './_alias-props.mjs';
5
5
  import { ModalDialogBase } from './_modal-base.mjs';
6
+ import { warn } from './_warn.mjs';
6
7
  const h = React.createElement;
7
8
 
8
9
  export const NeoModalDialog = React.forwardRef(function NeoModalDialog(rawProps, ref) {
9
10
  const { open, onClose, title, description, size = 'xs', type = 'default', closable = true, actions, children, ...rest } = withAliases('NeoModalDialog', rawProps);
11
+ if (['md', 'lg'].includes(size) && type !== 'default') warn(`modal-dialog: with size=${size} the master only offers type=default`);
10
12
  return h(ModalDialogBase, {
11
13
  open, onClose, title, description, size, type, closable, actions,
12
14
  ...rest, ref,
@@ -9,15 +9,16 @@ export const NeoRadioButton = React.forwardRef(function NeoRadioButton({ label,
9
9
  const autoId = React.useId();
10
10
  const fieldId = id || autoId;
11
11
  hasAccessibleName('NeoRadioButton', label, rest);
12
+ const { className, style, sx, ...toControl } = rest;
12
13
  return h(FormControlLabel, {
13
14
  ref,
14
- htmlFor: fieldId, label, className: hideLabel ? 'neo-label-hidden' : undefined,
15
+ htmlFor: fieldId, label, className: [hideLabel ? 'neo-label-hidden' : null, className].filter(Boolean).join(' ') || undefined, style, sx,
15
16
  control: h(Radio, {
16
17
  id: fieldId, disabled,
17
18
  ...(checked !== undefined ? { checked } : {}),
18
19
  ...(defaultChecked !== undefined ? { defaultChecked } : {}),
19
20
  onChange,
20
- ...rest,
21
+ ...toControl,
21
22
  }),
22
23
  });
23
24
  });
package/ui/RadioGroup.mjs CHANGED
@@ -7,10 +7,11 @@ const h = React.createElement;
7
7
 
8
8
  export const NeoRadioGroup = React.forwardRef(function NeoRadioGroup({ name, legend, value, defaultValue, direction = 'vertical', disabled = false, onChange, children, ...rest }, ref) {
9
9
  hasAccessibleName('NeoRadioGroup', legend, rest);
10
- if (!name) warn("NeoRadioGroup: without a `name`, two radios do NOT exclude each other even when they look like one group.");
10
+ if (!name) warn('NeoRadioGroup: without a `name`, two radios do NOT exclude each other even when they look like one group.');
11
11
  const legendId = `neo-radio-${React.useId()}-legend`;
12
- return h(FormControl, { component: 'fieldset', disabled, ref },
12
+ const { className, style, sx, ...toGroup } = rest;
13
+ return h(FormControl, { component: 'fieldset', disabled, ref, className, style, sx },
13
14
  h(FormLabel, { component: 'legend', id: legendId }, legend),
14
- h(RadioGroup, { 'aria-labelledby': legendId, name, value, defaultValue, row: direction === 'horizontal', onChange, ...rest }, children),
15
+ h(RadioGroup, { 'aria-labelledby': legendId, name, value, defaultValue, row: direction === 'horizontal', onChange, ...toGroup }, children),
15
16
  );
16
17
  });