feat(direction): RTL-2 — el guard del doble volteo, la forma que se me colo dos veces

Cierra §10.1, la ultima deuda del eje. `dee2c9e3a` arreglo los dos defectos pero
dejo la FORMA sin guard, y es la que sobrevivio a la migracion §6.3 y a que yo
diera el eje por cerrado en §9.19.

LA FIRMA — dentro de un bloque cuyo prelude tiene `:dir()`, la misma familia
logica (`border`/`padding`/`margin`/`inset`-`inline`) declarada en LAS DOS caras,
EXACTAMENTE UNA con valor neutro (`0`, `auto`, `none`, `initial`, `unset`,
`revert`). Ese desequilibrio ES el espejo cancelado: la propiedad ya se habia
volteado cuando la regla matchea, asi que reubicarla la devuelve al punto de
partida y la deja en el borde opuesto al de sus hermanas.

Es mas estrecha que «bloque `:dir()` con puras logicas» a proposito. Una regla
que CAMBIA un valor bajo RTL sin reubicarlo es legitima —asimetrico por diseño
existe—, y las dos caras con valor son un autor describiendo dos bordes reales.
Lo que delata el defecto es el PAR, una cara apagada. Hay un test negativo por
cada uno de esos casos, que son los que evitan que la regla se vuelva ruido.

LAS TRES DECISIONES que el handoff dejaba abiertas:

- `:dir(ltr)` tambien entra. Igual de sospechosa, y no anade ruido.
- Marcador PROPIO, `rtl-mirror: <reason>`. Reutilizar `rtl-physical:` mentiria:
  aqui no hay nada fisico, y un marcador que miente es peor que ninguno. Mismo
  mecanismo (`exemptLines` toma ahora el patron por parametro), dos vocabularios.
  Un test comprueba que el marcador de RTL-1 NO exime a RTL-2.
- Regla aparte, `lintRtlMirror()` con su propio `RtlMirrorFinding`. Los 14 tests
  de RTL-1 quedan intactos y el tipo lleva los campos que importan
  (`family`/`neutral`/`payload`) en vez de forzar los de RTL-1.

VERIFICACION, en este orden:

- `rtl-lint.test.ts` 27/27 (14 de RTL-1 intactos + 13 nuevos).
- RECALL con el runner COMPLETO contra el arbol pre-arreglo: restaure los dos
  ficheros de `dee2c9e3a^` sobre el arbol, corri `rtl:check` y los devolvi con
  `git checkout` en el mismo bloque. **2/2 cazados** (`feed.css:147`,
  `tree-view.css:211`). Probar la funcion no basta: el runner es lo que corre.
- `rtl:check` sobre HEAD: 1 error, el de `palabras`, preexistente y excluido.
- `docs:check` 0/0 sobre 564 docs. `check` 77 = linea base.

Un defecto de presentacion salio al hacerlo: el `calc()` multilinea de tree-view
partia el mensaje por el primer salto. Los campos que RTL-2 emite colapsan el
whitespace; con test.

Actualizados los textos que el handoff avisaba que quedarian obsoletos: el
`enforcement:` y §4 del contrato, la fila RTL del build contract, la fila X-1.6
del checklist, y los docblocks de `rtl-lint.ts` y `rtl-check.ts` — que son
doctrina, no adorno.

⚠️ Sigue siendo cierto lo que NINGUNA de las dos reglas ve: leen texto CSS. Un
`transform` inline escrito por JS, un preset de motion compartido y la geometria
SVG siguen necesitando el ojo en RTL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 7aee33acda
commit 1cb61567e5

