La proximidad: un criterio nuevo en la cascada
Dónde encaja la proximidad de ámbito en los siete criterios de la cascada, qué problema de temas anidados resuelve, y por qué una regla sin ámbito tiene proximidad infinita.
La cascada llevaba sin criterios nuevos desde que llegaron las capas. @scope añade uno, la proximidad de ámbito, y lo coloca en un sitio muy concreto: por debajo de la especificidad y por encima del orden de aparición. Con eso resuelve un problema que era literalmente irresoluble —los temas anidados— e introduce el primer criterio de la cascada cuya respuesta no se puede calcular leyendo la hoja de estilos.
- Situar la proximidad entre los siete criterios de ordenación de la cascada.
- Resolver el caso del tema claro dentro de un tema oscuro dentro de un tema claro.
- Predecir qué gana entre una regla con ámbito y una sin él.
- Reconocer los límites del criterio y cuándo no te va a salvar.
Los siete criterios, en orden
La cascada ordena las declaraciones aplicando estos criterios en orden descendente de prioridad. En cuanto uno decide, los siguientes no se consultan.
flowchart TB A[1 Origen e importancia] --> B[2 Contexto de encapsulacion] B --> C[3 El atributo style] C --> D[4 Capas en cascada] D --> E[5 Especificidad] E --> F[6 Proximidad de ambito] F --> G[7 Orden de aparicion] style F fill:#cba6f7,color:#11111b style E fill:#89b4fa,color:#11111b style G fill:#f9e2af,color:#11111b
La posición de la proximidad es lo que hay que memorizar, porque determina exactamente qué puede y qué no puede arreglar. Gana al orden de aparición y pierde contra la especificidad y contra las capas. Un @scope no te salva de una regla más específica; te salva de una regla igual de específica que estaba más abajo en el archivo.
La medida es sencilla: gana la declaración con menos saltos entre la raíz de su ámbito y el sujeto de la regla, contando saltos generacionales o entre hermanos. Menos saltos significa raíz más cercana al elemento, y raíz más cercana significa contexto más local.
El problema de los temas anidados
Este es el caso que justifica el criterio entero. Un tema claro que contiene un tema oscuro que contiene otro tema claro:
<div class="tema-claro">
<p>Texto claro</p>
<div class="tema-oscuro">
<p>Texto oscuro</p>
<div class="tema-claro">
<p>Texto claro otra vez</p>
</div>
</div>
</div>
Escrito de la forma tradicional, el resultado es incorrecto y no hay manera de arreglarlo:
.tema-claro p { color: black; }
.tema-oscuro p { color: white; }
El párrafo más interior encaja con las dos reglas. Ambas tienen la misma especificidad, (0,1,1), así que decide el orden de aparición y gana la última: el párrafo sale blanco sobre fondo claro. Intercambiar el orden de las reglas no arregla nada, solo traslada el fallo al caso simétrico. Subir la especificidad de una tampoco: rompe la anidación en el otro sentido. El problema no tiene solución dentro del modelo antiguo, porque la respuesta correcta depende de dónde está el elemento y ninguno de los criterios existentes miraba eso.
Con @scope, la proximidad lo decide:
@scope (.tema-claro) {
:scope { background: #cccccc; }
p { color: black; }
}
@scope (.tema-oscuro) {
:scope { background: #333333; }
p { color: white; }
}
El párrafo interior está a un salto de su .tema-claro y a dos de su .tema-oscuro. Gana el claro. Y el párrafo del medio está a un salto del oscuro y a dos del claro exterior: gana el oscuro. El orden de los bloques en el archivo deja de importar, que es exactamente lo que quieres cuando el resultado correcto depende de la anidación real y no de cómo alguien ordenó dos reglas hace tres años.
Dentro de un @scope, la exigencia de estar dentro del ámbito se aplica solo al sujeto del selector, es decir, al elemento que recibe los estilos. El resto de la cadena puede encajar con lo que sea, dentro o fuera. Por eso @scope (.tarjeta) { .lista-densa & p { } } es legítimo aunque .lista-densa esté por encima de la raíz: lo único que tiene que estar dentro del ámbito es el p.
Proximidad infinita
Aquí está el detalle que produce sorpresas al adoptar @scope de forma incremental. La especificación lo dice sin ambigüedad: las reglas sin raíz de ámbito se consideran de proximidad infinita.
Es decir, no quedan fuera de la comparación: participan y siempre pierden ese paso. Entre dos declaraciones que empatan en origen, capa y especificidad, cualquier regla con ámbito gana a cualquier regla sin ámbito, sin importar el orden en el archivo.
/* Sin ambito. Proximidad infinita. */
.tarjeta p { color: gray; }
/* Con ambito. Gana, aunque este ANTES en el archivo. */
@scope (.tarjeta) {
:scope p { color: black; }
}
La consecuencia práctica es que envolver una regla existente en un @scope la promociona en la cascada aunque no cambies ni el selector ni la especificidad. Si estás migrando por partes, esto se manifiesta como reglas que empiezan a ganar sin que nadie las haya tocado. No es un bug: es el criterio funcionando. Pero conviene saberlo antes de meter el primer @scope en una hoja de dos mil líneas.
La forma de convivir con ello durante una migración es la de siempre: usa capas. Como las capas se evalúan antes que la proximidad, poner el código nuevo y el viejo en capas declaradas te devuelve el control del orden y hace que la proximidad solo actúe dentro de cada capa, que es donde la quieres.
@layer heredado, ambitos;
@layer heredado {
.tarjeta p { color: gray; }
}
@layer ambitos {
@scope (.tarjeta) { :scope p { color: black; } }
}
Los límites del criterio
No vence a la especificidad. Una regla sin ámbito con un selector más específico gana igualmente. @scope no es una capa disfrazada ni una forma de saltarse las guerras de peso.
Solo cuenta el ámbito más interno. Cuando anidas @scope dentro de @scope, la pertenencia queda restringida por ambos, pero la raíz que se usa para medir la proximidad es la del bloque más interno. Los saltos hasta la raíz exterior no intervienen.
No hay forma de reforzarlo. Hubo un mecanismo en los borradores, la proximidad fuerte, que permitía ganar a criterios superiores. Se eliminó. Si necesitas que un ámbito venza a una regla más específica, la respuesta es una capa, no el ámbito.
Los empates los rompe el orden. Si dos declaraciones tienen exactamente la misma proximidad, se pasa al séptimo criterio y decide el orden de aparición, como toda la vida.
Fíjate en una propiedad que comparten los cinco criterios anteriores a la proximidad y que ninguno de ellos rompe: todos se pueden calcular leyendo el CSS y nada más. El origen está en de dónde viene el archivo. La capa está en la declaración @layer. La especificidad se cuenta mirando el selector. El orden de aparición está en el número de línea. Un analizador estático puede coger dos reglas y decidir cuál gana sin ver una sola página, y de hecho eso es exactamente lo que hacen los linters que avisan de reglas muertas, los extractores de CSS crítico y las herramientas que te dicen que una declaración nunca se aplica. La proximidad rompe esa propiedad por primera vez: depende de dónde esté el elemento en el árbol, y por tanto la misma pareja de reglas puede resolverse en un sentido para un elemento y en el contrario para otro elemento del mismo documento. No es que sea difícil de calcular: es que no tiene una única respuesta. Las consecuencias van en dos direcciones. Hacia las herramientas, significa que el análisis estático de CSS pasa de ser completo a ser aproximado, y que cualquier utilidad que prometa decirte qué regla gana tiene ahora que responder que depende, o pedirte un DOM concreto. Y hacia tu modelo mental, significa abandonar la idea de que la cascada es una propiedad de la hoja de estilos y adoptar la que siempre fue más correcta: la cascada es una función que toma una hoja y un elemento. Que haya hecho falta un criterio nuevo para hacer esa dependencia explícita dice mucho de lo bien escondida que estaba, y explica por qué los temas anidados llevaban veinte años sin solución: no es que nadie encontrara el truco, es que la respuesta correcta requería información que la cascada no estaba mirando.
- Enumera los siete criterios de memoria y sitúa la proximidad.
- Reproduce el caso de los temas anidados sin
@scopey comprueba que no tiene arreglo. - Resuélvelo con
@scopey verifica el color del párrafo más interior. - Añade un
@scopea una regla existente y observa que empieza a ganar sin cambiar el selector. - Construye un caso donde una regla sin ámbito y más específica venza a una con ámbito.