@ -4,10 +4,10 @@ type: canon
audience: human + agent audience: human + agent
authority: canonical — the resolution chain, which attribute carries it, and which selector form may read it authority: canonical — the resolution chain, which attribute carries it, and which selector form may read it
status: current status: current
enforcement: §4 only — scripts/rtl-check.ts (rule RTL-1, `error`) · `npm run rtl:check`. §1–§3 and §5 have no mechanical guard; they are held by review and by the acceptance rules. enforcement: §4 only — scripts/rtl-check.ts (rules RTL-1 and RTL-2, `error`) · `npm run rtl:check`. §1–§3 and §5 have no mechanical guard; they are held by review and by the acceptance rules.
related: related:
resolver: src/uix/soma/direction.ts (activeDir, and why it can return undefined) resolver: src/uix/soma/direction.ts (activeDir, and why it can return undefined)
guard: src/uix/eidos/rtl-lint.ts · scripts/rtl-check.ts (RTL-1) guard: src/uix/eidos/rtl-lint.ts · scripts/rtl-check.ts (RTL-1 · RTL-2)
prefs: src/arts/prefs/README.md (the app-global preference and its DOM projection) prefs: src/arts/prefs/README.md (the app-global preference and its DOM projection)
architecture: docs/architecture/active-architecture.md (where prefs sits in the runtime) architecture: docs/architecture/active-architecture.md (where prefs sits in the runtime)
guide: docs/guides/component-guide.md (the RTL rows of the build contract) guide: docs/guides/component-guide.md (the RTL rows of the build contract)
@ -176,10 +176,15 @@ sends it back to where it started — and, since the sibling declarations were
left alone, it lands on the opposite edge from the thing it belongs to. An left alone, it lands on the opposite edge from the thing it belongs to. An
indent on one side with its rail on the other is the signature. indent on one side with its rail on the other is the signature.
If a `:dir()` block contains nothing but `inset-inline-*`, `margin-inline-*`, The tell is the pair: inside a `:dir()` block, the same logical family declared
`padding-inline-*` or `border-inline-*`, it is almost certainly cancelling a on **both** faces, one turned off (`0`, `auto`, `none`) and the other repainted.
mirror rather than creating one. Delete it and check the base rule instead. Delete the rule and check the base declaration instead — it had already
RTL-1 does not see this shape — it looks for a _physical_ displacement. mirrored.
`RTL-2` guards this shape, in the same file and the same run as RTL-1. Its
escape hatch is `rtl-mirror: <reason>`, a different marker from RTL-1's
`rtl-physical:` because it declares a different thing. Reach for it only when a
design breaks the mirror on purpose; the fix is almost always the deletion.
Two further physical forms the guard cannot reach, both of which must be checked Two further physical forms the guard cannot reach, both of which must be checked
by eye in RTL: by eye in RTL:

@ -271,7 +271,7 @@ The morfo is DNA. If it's incomplete, every downstream layer is incomplete.
| X-1.3 | `eidos-lint-all.ts` invalid count = 0 for this component | error | all | tool:eidos-lint | | X-1.3 | `eidos-lint-all.ts` invalid count = 0 for this component | error | all | tool:eidos-lint |
| X-1.4 | `npm run check` does not produce errors in this component's files | error | all | tool:check | | X-1.4 | `npm run check` does not produce errors in this component's files | error | all | tool:check |
| X-1.5 | Component's smoke route loads without console errors (`SMOKE_SCOPE=/uix/components/{kebab} npm run smoke`) | error | all | tool:smoke | | X-1.5 | Component's smoke route loads without console errors (`SMOKE_SCOPE=/uix/components/{kebab} npm run smoke`) | error | all | tool:smoke |
| X-1.6 | `npm run rtl:check` PASS (RTL-1 — logical inline anchor paired with a physical inline translate). The run walks all of `src/uix/eidos`; there is no per-component scope | error | all | tool:rtl:check | | X-1.6 | `npm run rtl:check` PASS (RTL-1 — logical inline anchor paired with a physical inline translate; RTL-2 — a `:dir()` rule that turns one logical face off and repaints the other, cancelling a mirror the property had already made). The run walks all of `src/uix/eidos`; there is no per-component scope | error | all | tool:rtl:check |
--- ---

@ -34,7 +34,7 @@ defends it today.
| **Size (controls)** | the `--size-{k}-*` bundle (height·font·padding·gap·radius·icon) | re-deriving size→font; consuming none of the bundle | size-bundle test (recipe-css-contract) | | **Size (controls)** | the `--size-{k}-*` bundle (height·font·padding·gap·radius·icon) | re-deriving size→font; consuming none of the bundle | size-bundle test (recipe-css-contract) |
| **Touch hit-area** | §37: `--touch-target` (44px) under `pointer: coarse` only — AREA ≠ VISUAL (`::before` slop for bare markers) | targets <44 on touch without slop; growing the visual | archetypes.css coarse rules | | **Touch hit-area** | §37: `--touch-target` (44px) under `pointer: coarse` only — AREA ≠ VISUAL (`::before` slop for bare markers) | targets <44 on touch without slop; growing the visual | archetypes.css coarse rules |
| **Portal typography** | anchor `font-family`+`line-height`+`color` on the portaled content root | inheriting (falls to serif in the portal) | rule (LIVE) | | **Portal typography** | anchor `font-family`+`line-height`+`color` on the portaled content root | inheriting (falls to serif in the portal) | rule (LIVE) |
| **RTL** | **logical** properties (`inline/block`, `inset-inline`) for flow; physical `left/right` ONLY as floating *placement* APIs (EID-3 exception) or where the geometry itself is physical (compass handles, polar arcs, a JS-measured offset); branch on direction with **`:dir(rtl)`** — and then the provider MUST stamp the raw `dir`, or `:dir()` only ever sees the inherited direction ([direction contract](../canon/direction-contract.md)) | physical `padding-left`/… in content flow; a logical anchor paired with a physical `translateX` — the anchor flips, the transform does not; `[dir='rtl'] …`, which misses the common no-attribute case and ignores any nearer re-declaration; accepting the prop, running the chain and never stamping — the maths moves, the paint stays behind | RTL-1 · `npm run rtl:check` | | **RTL** | **logical** properties (`inline/block`, `inset-inline`) for flow; physical `left/right` ONLY as floating *placement* APIs (EID-3 exception) or where the geometry itself is physical (compass handles, polar arcs, a JS-measured offset); branch on direction with **`:dir(rtl)`** — and then the provider MUST stamp the raw `dir`, or `:dir()` only ever sees the inherited direction ([direction contract](../canon/direction-contract.md)) | physical `padding-left`/… in content flow; a logical anchor paired with a physical `translateX` — the anchor flips, the transform does not; `[dir='rtl'] …`, which misses the common no-attribute case and ignores any nearer re-declaration; accepting the prop, running the chain and never stamping — the maths moves, the paint stays behind; a `:dir()` rule that turns one logical face off and repaints the other — the property had ALREADY mirrored, so that cancels it | RTL-1 · RTL-2 · `npm run rtl:check` |
| **RTL · SVG** | a graphic with a READING axis mirrors (invert the scale's pixel range); a RADIAL one does not. `text-anchor` is LOGICAL: leave it alone when the composition mirrors, force the physical one when it does not — see `eidos/components/chart/README.md` §Direction | mirroring *and* flipping the anchor (they cancel); flipping the anchor on a gutter that never moves (the label walks across the graphic); mirroring y values | eye, in RTL — RTL-1 reads CSS text and cannot see SVG attrs or JS-written inline geometry | | **RTL · SVG** | a graphic with a READING axis mirrors (invert the scale's pixel range); a RADIAL one does not. `text-anchor` is LOGICAL: leave it alone when the composition mirrors, force the physical one when it does not — see `eidos/components/chart/README.md` §Direction | mirroring *and* flipping the anchor (they cancel); flipping the anchor on a gutter that never moves (the label walks across the graphic); mirroring y values | eye, in RTL — RTL-1 reads CSS text and cannot see SVG attrs or JS-written inline geometry |
| **i18n** | `eidos.langs.ts('#?key\|fallback')` + key in the catalog | hardcoded strings / `aria-label`s | rule (LIVE) | | **i18n** | `eidos.langs.ts('#?key\|fallback')` + key in the catalog | hardcoded strings / `aria-label`s | rule (LIVE) |
| **Color (values)** | role tokens `--color-*` / recipe tokens | raw hex/rgb/hsl/oklch | R-2.1/2.6 · R-4.6 | | **Color (values)** | role tokens `--color-*` / recipe tokens | raw hex/rgb/hsl/oklch | R-2.1/2.6 · R-4.6 |

@ -1488,9 +1488,9 @@ afirma sobre las dos reglas retiradas.
## §10 · Cola para mañana ## §10 · Cola para mañana
### 10.1 · RTL-2 — el guard del doble volteo (LA tarea) ### 10.1 · RTL-2 — el guard del doble volteo · **CERRADA** (ver §11)
Es la única deuda del eje. §9.20 dejó los dos defectos arreglados pero **la forma Era la única deuda del eje. §9.20 dejó los dos defectos arreglados pero **la forma
sigue sin guard**, y es la forma que se me escapó DOS veces: en la migración sigue sin guard**, y es la forma que se me escapó DOS veces: en la migración
§6.3 y en el cierre §9.19. §6.3 y en el cierre §9.19.
@ -1588,3 +1588,60 @@ Commiteado hasta `961c1bf8b`, **sin pushear** — el remoto es `gita`, no hay
`src/arts/adom/__scratch-verify.ts` y `web/routes/alpha/` (este último **no se `src/arts/adom/__scratch-verify.ts` y `web/routes/alpha/` (este último **no se
commitea nunca**). `check` = 77, que es la línea base con los ficheros de commitea nunca**). `check` = 77, que es la línea base con los ficheros de
`media-player` de la otra sesión. `media-player` de la otra sesión.
---
## §11 · RTL-2 implementada — el guard del doble volteo
§10.1 cerrada. La regla existe, corre en `npm run rtl:check` junto a RTL-1, y
nace **verde con recall demostrado**.
**Las tres decisiones que §10.1 dejaba abiertas, tomadas:**
1. **`:dir(ltr)` también entra.** La firma es igual de sospechosa y no cuesta
nada: 0 falsos positivos sobre el árbol.
2. **Marcador propio, `rtl-mirror: <reason>`.** Reutilizar `rtl-physical:`
mentiría — aquí no hay nada físico, y un marcador que miente es peor que
ninguno. Mismo mecanismo (`exemptLines` pasó a tomar el patrón por
parámetro), dos vocabularios precisos. Hay un test que comprueba que el
marcador de RTL-1 **no** exime a RTL-2.
3. **Regla aparte**: `lintRtlMirror()` con su propio `RtlMirrorFinding`. Los 14
tests de RTL-1 no se tocaron, y el tipo lleva los campos que importan
(`family` · `neutral` · `payload`) en vez de forzar los de RTL-1.
**La firma implementada** — dentro de un bloque cuyo prelude tiene `:dir()`, la
misma familia lógica (`border`/`padding`/`margin`/`inset`-`inline`) declarada en
**las dos caras**, **exactamente una** con valor neutro (`0`, `auto`, `none`,
`initial`, `unset`, `revert`). El desequilibrio ES el espejo cancelado.
**Verificación, en este orden:**
| | resultado |
| ----------------------------------------------------- | ----------------------------------------------------- |
| `rtl-lint.test.ts` | **27/27** (14 de RTL-1 intactos + 13 nuevos) |
| Guard real contra el árbol pre-arreglo (`dee2c9e3a^`) | **2/2 cazados** — `feed.css:147`, `tree-view.css:211` |
| `npm run rtl:check` sobre HEAD | **1 error**, el de `palabras`, preexistente |
| `docs:check` | 0/0 sobre 564 docs |
| `check` | 77 = línea base |
La prueba de recall se hizo restaurando los dos ficheros del commit anterior
SOBRE el árbol, corriendo el guard completo y devolviéndolos con `git checkout`
en el mismo bloque — probar el runner entero, no sólo la función.
**Un defecto de presentación que salió al hacerlo**: el `calc()` multilínea de
`tree-view` partía el mensaje de error por el primer salto de línea. Los campos
que RTL-2 emite colapsan el whitespace; hay test.
**Textos actualizados** (los que §10.1 avisaba que quedarían obsoletos): el
`enforcement:` y §4 del contrato · la fila RTL del build contract en
`component-guide.md` · la fila `X-1.6` del checklist · los docblocks de
`rtl-lint.ts` y `rtl-check.ts`, que son doctrina.
⚠️ **Sigue siendo cierto lo que RTL-1 y RTL-2 NO ven**: leen texto CSS. Un
`transform` inline escrito por JS, un preset de motion compartido y la geometría
SVG siguen necesitando el ojo en RTL. El docblock lo dice; no lo borres al
tocarlo.
**Queda**: §10.2 sin cambios — los charts deberían aceptar `dir` (divergencia
declarada §7 del contrato) · `number-field` sin tabla de props · numeración
duplicada preexistente en `component-guide.md` · deuda ajena §8.4.

@ -12,14 +12,21 @@
* The anchor flips with the writing direction, the displacement does * The anchor flips with the writing direction, the displacement does
* not: correct in LTR, a full element-width off-axis in RTL. * not: correct in LTR, a full element-width off-axis in RTL.
* *
* The rule, its deliberate exclusions (block axis, zero X, `padding-inline`) * RTL-2 inside a `:dir()` block, one logical face turned off and the other
* and the `rtl-physical:` escape hatch live in `src/uix/eidos/rtl-lint.ts`. * repainted. The property had ALREADY mirrored, so relocating it
* cancels the mirror and parks it opposite the siblings left alone.
* This is the shape that survived the `[dir='rtl']` → `:dir(rtl)`
* migration untouched, because that pass rewrote selectors and never
* read a body — a mechanical migration inherits what it translates.
*
* Both rules, their deliberate exclusions and their escape hatches
* (`rtl-physical:` / `rtl-mirror:`) live in `src/uix/eidos/rtl-lint.ts`.
*/ */
import { readdirSync, readFileSync, statSync } from 'node:fs'; import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path'; import { join, relative } from 'node:path';
import { lintRtlGeometry } from '../src/uix/eidos/rtl-lint'; import { lintRtlGeometry, lintRtlMirror } from '../src/uix/eidos/rtl-lint';
const REPO = process.cwd(); const REPO = process.cwd();
const EIDOS_DIR = join(REPO, 'src', 'uix', 'eidos'); const EIDOS_DIR = join(REPO, 'src', 'uix', 'eidos');
@ -39,20 +46,32 @@ function main(): void {
for (const file of files) { for (const file of files) {
const rel = relative(REPO, file).replace(/\\/g, '/'); const rel = relative(REPO, file).replace(/\\/g, '/');
for (const f of lintRtlGeometry(readFileSync(file, 'utf8'))) { const css = readFileSync(file, 'utf8');
for (const f of lintRtlGeometry(css)) {
errors++; errors++;
console.error( console.error(
`ERROR [RTL-1] ${rel}:${f.line} — physical "${f.translate}" against logical ` + `ERROR [RTL-1] ${rel}:${f.line} — physical "${f.translate}" against logical ` +
`${f.anchors.map((a) => `"${a}"`).join(' + ')} in ${f.selector}` `${f.anchors.map((a) => `"${a}"`).join(' + ')} in ${f.selector}`
); );
} }
for (const f of lintRtlMirror(css)) {
errors++;
console.error(
`ERROR [RTL-2] ${rel}:${f.line} — double flip: "${f.neutral}" turns a face off and ` +
`"${f.payload}" repaints the other, but ${f.family} had already mirrored ` +
`in ${f.selector}`
);
}
} }
if (errors > 0) { if (errors > 0) {
console.error( console.error(
`\nrtl-check: ${errors} error(s) across ${files.length} files\n` + `\nrtl-check: ${errors} error(s) across ${files.length} files\n` +
'Express the offset logically (margin-inline-start: calc(…)), go fully physical\n' + 'RTL-1: express the offset logically (margin-inline-start: calc(…)), go fully\n' +
'(left/right), or mark the line /* rtl-physical: <reason> */ if the pairing is meant.' 'physical (left/right), or mark the line /* rtl-physical: <reason> */ if meant.\n' +
'RTL-2: DELETE the rule and check the base declaration — it had already mirrored.'
); );
process.exit(1); process.exit(1);
} }

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { lintRtlGeometry } from './rtl-lint'; import { lintRtlGeometry, lintRtlMirror } from './rtl-lint';
describe('lintRtlGeometry — RTL-1', () => { describe('lintRtlGeometry — RTL-1', () => {
// The regression this guard exists for: `slider.css` before `013ceac57`. // The regression this guard exists for: `slider.css` before `013ceac57`.
@ -188,3 +188,176 @@ describe('lintRtlGeometry — RTL-1', () => {
expect(lintRtlGeometry(css)).toEqual([]); expect(lintRtlGeometry(css)).toEqual([]);
}); });
}); });
describe('lintRtlMirror — RTL-2', () => {
// The two regressions this guard exists for, verbatim from `dee2c9e3a^`.
// Both shipped: the migration of §6.3 swapped `[dir='rtl']` for `:dir(rtl)`
// without asking whether the BODY was right, so a mechanical rewrite carried
// the defect across untouched.
const FEED_BEFORE = `
[data-feed-thread] {
padding-inline-start: var(--_feed-thread-indent);
border-inline-start: var(--border-width-medium) solid var(--_feed-palette-element);
}
[data-feed-root]:dir(rtl) [data-feed-thread] {
border-inline-start: 0;
border-inline-end: var(--border-width-medium) solid var(--_feed-palette-element);
}
`;
const TREE_VIEW_BEFORE = `
[data-tree-view-root][data-indent-guides]:dir(rtl) [data-tree-view-branch]::before {
inset-inline-start: auto;
inset-inline-end: calc(var(--tree-depth, 0) * var(--_indent));
}
`;
it('catches the feed rail, which left the indent on the other edge', () => {
const findings = lintRtlMirror(FEED_BEFORE);
expect(findings).toHaveLength(1);
expect(findings[0].line).toBe(9);
expect(findings[0].family).toBe('border-inline');
expect(findings[0].neutral).toBe('border-inline-start: 0');
expect(findings[0].selector).toBe('[data-feed-root]:dir(rtl) [data-feed-thread]');
});
it('catches the tree-view indent guide, released with `auto`', () => {
const findings = lintRtlMirror(TREE_VIEW_BEFORE);
expect(findings).toHaveLength(1);
expect(findings[0].family).toBe('inset-inline');
expect(findings[0].neutral).toBe('inset-inline-start: auto');
});
it('collapses a multi-line value — the real tree-view calc() spans four lines', () => {
const css = `
[data-x]:dir(rtl) {
inset-inline-start: auto;
inset-inline-end: calc(
var(--depth, 0) * var(--indent) +
var(--indent) / 2
);
}
`;
const findings = lintRtlMirror(css);
expect(findings).toHaveLength(1);
expect(findings[0].payload).toBe(
'inset-inline-end: calc( var(--depth, 0) * var(--indent) + var(--indent) / 2 )'
);
});
it('passes the fix — deleting the rule, since the base already mirrored', () => {
const css = `
[data-feed-thread] {
padding-inline-start: var(--_feed-thread-indent);
border-inline-start: var(--border-width-medium) solid var(--_feed-palette-element);
}
`;
expect(lintRtlMirror(css)).toEqual([]);
});
it('does NOT flag a value changed without being relocated', () => {
// Asymmetric by design is real; what betrays the double flip is the PAIR.
const css = `
[data-x]:dir(rtl) {
padding-inline-start: 0;
}
`;
expect(lintRtlMirror(css)).toEqual([]);
});
it('does NOT flag two faces that both carry a value', () => {
// An author describing two different edges, not cancelling a mirror.
const css = `
[data-x]:dir(rtl) {
margin-inline-start: var(--a);
margin-inline-end: var(--b);
}
`;
expect(lintRtlMirror(css)).toEqual([]);
});
it('does NOT flag two faces that are both off — that is a reset', () => {
const css = `
[data-x]:dir(rtl) {
inset-inline-start: auto;
inset-inline-end: auto;
}
`;
expect(lintRtlMirror(css)).toEqual([]);
});
it('does NOT flag the pair outside a :dir() block', () => {
// Without the direction branch there is no mirror to cancel.
const css = `
[data-x] {
border-inline-start: 0;
border-inline-end: 1px solid red;
}
`;
expect(lintRtlMirror(css)).toEqual([]);
});
it("judges a child on its OWN prelude, not the parent's :dir()", () => {
// The same call RTL-1 makes about anchors.
const css = `
[data-x]:dir(rtl) {
color: red;
& [data-y] {
border-inline-start: 0;
border-inline-end: 1px solid red;
}
}
`;
expect(lintRtlMirror(css)).toEqual([]);
});
it('flags :dir(ltr) too — the shape is just as suspect', () => {
const css = `
[data-x]:dir(ltr) {
margin-inline-end: 0;
margin-inline-start: var(--gap);
}
`;
expect(lintRtlMirror(css)).toHaveLength(1);
});
it('honours the rtl-mirror escape hatch, same line or the line above', () => {
const sameLine = `
[data-x]:dir(rtl) {
border-inline-start: 0;
border-inline-end: 1px solid red; /* rtl-mirror: the design breaks the mirror here */
}
`;
const lineAbove = `
[data-x]:dir(rtl) {
border-inline-start: 0;
/* rtl-mirror: the design breaks the mirror here */
border-inline-end: 1px solid red;
}
`;
expect(lintRtlMirror(sameLine)).toEqual([]);
expect(lintRtlMirror(lineAbove)).toEqual([]);
});
it("does not accept RTL-1's marker — it would declare the wrong thing", () => {
const css = `
[data-x]:dir(rtl) {
border-inline-start: 0;
border-inline-end: 1px solid red; /* rtl-physical: wrong marker */
}
`;
expect(lintRtlMirror(css)).toHaveLength(1);
});
it('does not read commented-out code', () => {
const css = `
[data-x]:dir(rtl) {
border-inline-start: 0;
/* border-inline-end: 1px solid red; */
}
`;
expect(lintRtlMirror(css)).toEqual([]);
});
});

@ -11,6 +11,15 @@
* RTL-1 `transform: translateX(…)` / `translate: <x> …` in a block that also * RTL-1 `transform: translateX(…)` / `translate: <x> …` in a block that also
* anchors on `inset-inline*` or `margin-inline*` = error. * anchors on `inset-inline*` or `margin-inline*` = error.
* *
* RTL-2 inside a `:dir()` block, the same logical family declared on BOTH
* faces — one neutral, the other carrying the value = error. That is
* the DOUBLE FLIP: the logical property had already mirrored by the
* time the rule matched, so relocating it cancels the mirror instead
* of creating one, and it lands on the opposite edge from the sibling
* declarations that were left alone. Signature in the wild: an indent
* on one side with its rail on the other (`feed.css`,
* `tree-view.css`, both fixed in `dee2c9e3a`).
*
* The canonical fix is the idiom the Slider's own `Tick` already used: keep the * The canonical fix is the idiom the Slider's own `Tick` already used: keep the
* logical anchor and express the offset logically too * logical anchor and express the offset logically too
* (`margin-inline-start: calc(var(--size) / -2)`). * (`margin-inline-start: calc(var(--size) / -2)`).
@ -22,7 +31,7 @@
* from `getBoundingClientRect` and was applied as an inline style). Components * from `getBoundingClientRect` and was applied as an inline style). Components
* that paint from JS need the eye, not this guard. * that paint from JS need the eye, not this guard.
* *
* Deliberately NOT flagged: * Deliberately NOT flagged by RTL-1:
* - the BLOCK axis (`inset-block-start` + `translateY`) — it never flips. * - the BLOCK axis (`inset-block-start` + `translateY`) — it never flips.
* - a zero / `none` X component — it displaces nothing. * - a zero / `none` X component — it displaces nothing.
* - `padding-inline*` — it pads content inside the box; it does not move the * - `padding-inline*` — it pads content inside the box; it does not move the
@ -33,9 +42,21 @@
* - `matrix()` / `matrix3d()` — vanishingly rare in eidos and not worth the * - `matrix()` / `matrix3d()` — vanishingly rare in eidos and not worth the
* decomposition; if one appears, it is reviewed by hand. * decomposition; if one appears, it is reviewed by hand.
* *
* Escape hatch: a CSS comment containing `rtl-physical: <reason>` on the * Deliberately NOT flagged by RTL-2:
* translate declaration's own line or the line directly above it. Two uses are * - a `:dir()` block that CHANGES a logical value without relocating it
* legitimate: * (`padding-inline-start: 0` alone) — asymmetric by design is a real thing;
* what betrays the double flip is the pair, one face neutral.
* - both faces carrying a value — that is an author describing two different
* edges, not cancelling a mirror.
* - a block with no `:dir()` in its own prelude. A parent's `:dir()` is not
* evidence about a child rule, the same call RTL-1 makes about anchors.
*
* Escape hatch: a CSS comment on the offending declaration's own line or the
* line directly above it — `rtl-physical: <reason>` for RTL-1, `rtl-mirror:
* <reason>` for RTL-2. Two vocabularies rather than one because they declare
* different things, and a marker that lies is worse than no marker.
*
* For RTL-1, two uses are legitimate:
* *
* - the displacement's sign is flipped elsewhere under `:dir(rtl)`, so the * - the displacement's sign is flipped elsewhere under `:dir(rtl)`, so the
* pair IS correct even though both declarations are still in the block — * pair IS correct even though both declarations are still in the block —
@ -49,6 +70,10 @@
* attribute is never defaulted, and a descendant selector also ignores any * attribute is never defaulted, and a descendant selector also ignores any
* nearer `dir` that re-declares the axis. * nearer `dir` that re-declares the axis.
* *
* For RTL-2 the marker is almost never the answer either: the fix is to DELETE
* the rule and check the base declaration, which had already mirrored. Reach
* for `rtl-mirror:` only when a design genuinely breaks the mirror on purpose.
*
* No CSS-AST dependency: a brace/paren scanner is enough for eidos CSS, and it * No CSS-AST dependency: a brace/paren scanner is enough for eidos CSS, and it
* keeps this file consumable from the `server` vitest project. * keeps this file consumable from the `server` vitest project.
*/ */
@ -64,6 +89,19 @@ export interface RtlFinding {
readonly anchors: readonly string[]; readonly anchors: readonly string[];
} }
export interface RtlMirrorFinding {
/** Line of the face that carries the value — the one to delete. */
readonly line: number;
/** Selector (or at-rule prelude) of the offending block. */
readonly selector: string;
/** The logical family, e.g. `border-inline`. */
readonly family: string;
/** The face turned off, as authored. */
readonly neutral: string;
/** The face repainted, as authored. */
readonly payload: string;
}
interface Declaration { interface Declaration {
readonly prop: string; readonly prop: string;
readonly value: string; readonly value: string;
@ -80,8 +118,14 @@ const TRANSLATE_FN = /\btranslate(3d|x|y|z)?\s*\(/gi;
const ZERO = /^[+-]?0(?:\.0+)?(?:px|%|r?em|v[wh]|ch|ex|cm|mm|in|pt|pc|q)?$/i; const ZERO = /^[+-]?0(?:\.0+)?(?:px|%|r?em|v[wh]|ch|ex|cm|mm|in|pt|pc|q)?$/i;
const EXEMPTION = /rtl-physical\s*:/; const EXEMPTION = /rtl-physical\s*:/;
/** The four logical families that have a `-start` and an `-end` face. */
const LOGICAL_FACE = /^(border|padding|margin|inset)-inline-(start|end)$/;
/** Values that TURN A FACE OFF rather than describe it. */
const NEUTRAL = /^(auto|none|initial|unset|revert)$/i;
const MIRROR_EXEMPTION = /rtl-mirror\s*:/;
export function lintRtlGeometry(cssText: string): RtlFinding[] { export function lintRtlGeometry(cssText: string): RtlFinding[] {
const exempt = exemptLines(cssText); const exempt = exemptLines(cssText, EXEMPTION);
const findings: RtlFinding[] = []; const findings: RtlFinding[] = [];
for (const block of parseBlocks(cssText)) { for (const block of parseBlocks(cssText)) {
@ -103,6 +147,66 @@ export function lintRtlGeometry(cssText: string): RtlFinding[] {
return findings; return findings;
} }
/**
* RTL-2 — the double flip. A `:dir()` rule that turns one logical face off and
* repaints the other is cancelling a mirror the property had already performed.
*/
export function lintRtlMirror(cssText: string): RtlMirrorFinding[] {
const exempt = exemptLines(cssText, MIRROR_EXEMPTION);
const findings: RtlMirrorFinding[] = [];
for (const block of parseBlocks(cssText)) {
if (!block.prelude.includes(':dir(')) continue;
// Last declaration wins, the way the cascade resolves a repeated property.
const faces = new Map<string, Map<string, Declaration>>();
for (const decl of block.decls) {
const match = LOGICAL_FACE.exec(decl.prop);
if (!match) continue;
const family = faces.get(match[1]) ?? new Map<string, Declaration>();
family.set(match[2], decl);
faces.set(match[1], family);
}
for (const [family, sides] of faces) {
const start = sides.get('start');
const end = sides.get('end');
if (!start || !end) continue;
// Exactly one face off: that asymmetry IS the cancelled mirror. Both
// off is a reset, both carrying a value describes two real edges.
const startOff = isNeutral(start.value);
if (startOff === isNeutral(end.value)) continue;
const neutral = startOff ? start : end;
const payload = startOff ? end : start;
if (exempt.has(payload.line) || exempt.has(payload.line - 1)) continue;
findings.push({
line: payload.line,
selector: block.prelude,
family: `${family}-inline`,
// Collapsed: a multi-line `calc()` would otherwise cut the message
// in half at the first newline.
neutral: oneLine(`${neutral.prop}: ${neutral.value}`),
payload: oneLine(`${payload.prop}: ${payload.value}`)
});
}
}
return findings;
}
function oneLine(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
/** Does this value turn the face off rather than describe it? */
function isNeutral(value: string): boolean {
const trimmed = value.trim();
return ZERO.test(trimmed) || NEUTRAL.test(trimmed);
}
/** Does this declaration move the element along the inline (X) axis? */ /** Does this declaration move the element along the inline (X) axis? */
function displacesInlineAxis(decl: Declaration): boolean { function displacesInlineAxis(decl: Declaration): boolean {
if (decl.prop.startsWith('--')) return false; if (decl.prop.startsWith('--')) return false;
@ -158,11 +262,11 @@ function splitTopLevel(input: string, separator: ',' | ' '): string[] {
return out; return out;
} }
/** 1-based lines carrying an `rtl-physical:` marker, read before comments go. */ /** 1-based lines carrying the given escape marker, read before comments go. */
function exemptLines(cssText: string): Set<number> { function exemptLines(cssText: string, marker: RegExp): Set<number> {
const out = new Set<number>(); const out = new Set<number>();
cssText.split('\n').forEach((line, i) => { cssText.split('\n').forEach((line, i) => {
if (EXEMPTION.test(line)) out.add(i + 1); if (marker.test(line)) out.add(i + 1);
}); });
return out; return out;
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.