feat(theming)!: SS16 - el guard vive donde NACE el valor, y el velo del drawer vuelve a pintar

Firma SS16, quinta aplicacion de la doctrina "la capa sostiene la pluma, no es
la dueña". El censo, el ledger y la ley del espacio cerrado auditaban SOLO
quien LEE (recetas de eidos); quien ESCRIBE la custom property por instancia
vive en soma/arts y ningun instrumento del eje lo habia enumerado jamas.

EL BUG VIVO QUE ESTO DESTAPO — y no se parcheo, se diagnostico
--drawer-overlay-opacity era DOS especies bajo un nombre: el knob de tema (el
contrato dice 62%) y el canal del arrastre (0..1). soma escribia el canal SIN
UNIDAD sobre el nombre del knob; color-mix() exige porcentaje, la funcion caia
invalida y el velo computaba rgba(0,0,0,0): NO PINTABA. Y el inline dejaba el
knob inalcanzable para cualquier tema. Se separan: el publico se queda como
knob (nadie lo escribe en runtime), el arrastre pasa a
--_drawer-overlay-progress (sin unidad, que para un multiplicador es lo
correcto) y la receta los COMPONE - calc(knob * progress) -, asi que el
arrastre ATENUA el tema en vez de destruirlo.
Medido en Chrome: reposo 0.408471 (antes rgba 0,0,0,0) - el tema LLEGA (20%
-> 0.1318, 62% -> 0.4085, 100% -> 0.6588; antes ninguno movia nada) - el
arrastre sigue (Escape real -> alpha 0; 0/0.25/0.5/1 lineal) - control
negativo: reinyectando la escritura vieja el velo vuelve a caer, o sea que la
unidad era el SINTOMA y las dos especies la enfermedad.
⚠ Estaba adjudicado EN PROSA en theming-sentinel-exceptions.ts:815 desde hacia
dias: la excepcion se trago el bug.

LOS 18 NOMBRES A SU SITIO
Clase B (11 en command/dialog/scroll-area/toast + 4 del drawer): forma publica
sin contrato y sin UN SOLO lector en el repo - una API publicada que el
framework no consume. A --_{c}-*, y los README de soma dejan de enseñarlos
como API del consumidor: ahora enseñan LA PARTE, con la formulacion verbatim
que SS15 ya habia verificado. Clase D (tree-view, gradient-picker): tenian
lector, verificados en navegador. --scrollbar-width NO se renombro por
inercia: es una escritura sobre el unico <body>, la doctrina no le llega, y
queda REGISTRADA con su razon en vez de inventarle un dueño.

LA AGUJA - src/uix/value-channels.test.ts (fichero propio, 6 tests)
Vitest y no script, porque el gate termina en la suite y eso es lo que
convierte la doctrina en ley. Deriva el vocabulario de sistema RESTANDO el
contrato a lo que emite el generador (lista derivada, nunca a mano). Barre
NUEVE raices - las siete nuevas verificadas a cero ANTES de asertarlas - y el
quinto test asserta que cada raiz declarada se anduvo de verdad: una raiz que
resuelve a cero ficheros es la puerta que nadie habria visto.
Dos correcciones al dimensionado, por medida: el arbol tiene SEIS formas de
escribir, no cuatro, y una de las que faltaban era LA CANONICA (la
--_${component}-... que SS14 y SS15 firmaron) - un guard ciego a ella habria
dado verde sobre su propio destino. Y un barrido mas ancho marcaba en rojo un
anchor-name, que en gramatica es identico a un nombre de propiedad: probado y
REVERTIDO. El instrumento miente primero.
Mutaciones: cinco, con el arbol byte a byte identico. Incluyen las dos que
prueban lo que las correcciones añaden (una clave _ DEL contrato pasa de verde
a rojo; un --_ legitimo en blocks pasa de rojo a verde).

LO QUE EL ADVERSARIAL CORRIGIO DE MI PROPIA LEY
Dictamen: "es LEY sobre la mitad que barre, y sigue siendo PROSA sobre el
absoluto que enuncia". Cierto: decia "toda escritura por instancia" y la aguja
no mira eidos, donde viven ONCE escrituras de nombres contratados. Corregido -
el enunciado nombra sus nueve raices y DECLARA sus dos fronteras (eidos, con
su expediente abierto; web/routes, congelado); las salidas son CUATRO y no
tres (la cuarta, sistema, es verde); y el registro se queda con cuatro campos
por entrada (since, reason, destination, heldBecause) con un test que exige
los cuatro - la diferencia entre un registro y un cajon.
Y la relectura con ojo de abogado encontro DOS absolutos mas, en direccion
contraria, escritos bajo "WHAT THE GUARD DOES NOT CHECK": "por instancia" no
es decidible estaticamente (el antecedente del guard es MAS ANCHO que el de la
doctrina), y el {c} de --_{c}-* no lo comprueba nadie, solo el guion bajo.

SS18 ABIERTO, y es la respuesta medida a "¿puede volver a nacer un nombre sin
dueño sin que nadie se entere?": SI, desde eidos. 11 contratadas + 18 sin
dueño, partidas en dos especies (la fundacion generando su vocabulario, que es
legitima, y las props ergonomicas del consumidor, que no es lo mismo).
dialog-overlay-opacity es el GEMELO EXACTO del velo del drawer y sigue vivo.
No es "añadir el root": distinguir las dos especies EXIGE FIRMA.

Guards: value-channels 6/6 - recipe-css-contract + reach-floor + generated-css
55/55 - docs:check 0/0 sobre 819 - --debt 1148/0/0 - tsc 0 propios - prettier
limpio.

BREAKING: los 18 nombres publicos ya no existen; el canal se lee --_{c}-* y
sigue sin ser de nadie para fijarlo. Y consumir un --{c}-* del contrato desde
un provider es ROJO desde hoy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 1 month ago
parent 839f79eeb7
commit f1f686d097

@ -831,7 +831,13 @@ data-highlighted → item with virtual focus (aria-activedescendan
data-resizing → splitter resize active
```
Exposed CSS variables:
Value channels soma writes per instance. **There is only ONE form**: `--_{c}-*`.
This list carried both spellings until 2026-08-27 — it was the acta of a
migration half done — and the law that ended it is R-5.5 in
[`canon/recipe-contract.md`](../canon/recipe-contract.md) §4: a per-instance
write outside `--_{c}-*` is a public form nobody owns, or, if the contract
declares the name, a token that LIES (a theme can never win an inline write).
The guard is `src/uix/value-channels.test.ts`.
```
--_{host}-floating-transform-origin → §15: the positioner writes its
@ -839,13 +845,20 @@ Exposed CSS variables:
--_{host}-floating-available-height channel in the HOST's namespace
--_{host}-floating-anchor-width (host = the component that owns the
--_{host}-floating-anchor-height floating composition)
--dialog-depth
--dialog-nested-count
--drawer-progress → 0-1 drag progress
--drawer-offset-x / y → drag offset in px
--toast-swipe-move-x / y → toast swipe offset
--_dialog-depth → §16: nesting depth of this dialog
--_dialog-nested-count → §16: how many dialogs it has above it
--_drawer-progress → §16: 0-1 drag progress
--_drawer-offset-x / y → §16: drag offset in px
--_drawer-overlay-progress → §16: 0-1 snap progress, a MULTIPLIER over
the public --drawer-overlay-opacity knob
--_toast-swipe-move-x / y → §16: toast swipe offset
--_toast-swipe-end-x / y → §16: toast swipe release offset
```
The list is a sample, not the census: soma and arts write **61** names over 72
sites (measured 2026-08-27), and the guard — not this page — is what keeps
them in the namespace.
## 10. IDs
IDs are generated with component context:

@ -179,19 +179,20 @@ contract's case).
## 4. Enforcement
| Rule | Guards | Severity |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
| R-4.1 | `box-shadow` without `var(` (elevation/ring outside tokens) | error |
| R-4.2 | literal `opacity: 0<N<1` outside `@keyframes` | error |
| R-4.3 | `background*` in `:hover` rules without a token (`var(`) or with manual `color-mix(… currentColor …)` | error |
| R-4.4 | recipe tokens with physical axes `padding-x/-y`, `margin-x/-y` | error |
| R-4.5 | a local `@keyframes` without a `/* functional: … */` annotation | error |
| R-4.6 | direct `var(--scale-*)` / `var(--primitive-*)` in component CSS | error |
| R-4.7 | `!important` without a same-line `/* important: <reason> */` annotation (THM-5, 2026-07-11) | error |
| R-5.1 | an appearance declaration that reaches no theme and carries no act — neither a public token, nor a `/* literal: … */` annotation, nor an entry in the debt ledger (2026-08-25) | error |
| R-5.2 | a recipe with themeable knobs, no entry in `lib/recipes/base.ts`, and unregistered debt (2026-08-25) | error |
| R-5.3 | recipe token keys outside the naming grammar of §1 — `color` as a slot, or an interactive modifier behind (D-TH.6, 2026-08-20) | error |
| R-5.4 | a public token that moves no computed value in the live demo and carries no written adjudication (`npm run theming:sentinel -- <c> <url>`, 2026-08-21) | error |
| Rule | Guards | Severity |
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| R-4.1 | `box-shadow` without `var(` (elevation/ring outside tokens) | error |
| R-4.2 | literal `opacity: 0<N<1` outside `@keyframes` | error |
| R-4.3 | `background*` in `:hover` rules without a token (`var(`) or with manual `color-mix(… currentColor …)` | error |
| R-4.4 | recipe tokens with physical axes `padding-x/-y`, `margin-x/-y` | error |
| R-4.5 | a local `@keyframes` without a `/* functional: … */` annotation | error |
| R-4.6 | direct `var(--scale-*)` / `var(--primitive-*)` in component CSS | error |
| R-4.7 | `!important` without a same-line `/* important: <reason> */` annotation (THM-5, 2026-07-11) | error |
| R-5.1 | an appearance declaration that reaches no theme and carries no act — neither a public token, nor a `/* literal: … */` annotation, nor an entry in the debt ledger (2026-08-25) | error |
| R-5.2 | a recipe with themeable knobs, no entry in `lib/recipes/base.ts`, and unregistered debt (2026-08-25) | error |
| R-5.3 | recipe token keys outside the naming grammar of §1 — `color` as a slot, or an interactive modifier behind (D-TH.6, 2026-08-20) | error |
| R-5.4 | a public token that moves no computed value in the live demo and carries no written adjudication (`npm run theming:sentinel -- <c> <url>`, 2026-08-21) | error |
| R-5.5 | a per-instance custom-property WRITE outside `--_{c}-*`, in the nine roots the guard sweeps — a contracted name (a token that lies) or an ownerless public form. `src/uix/eidos` is OUT by declaration, §18 (`src/uix/value-channels.test.ts`, 2026-08-27) | error |
**R-5.1 measures against a LEDGER OF DEBT, not against a ceiling** (firma
2026-08-25). The two rules spent months documented and unimplemented
@ -360,6 +361,174 @@ red naming the key; the same injection with a FOREIGN borrowed name and a
fallback stays green; and a registry entry that stops describing a consumption
is named STALE and reddens until it is deleted.
**And its SIBLING closes the other side — the one that WRITES** (firma «el
guard vive donde NACE el valor», 2026-08-27, R-5.5; its SCOPE written the same
day, over the objection below). The law above audits who READS a name; this one
audits who writes it:
> **Where the framework writes a custom property PER INSTANCE, the name lives
> in `--_{c}-*` and `base.ts` does not declare it. A name the contract DOES
> declare — public `--{c}-x` or private `--_{c}-x`, one single species — is a
> TOKEN THAT LIES: a theme is entitled to it and can never win it.**
**THE SCOPE, in the law itself, because a rule that says «every» while its
guard reads two directories is prose.** Its subject is _the framework's writing
side_, and that is nine roots: `src/uix/soma` · `src/arts` · `src/uix/blocks` ·
`src/packs` · `src/libs` · `src/svrs` · `src/uix/sema` · `src/uix/morfo` ·
`src/uix/active-uix`. The first two write; the other seven wrote **nothing** on
2026-08-27 and are swept anyway, so a first write cannot be born unseen — held
to the same two reds as everyone else and **never to zero**, because a block
that stamps its own `--_{c}-*` geometry tomorrow is legal, and reddening it
would be enforcing a census instead of a law.
**Two roots are OUT by declaration, and neither one is empty:**
- **`src/uix/eidos`** — **11 writes of CONTRACTED names and 18 ownerless public
forms live there today** (measured by pointing the same scanner at it). They
are two species and not one, which is exactly why the answer is not to add
the root: **13 sites** in the `lib/build-*` and `lib/render-css` generators
are **the foundation emitting its own vocabulary**, which is what a
foundation is for, while **16 in `components/*`** are **the ergonomic props a
wrapper offers its consumer**. Telling those apart takes a signature
— one of the eleven, `--dialog-overlay-opacity`, is the exact twin of the
drawer veil this firma just fixed, still alive because `dialog` has no
progress channel. **The expediente is [`next-features.md`](../next-features.md)
§18, and it is OPEN.**
- **`web/routes`** — FROZEN (`05dcb4db7`) and rebuilt whole; the 204 writes
there are a tree that is leaving. Judging them would need a clause this law
does not have, about WHO: a demo writing `--button-palette-solid` inline is a
CONSUMER using a token for what it is, not a framework publishing a name.
A write inside those roots has **four** outcomes — two green, two red — and
they are listed in the order the guard asks them, because the order is part of
the law:
1. **A name `base.ts` declares — RED.** The worst species, and the reason this
law is not a style rule. An inline per-instance write beats every normal
rule, so the theme entitled to that name moves NOTHING while the contract
keeps promising that it does. It is invisible from the reading side by
construction: the recipe reads a declared, contracted, perfectly legal name,
and the census scores it `public` and REACHED. R-5.4 is the rule that exists
to kill this species and it cannot see it either — R-5.4 writes on `:root`
or on the component root, and the inline write outranks both.
**This is asked BEFORE the underscore, and it was not always**: while the
prefix answered first, the **124** `_`-prefixed keys `base.ts` declares could
be written inline for free. A private recipe token is a theme's token too
(`render-css.ts:1900` emits a `_x` key as `--_{c}-x` out of the same
`EidosConfig.recipes` a theme supplies), so the species is «the theme is
entitled to this name and the write outranks it» and the underscore has
nothing to say about it. Measured before flipping the order: of 64 private
writes in soma + arts and 46 in eidos, **zero** name a contracted key — the
hole closes and nothing reddens.
2. **`--_{c}-*` that the contract does not declare — GREEN.** The destination.
The layer holds the pen; it does not own the surface. A value channel is not
theming surface and says so in its name.
3. **A name of the SYSTEM vocabulary — GREEN.** An art writing `--color-*` /
`--space-*` / `--motion-*` speaks the foundation's language; it is not
inventing a surface.
4. **Any other `--…` — RED.** A public FORM with no owner: published as API by
its spelling alone, reachable by nobody. The `--{c}-indicator-*` species of
§14, one floor down.
**WHAT THE GUARD DOES NOT CHECK, said here so no reader infers it from a
green.** Two gaps, both deliberate, and each one a place where the sentence and
the instrument do not coincide:
- **«PER INSTANCE» is not decidable statically, so the guard does not decide
it.** Its antecedent is WIDER than the doctrine's: it reddens any write of a
contracted or ownerless name in those roots, per-instance or not. That is why
the registry exists at all rather than being a leak in it — `--scrollbar-width`
is a single write on the one `<body>`, so the doctrine does not reach it while
the guard does, and the entry is what carries the difference in writing. A
rule whose antecedent the machine cannot evaluate is enforced by the nearest
one it can, and the gap is booked, not hidden.
- **The `{c}` of `--_{c}-*` is NOT enforced — only the underscore is.**
`--_totally-invented-thing` passes. The component segment is naming doctrine
(THEMING §6: no abbreviation, the component's own namespace) and this guard is
not its enforcer; the tree carries legitimate exceptions to the letter anyway,
because a shared LAYER writes in the HOST's namespace (`--_{host}-floating-*`,
§15) and some privates are layer-scoped, not component-scoped
(`--_viewport-placement-offset`). Closing this one means a rename axis, not a
line of guard.
**The system vocabulary is a DERIVED list, never a hand-written one**: the
guard subtracts the recipe contract from everything the foundation emits
(`renderGeneratedBaseEidosCss`), and what remains is what an art may speak. A
hand-kept allowlist would need editing every time the foundation grows, and
would drift into the exception file this axis has spent five signatures
avoiding. What that subtraction does is **carve outcome 3 out of outcome 4** —
it is not what separates the two reds, which is the contract set alone. Said
plainly because the earlier wording had it backwards, and «anything else is
red» is false while outcome 3 exists.
The exemption registry is the debt ledger's shape, not the exception file's:
it may only SHRINK — an entry whose name stops being an ownerless write is
STALE and stays red until deleted. It COUNTS a name; it does not bless it.
**And it answers the house's own objection, which is 24 hours older than it**:
`PENDING_PRIVATE_RENAME` was DISMANTLED on 2026-08-26 over this same species,
«there is no third state». What keeps this one alive is not permissiveness but
FORM — that registry held names awaiting a rename already decided, which is a
queue, and a queue with no deadline is a cupboard; these hold names the law
does not reach or cannot judge alone, which is the ledger's own case. So an
ENTRY is four fields and the guard asserts all four are filled:
| field | what it stops |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `since` | an undated registry cannot age |
| `reason` | why the antecedent misses it — MEASURED, not asserted |
| `destination` | where it goes, named. Not «to be decided» |
| `heldBecause` | **why it did not move THAT day.** The load-bearing one: an entry that cannot say what blocks it has nothing blocking it, and belongs in the commit instead of in this list |
Two adjudications opened it and both are outside the law's antecedent rather
than deviations from it: `--scrollbar-width` (`arts/adom/body-scroll-lock` —
one write on the single `<body>`, a fact of the viewport, not of an instance)
and the seven `--palabras-scheme-*` of the WIP track, registered rather than
waived because the debt ledger already settled that question the other way
(«THE WIP TRACKS ARE IN … debt is real»).
**Why the guard is a vitest file and not a script**: `npm run gate` ends in
`npm run test`, so the suite is what turns a doctrine into a law. And why it
sits at `src/uix/` root rather than under `eidos/`: its SUBJECT is the writing
side and its JUDGE is the eidos contract, so it belongs to no single layer.
The measurement that forced it: the census, the debt ledger, R-5.1 and the
closed-namespace law all read `src/uix/eidos/components` and nothing else
(`scripts/theming-census.ts`, `const ROOT`). **The 73 % was measured over the
side that READS, and only that side** — deliberately not called «half», because
no recomputed denominator exists: a write in soma is not a recipe declaration,
so the two do not share a fraction. What is true is narrower and enough: the
number says nothing about the writers, and until this rule there was no
instrument that did. The commit that named the general law is
`05dcb4db7` («LEY — web/routes congelado, y la consecuencia: el guard vive
donde NACE el valor»), signed for the P1 axis the same week with the same
argument: an instrument aimed at the observed artefact measures a tree that is
leaving, not the contract. This is that sentence translated to theming.
Mutation-proved six ways, each with the tree restored byte-identical: a new
unprefixed name in a provider → red, NAMING it; a write of a key that IS in the
contract → red, «a token that LIES»; a correct `--_` channel → green; a SYSTEM
name written by an art → green (the only branch with no live case in the tree,
so it had to be proved by injection); the canonical dynamic form
``[`--${component}-x`]`` → red as `--{}-x`; and a registry entry that stops
describing a write → STALE. The scope pass added three more the same day: a
CONTRACTED private key (`--_avatar-badge-bg`) written inline → red as a token
that lies, which was **green** before the order flipped; and, in `blocks` — a
root nobody swept that morning — `--_hero-private-leak` → **green** while
`--hero-public-leak` → red, which is the difference between the law and the
census of zero it used to assert there.
**Sweeping by CAPTURE and not by pattern is what made it correct.** The firma
named four write forms; the scanner resolves **six**, and one of the two missing
was
the CANONICAL one — the computed key ``[`--_${component}-floating-anchor-width`]``
that §14 and §15 signed. A guard blind to that form is blind to the destination
it enforces, and it would have been green over a public `--{c}-*` written that
way. The other was `out['--x'] = v`, the member assignment `palabras` uses,
which is what put those seven names on the record at all. Two shapes stay
unresolvable and are written down rather than implied by a green: a name
ASSEMBLED across statements (`soma/layers/measured-indicator`) and a name
reaching `setProperty` through a variable.
The exception valve of §3 is untouched: the `/* literal: <reason> */`
annotation on the declaration, and the component-level `R-5.1 exception:` /
`R-5.2 exception:` of a README (23 components carry one today). Precedence,

@ -1038,7 +1038,7 @@ Headless providers MUST NOT emit visual CSS properties (`border-radius`, `backgr
- `pointer-events: auto` — required for overlays and fixed-position content
- `transition: none` — required during active drag to disable CSS transitions
- `transform: translate3d(...)` — required during active drag for visual feedback
- CSS custom properties (`--drawer-progress`, `--drawer-offset-*`) — data for the visual layer
- CSS custom properties (`--_drawer-progress`, `--_drawer-offset-*`) — data for the visual layer
The visual layer (Eidos) owns appearance. The provider owns behavior.
@ -1098,7 +1098,7 @@ Rules:
- `setPointerCapture` deferred until moveBuffer exceeded for containers with child buttons (Drawer, Slider). **Exception**: pure drag handles (Splitter resize trigger) capture immediately — the handle IS the drag target, no children to protect
- Gesture `.props` (only `onpointerdown`) must be spread into the component's `props`
- Gesture cleanup on unmount: `$effect(() => { return () => { this.gesture.cancel(); }; })`
- CSS vars (`--drawer-progress`, `--drawer-offset-x/y`) set by provider for visual layer
- CSS vars (`--_drawer-progress`, `--_drawer-offset-x/y`) set by provider for visual layer
- During active drag: `transition: none` + inline `transform` for immediate feedback
- Scroll-drag guard: disable gesture via `enabled`, not by suppressing callback
- The gesture layer measures — the component decides what it means (dismiss, value, resize)

@ -2957,6 +2957,16 @@ verificación dinámica de §14 sirve de molde.
## 16. El guard vive donde NACE el valor — el eje audita MEDIO espacio (A-1) — 2026-08-27
**✅ EJECUTADA 2026-08-27** — firma «la capa sostiene la pluma», la **quinta**
aplicación de la doctrina del canal de valor (§14 la tercera, §15 la cuarta).
El acta, con los números medidos, cierra esta sección en **§16-bis**; lo
redactado abajo se conserva entero porque el diagnóstico fue correcto —
incluidas las dos cifras que el acta corrige, que se corrigen **anotándolas**.
> **Anotación 2026-08-27 (cierre)** — el párrafo de estado que sigue decía
> «PENDIENTE — redactado, NO ejecutado» y era cierto la mañana de ese día. Se
> conserva como el acta que es; el estado de HOY es el de la línea de arriba.
**PENDIENTE — redactado, NO ejecutado.** Dimensionado y medido el 2026-08-27
con aguja propia; **nada de lo de abajo se ha tocado en el código**. Va PRIMERO,
antes que §17 (C-1); las cuatro razones, al final de esta sección.
@ -3135,6 +3145,201 @@ adjudicado en prosa) ·
`src/uix/soma/components/scroll-area/scroll-area-provider.svelte.ts:186,190`
(la intersección A-1 ∩ C-1) · `docs/architecture/soma-architecture.md:837-841`.
### 16-bis. Acta de ejecución — 2026-08-27
> **Anotación 2026-08-27 (cierre de firma), TRES correcciones a esta acta.**
> Sus números son ciertos y el pase crítico los reprodujo al dígito; lo que
> falló fue el **alcance escrito**. Veredicto literal: *«es LEY sobre la mitad
> que barre, y sigue siendo PROSA sobre el absoluto que enuncia»*. Lo corregido
> el mismo día, sin reescribir una línea de lo de abajo:
>
> 1. **El enunciado universal está ACOTADO en la ley.** «Toda escritura por
> instancia» lo desmentían **once escrituras de nombres contratados vivas en
> `src/uix/eidos`**, una raíz que la ley no excluía por escrito y la aguja no
> miraba. R-5.5 nombra ahora sus **nueve raíces** una a una y declara sus dos
> fronteras — `src/uix/eidos` y `web/routes` — **con su expediente abierto**:
> **§18**, en este mismo fichero.
> 2. **Las salidas son CUATRO, no tres.** La ley decía «tres, y no hay cuarta»
> mientras su propia tabla de mutaciones (fila **d**, aquí abajo) enseñaba una
> cuarta VERDE: el vocabulario de SISTEMA. Corregido, y en el orden en que el
> guard las pregunta.
> 3. **El registro se QUEDA, con cuatro campos por entrada.** Es el patrón de la
> casa —transitorio, sólo mengua, con STALE que lo vacía solo— y responde por
> escrito a la objeción de que `PENDING_PRIVATE_RENAME` se desmontó 24 horas
> antes. Cada entrada gana ahora **fecha · razón · destino · por qué NO se
> movió hoy**, y el guard asserta que los cuatro están rellenos: es el cuarto
> campo el que impide que un registro se vuelva un cajón. Las 8 entradas
> (7 `--palabras-scheme-*` + `--scrollbar-width`) están completas.
>
> **Dos menores del mismo pase, medidos y ejecutados**: `classify` preguntaba
> por el prefijo `--_` ANTES que por el contrato, así que las **124** claves
> `_`-prefijadas de `base.ts` se podían escribir inline gratis — **medido:
> 0 de 64 escrituras privadas de soma+arts y 0 de 46 de eidos nombran una clave
> contratada**, así que el orden se invirtió y no enrojeció nada. Y
> `SILENT_ROOTS` prohibía TODA escritura en `blocks`/`packs`, con lo que un
> bloque que mañana estampe un `--_{c}-*` legítimo nacía rojo: hoy esas raíces
> —y `libs`, `svrs`, `sema`, `morfo`, `active-uix`, **verificadas en cero antes
> de asertarlas**— se barren bajo los mismos dos rojos, nunca contra cero.
>
> ⚠ Donde el acta dice «`blocks` y `packs` siguen escribiendo **0**, y el guard
> lo asserta», léase: siguen escribiendo 0 (medido), y el guard ya **no** lo
> asserta — los barre bajo la ley, que es lo que la ley prohíbe de verdad.
**Lo firmado, en una línea.** *Toda escritura de custom property POR INSTANCIA
vive en `--_{c}-*`. Si el nombre que se escribe está en el contrato, es un TOKEN
QUE MIENTE: un tema no puede ganarle jamás.* Es **R-5.5** en
`docs/canon/recipe-contract.md` §4, hermana de la ley del espacio cerrado —
aquélla audita quien LEE, ésta quien ESCRIBE.
**La aguja**: `src/uix/value-channels.test.ts`, fichero PROPIO — ni reusa el
lector de contrato del censo ni toca `src/uix/contracts.test.ts`. Es un test de
vitest y no un script porque `npm run gate` termina en `npm run test`: la suite
es lo que convierte la doctrina en ley; un `scripts/*.ts` necesita entrada nueva
de gate y un autor que se acuerde. Vive en la raíz de `src/uix/` y no bajo
`eidos/` porque su SUJETO es soma + arts y su JUEZ es el contrato de eidos: no
es de ninguna capa. Lee el contrato del propio `THEME_BASE_RECIPE_TOKENS` y
deriva el vocabulario de SISTEMA restando ese contrato a todo lo que emite
`renderGeneratedBaseEidosCss()` — lista DERIVADA, nunca escrita a mano.
**El censo, medido sobre el árbol ya movido** (1.729 ficheros de código de
soma + arts; los tests se excluyen porque un spec OBSERVA a un escritor, no lo
es):
| clase | nombres | qué es |
|---|---|---|
| **`--_{c}-*`** | **53** | el destino de la doctrina |
| **token que MIENTE** | **0** | *era 1: el velo del drawer* |
| **forma pública sin dueño** | **8** | los 7 `--palabras-scheme-*` + `--scrollbar-width`, los dos REGISTRADOS |
| **vocabulario de sistema** | 0 | nadie en soma ni en arts escribe un nombre de la fundación |
**61 nombres · 72 sitios**, sobre un contrato de 4.693 nombres y un vocabulario
de sistema de 1.058. `blocks` y `packs` siguen escribiendo **0**, y el guard lo
asserta.
**⚠ Dos correcciones al dimensionado de arriba, por MEDIDA.** El censo previo
enumeró **cuatro** formas de escritura; el árbol tiene **SEIS**, y una de las
dos que faltaban era **la CANÓNICA**:
- **`computed-key`** — ``[`--_${component}-floating-anchor-width`]:``, la forma
con `component: string` que §14 y §15 firmaron. Un guard ciego a ella es ciego
al destino que hace cumplir, y habría dado verde sobre un `--{c}-*` público
escrito así. **8 sitios.**
- **`member-assign`** — `out['--x'] = v` sobre un objeto de estilo. **7 sitios**,
todos de `palabras`, y son la razón de que esos siete nombres estén hoy en el
registro en vez de invisibles.
Por eso 61 nombres y no 49: el barrido por CAPTURA ve doce nombres que el
barrido por PATRÓN no podía ver. Dos formas quedan irresolubles y se escriben en
el fichero en vez de taparse con un verde: un nombre ENSAMBLADO entre sentencias
(`soma/layers/measured-indicator`) y uno que llega a `setProperty` por variable.
⚠ Y una tercera lección: un barrido de literales pelados —probado y REVERTIDO—
marcaba en rojo `--floating-anchor-{id}`, que es un VALOR de `anchor-name`, un
dashed-ident idéntico en gramática a un nombre de propiedad y jamás escrito
como tal.
**El registro de adjudicaciones abiertas** tiene la forma del ledger de deuda,
no la del fichero de excepciones: NOMBRE + razón escrita + destino, y **sólo
puede MENGUAR** (una entrada cuyo nombre deja de ser escritura sin dueño sale
STALE y sigue roja hasta que se borre la línea). Dos entradas, y ninguna es una
desviación firmada — las dos caen FUERA del antecedente de la ley:
1. **`--scrollbar-width`** (`arts/adom/body-scroll-lock`) — no es por instancia:
una sola escritura sobre el único `<body>`, y su valor es un hecho del
VIEWPORT. Su destino es una bifurcación que exige firma (declararla como
sistema la vuelve API real que nadie lee; retirarla borra un nombre).
2. **Los siete `--palabras-scheme-*`** — REGISTRADOS, no eximidos como pista
WIP, porque el ledger de deuda ya zanjó esa pregunta al revés («THE WIP
TRACKS ARE IN … debt is real»). Un octavo hermano ya no puede colarse sin
firma.
**Las mutaciones — seis caras, el árbol restaurado byte a byte idéntico en cada
una** (SHA verificado antes y después; `git diff HEAD` vacío):
| # | inyección | veredicto |
|---|---|---|
| a | `'--sticky-shadow-strength': '3'` en un provider | **ROJO**, nombrándolo: `--sticky-shadow-strength ← …/sticky-provider.svelte.ts:134 (object-key)` |
| b | `'--drawer-overlay-opacity': '1'` — clave DEL CONTRATO | **ROJO**: «*A token that LIES…*» |
| c | `'--_sticky-shadow-strength': '3'` | **VERDE** |
| d | `'--color-surface-raised': 'red'` — nombre de SISTEMA | **VERDE** — la única rama sin caso vivo en el árbol, así que sólo se prueba por inyección |
| e | una entrada de registro que ya nadie escribe | **ROJO STALE** |
| f | ``[`--${this.opts.component}-floating-leak`]`` en `floating` | **ROJO** como `--{}-floating-leak … (computed-key)` |
**Y tres más del pase de alcance (2026-08-27, cierre)**, cada una con backup y
restauración en la misma invocación y SHA idéntico antes y después. Las dos
primeras re-corren caras de arriba para probar que la aguja **sigue mordiendo
tras los cambios**; las tres últimas prueban lo que los cambios añaden:
| # | inyección | veredicto |
|---|---|---|
| b′ | `'--drawer-overlay-opacity': '1'` (re-corrida) | **ROJO** «*A token that LIES*» — `… ← sticky-provider.svelte.ts:134 (object-key)` |
| f′ | ``[`--${this.opts.component}-floating-leak`]`` (re-corrida) | **ROJO** — `--{}-floating-leak ← floating.svelte.ts:316 (computed-key)` |
| g | `'--_avatar-badge-bg': 'red'` — clave **`_` DEL CONTRATO** | **ROJO** «*token that LIES*». **Era VERDE** antes de invertir el orden de `classify` |
| h | `--_hero-private-leak` en `blocks/hero/hero.svelte` | **VERDE**. Era **ROJO** bajo `SILENT_ROOTS` sin ser violación |
| i | `--hero-public-leak` en el mismo fichero | **ROJO** — `… ← src/uix/blocks/hero/hero.svelte:222 (declaration)`, en una raíz que esa mañana no barría nadie |
**Lo que las tres piezas del codemod dejaron** (detalle en sus propios informes
y en los READMEs que tocaron):
- **Clase A — el velo del drawer PINTA.** `overlayOpacity` pasa a llamarse
`overlayProgress` y se escribe como `--_drawer-overlay-progress`; la receta
COMPONE: `var(--drawer-overlay-opacity) * var(--_drawer-overlay-progress, 1)`.
Medido en Chrome real: reposo `color(srgb 0 0 0 / 0.408471)` (antes
`rgba(0,0,0,0)`), y un tema en `:root` a 20 % / 62 % / 100 % **ahora sí llega**
(0.131765 / 0.408471 / 0.658824).
- **Clase B — los 15 nombres publicados, retirados** en `command` ·`dialog` ·
`drawer` · `scroll-area` · `toast`, con sus READMEs reescritos. ⚠ La frase
«los 15 tienen CERO lectores» era **falsa para dos**:
`--scroll-area-corner-{width,height}` tenían un lector VIVO — un `var()`
inline dentro de soma (`scroll-area-provider:687,688`), invisible a un censo
de lectores acotado a la capa que lee. El renombrado no era libre de riesgo
por construcción; lo cazó barrer por STEM.
- **Clase D — dos de tres renombrados** (`--_tree-view-depth`,
`--_gradient-picker-current-gradient`) y `--scrollbar-width` **adjudicado por
medida, sin tocar**. Consecuencia cobrada: el censo reclasificó los dos
lectores de `gradient-picker` de `global` a `channel` y **dos líneas del
ledger salieron STALE y se retiraron** con acta fechada — ledger **1.150 →
1.148 · 0 new · 0 stale**. Es el único movimiento del ledger en toda la firma,
y no lo produjo la aguja: renombrar privados no toca el ledger.
**Verificación final, verbatim**: `npx vitest run src/uix/value-channels.test.ts`
**5/5** · `npm run docs:check` **0 errores / 0 avisos** · `npx tsc` **0 errores
propios** · censo `--debt` **1.148 · 0 new · 0 stale** · `prettier --check` sobre
el fichero nuevo, limpio.
**Lo que NO se ejecutó, y por qué** (no es recorte silencioso: cada uno tiene
dueño ajeno o veto escrito):
- **`src/uix/eidos/lib/recipes/base.ts`** — tres comentarios (`:2530`, `:3201`,
`:3268`) citan `--tree-depth` y `--gp-current-gradient` y hoy son FALSOS.
Vetado a las piezas por brief; es una palabra en cada uno y debería viajar en
el mismo commit.
- **`web/routes/**`** — CONGELADO por ley: 4 sitios (`tree-view/+page.svelte`
×3, `gradient-picker/+page.svelte` ×1).
- **La cabecera de `scripts/theming-census-debt.ts`** todavía dice «582 of the
1150 keys»; el total vivo es 1.148. Fichero de otra mano en esta misma firma.
*(Anotación del cierre: **CORREGIDO** el mismo día — el fichero estaba sucio
sólo por el acta de clase D de esta firma, no por un peer, así que se heredó.
Verificado con `--debt` antes de tocarlo: **1148 · 0 new · 0 stale**, y las
dos cifras por pista siguen siendo 359 + 223 = **582**, que no se mueve.)*
- **`scripts/theming-sentinel-exceptions.ts:815`** (`drawer.overlay-bg`) dice
que el velo *no pinta nada hoy*: ya está arreglado, y es un acta — se anota,
no se reescribe. *(Anotación del cierre: queda además registrada como fila de
**§18**, porque el fichero es un instrumento del eje y su pase es propio.)*
- **`types.ts` de `drawer` y `dialog`** documentan `'0.5'` como valor CSS válido
del prop de opacidad; `color-mix()` no admite un número ahí. Misma frase en
los dos gemelos: pide una firma conjunta. *(Anotación del cierre: es fila de
**§18** — `drawer/types.ts:73` y `dialog/types.ts:102`.)*
**§17 (C-1) sigue esperando firma** y es ahora lo siguiente de la cola. Gana
algo con esto: su violación más nítida (`scroll-area`, `--space-2-5` del
contrato contra un `8px` literal) vive **en soma**, y el espacio que A-1 abrió
es exactamente donde una aguja de corolario 2 tendrá que mirar.
*(Anotación del cierre, 2026-08-27: el pase de alcance abrió **§18** en este
mismo fichero — el otro lado del muro, `src/uix/eidos`. También espera firma, y
es la respuesta medida a «¿por dónde vuelve a nacer un nombre público sin
dueño?». No compite con §17: §17 es el corolario 2 del lado que LEE.)*
## 17. El corolario 2 no tiene aguja (C-1) — 2026-08-27
**PENDIENTE — redactado, NO ejecutado.** Dimensionado y medido el 2026-08-27;
@ -3264,3 +3469,167 @@ por sus dos caras (reintroducir un fallback discrepante ⇒ rojo nombrándolo;
retirarlo ⇒ verde). ⚠ Y queda dicho por escrito que **el guard sólo mira el lado
que LEE** hasta que §16 amplíe el espacio: la violación de `scroll-area` seguirá
fuera de su vista.
## 18. El otro lado del muro — eidos también escribe por instancia — 2026-08-27
**PENDIENTE — redactado, NO ejecutado.** Medido el 2026-08-27 apuntando **la
aguja de §16 sin tocarle una línea** a `src/uix/eidos`; nada de lo de abajo se
ha tocado en el código.
**Por qué existe esta sección.** Al cerrar §16 se le hizo al pase adversarial
una pregunta concreta: *¿queda alguna vía por la que un nombre público sin dueño
vuelva a nacer sin que nadie se entere?* La respuesta, medida por dos manos
independientes, es **sí: desde eidos**. R-5.5 barre nueve raíces y `src/uix/eidos`
no es una de ellas — hoy eso está **escrito en la ley** (§4 de
[`canon/recipe-contract.md`](canon/recipe-contract.md), «THE SCOPE»), que es la
mitad de la reparación. La otra mitad es este expediente.
### El censo MEDIDO — 3.034 ficheros de eidos, la misma aguja
| clase | sitios | qué es |
| --- | --- | --- |
| **token que MIENTE** | **11** | el nombre está en `base.ts` y se escribe inline |
| **forma pública sin dueño** | **18** | `--{c}-x` que ningún contrato declara |
| `--_{c}-*` | 46 | el destino de la doctrina — nada que hacer |
| vocabulario de sistema | 15 | `--motion-stagger-*`, `--shape-*`: la fundación hablando su idioma |
**90 escrituras.** Las 29 rojas por la letra se parten en **dos especies que no
son la misma**, y esa es toda la dificultad de la firma:
**Especie 1 · LA FUNDACIÓN generando su propio vocabulario — legítima.** 13 de
las 29 viven en `eidos/lib/*` y no son escrituras por instancia en absoluto:
son los generadores emitiendo la hoja. `build-space-scale.ts:93,104` emite
`--space-{n}`, `build-scheme.ts:211,214` emite `--primitive-*` y
`--color-*-contrast`, `build-type-scale.ts:85` emite `--font-size-{n}`,
`build-depth.ts:35`, `build-gradient.ts:126,135`, `component-color.ts:57`
(`--color-custom`) y `render-css.ts:1173,3054,3058`. **Un generador que declara
el vocabulario que todo el mundo lee no está publicando una superficie sin
dueño: está siendo la fundación.** Lo mismo vale para el único token contratado
del grupo, `--box-position` (`render-css.ts:620`), que es una **declaración
estática en la hoja generada** — el mecanismo propio de Box, documentado en su
sitio y medido en Chrome el 2026-08-17 — y no un inline por instancia. Por la
letra de R-5.5 sería rojo; por su antecedente no lo es. **Añadir el root a la
aguja pondría estas 13 en rojo el primer día.**
**Especie 2 · LAS PROPS ERGONÓMICAS del consumidor — ésta es la que pide
firma.** 16 sitios en `eidos/components/*`, y **10 de ellos escriben un nombre
del CONTRATO**:
| nombre contratado | sitio |
| --- | --- |
| `--dialog-overlay-opacity` | `dialog/dialog-overlay.svelte:27` |
| `--drawer-overlay-opacity` | `drawer/drawer-overlay.svelte:32` |
| `--scroll-area-scrollbar-size` | `scroll-area/scroll-area.svelte:58` |
| `--scroll-area-thumb-radius` | `scroll-area/scroll-area.svelte:59` |
| `--toast-toaster-gap` | `toast/toast-viewport.svelte:17` |
| `--barcode-fg` · `--barcode-bg` | `barcode/barcode.svelte:173,174` |
| `--qr-code-fg` · `--qr-code-bg` | `qr-code/qr-code.svelte:90,91` |
| `--background-pattern-cell` | `background/background-pattern.svelte:39` |
Más 6 formas públicas sin dueño al mismo nivel: `--cf-swatch-color`
(`color-field.svelte:34`, **y abreviada**, contra theming §6 r5) ·
`--text-focus-border-color` y `--text-focus-glow-color`
(`text-focus.svelte:117,118`, `style:` directive) · `--pcols`
(`palabras-element-inspector.svelte:550`) · `--palabras-scheme-{}-size` y
`--palabras-scheme-image-filter` (`palabras-scheme.ts:330,368`, hermanos de los
siete ya registrados en la aguja).
### El caso que lo hace urgente — el GEMELO del velo
`--dialog-overlay-opacity` es **el gemelo exacto del velo del drawer que §16
acaba de arreglar**: mismo mecanismo, mismo `serializeOpacity`, mismo
`color-mix()`, y sigue vivo por una razón sola — **`dialog` no tiene canal de
progreso, así que no había nada que componer y la pieza 1 no lo tocó**. Los dos
ficheros son la misma línea:
```
dialog-overlay.svelte:27 composeInlineStyle(style, resolvedOpacity ? `--dialog-overlay-opacity: ${resolvedOpacity};` : undefined)
drawer-overlay.svelte:32 composeInlineStyle(style, resolvedOpacity ? `--drawer-overlay-opacity: ${resolvedOpacity};` : undefined)
```
**Mientras soma ahora COMPONE, eidos sigue PISANDO.** El velo del drawer es hoy
`var(--drawer-overlay-opacity) * var(--_drawer-overlay-progress, 1)` — el knob
público por el canal privado, que es la forma firmada. El envoltorio de eidos, a
un directorio de distancia, sigue escribiendo el knob entero: **la especie
vieja, del otro lado del muro.** Medido en Chrome por el pase adversarial: con
el prop puesto (y la demo lo pone siempre), un tema en `:root` a 20 / 62 / 100 %
da `0.408471` en las tres — **no llega**; sin el prop, las tres llegan.
**Dos matices que la firma tiene que respetar, y que la separan de un codemod:**
1. **Las diez son CONDICIONALES** (`if (size)`, `resolvedOpacity ? … :
undefined`): sólo escriben cuando el consumidor pasa el prop. La escritura de
soma que §16 retiró era INCONDICIONAL — el tema no ganaba nunca. Aquí el tema
gana por defecto y sólo pierde donde alguien pidió explícitamente ese valor,
que es para lo que sirve un estilo inline. **Es un argumento real de
legitimidad, no una excusa.**
2. **Y aun así publica el nombre del contrato como su mecanismo.** El JSDoc dice
«Drives `--drawer-overlay-opacity`», de modo que el prop no es un valor: es
una vía documentada para pisar un token contratado sin composición posible.
### La fila que la propia firma dejó abierta — el ejemplo del tipo produce el bug
`src/uix/eidos/components/drawer/types.ts:73` y `dialog/types.ts:102`
documentan, **con la misma frase en los dos gemelos**, que el prop acepta
«`'80%'`, `'0.5'`». `serializeOpacity` convierte un número ≤ 1 a porcentaje pero
**pasa las cadenas verbatim**, y `color-mix()` no admite un número pelado en esa
posición: `overlayOpacity="0.5"` cae la mezcla entera y el velo desaparece.
**El ejemplo del propio tipo produce exactamente el bug que §16 acabó de
cerrar.** Es una firma conjunta de una línea en dos ficheros; no se tocó porque
son los dos gemelos y arreglar uno solo los divergiría.
### Actas que quedan como actas (se ANOTAN, no se reescriben)
- **`scripts/theming-sentinel-exceptions.ts:815`** (entrada `drawer.overlay-bg`)
declara que *el velo no pinta nada hoy*. **Quedó OBSOLETA el 2026-08-27**: ya
pinta (`color(srgb 0 0 0 / 0.408471)` en reposo). No se reescribe aquí por dos
razones que apuntan al mismo sitio — es un acta fechada, y el fichero es un
**instrumento del eje**. Se anota en su propio pase.
### Opciones, con su coste y su precio
| | qué | coste | precio |
| --- | --- | --- | --- |
| **0 · No hacer nada** | — | 0 | **La vía sigue abierta**: un prop ergonómico nuevo que escriba `--{c}-x` inline nace VERDE, y 29 sitios ya viven ahí. El gemelo del velo sigue pisando el knob, y el ejemplo `'0.5'` de dos `types.ts` sigue enseñando a romperlo. |
| **1 · Sólo el gemelo** | `dialog` compone como el drawer, o el prop escribe `--_dialog-overlay-opacity` y la receta lee `var(--_…, var(--…))` | 2 ficheros | Cierra el caso vivo y **deja la puerta abierta**: la aguja sigue sin ver eidos. |
| **2 · Firma de especie + aguja acotada** | adjudicar «fundación vs prop del consumidor» y extender R-5.5 a `eidos/components/*` **excluyendo `eidos/lib/*` por escrito** | la firma + ~1 línea de aguja + adjudicar 16 sitios | Nace en **rojo con 16 filas**; hay que adjudicarlas o convertirlas el mismo día. |
| **3 · Todo** | + la forma canónica en los diez: `--_{c}-x` escrito, `var(--_{c}-x, var(--{c}-x))` leído | + 10 recetas y 10 envoltorios | Toca CSS que hoy pinta: **exige verificación en navegador componente a componente**. |
### Recomendación
**Opción 2, y la firma va PRIMERO — no es la misma decisión que §16.** Allí el
sujeto era el framework escribiendo su propio canal, y la doctrina llevaba
cuatro aplicaciones firmadas. Aquí el sujeto es **un envoltorio ofreciendo un
prop a su consumidor**, que es la pregunta que §16 dejó explícitamente sin
responder al declarar su frontera. Los dos pases adversariales coinciden en lo
mismo y conviene copiarlo literal: **no es «añadir el root»** — distinguir
fundación de prop-del-consumidor **exige firma**, igual que §16 exigió las suyas.
Dentro de la opción 2, el criterio de quien lo midió: **las 13 de `eidos/lib/*`
no son deuda** (un generador ES la fundación) y deben quedar excluidas *por
escrito en la ley*, no por omisión del array — que es precisamente el defecto
que esta corrección acaba de arreglar en R-5.5.
**Lo que exige firma del autor, y sólo esto:**
1. **la especie**: ¿un prop ergonómico que pisa un token contratado es
legítimo por ser condicional, o es la misma mentira con mejores modales?
2. **el gemelo `dialog`**, si se arregla, **MUEVE PÍXEL** en cualquier demo que
pase el prop;
3. **el `'0.5'` de los dos `types.ts`** — una frase, dos ficheros, gemelos.
### Criterio de éxito
La ley dice qué hace R-5.5 con `eidos/components/*` **y con `eidos/lib/*`, las
dos por escrito**; el gemelo `dialog` compone o está adjudicado con su razón
medida; los 16 sitios de componente están adjudicados uno a uno (no tapados en
bloque); y la aguja se ha mutado por sus dos caras en la raíz nueva — un prop
que pisa un nombre contratado ⇒ rojo nombrándolo, el mismo valor por `--_{c}-*`
⇒ verde.
**Ficheros clave**: `src/uix/value-channels.test.ts` (`LAW_ROOTS` y el bloque
«THE BORDER», que es donde vive la frontera) ·
`src/uix/eidos/components/{dialog/dialog-overlay,drawer/drawer-overlay}.svelte`
· `src/uix/eidos/components/{drawer,dialog}/types.ts` ·
`src/uix/eidos/lib/{render-css,build-scheme,build-space-scale,build-type-scale,build-depth,build-gradient,component-color}.ts`
· `docs/canon/recipe-contract.md` §4 (R-5.5, «THE SCOPE»).

@ -7,7 +7,8 @@ sección §«EL CIERRE, EJECUTADO» abajo). Lo que vigila el estado desde hoy no
es este documento sino los guards: `theming-reach-floor.test.ts` (newDebt=0 ·
STALE=0 · reach ≥ 73 % · **≥67** al 100 %), `component-audit` R-5.1/5.2/5.3 en
`error`, y el centinela R-5.4. La deuda restante vive NOMBRADA clave a clave
en `scripts/theming-census-debt.ts` (**1150** entradas que sólo pueden menguar).
en `scripts/theming-census-debt.ts` (**1148** entradas que sólo pueden menguar;
eran 1150 hasta el 2026-08-27 — ver el handoff de cierre).
Trabajo nuevo de theming se abre como entrada nueva de §13/next-features, no
heredando de aquí. Las DOS firmas que el cierre dejó pendientes están
**EJECUTADAS**: la de los 318 privados no-derivados (§«FIRMA EJECUTADA — los
@ -30,22 +31,167 @@ están cerrados o adjudicados, la deuda de artefactos está saldada, y **§12.9
la firma B′ quedaron FIRMADAS Y EJECUTADAS** — las dos decisiones más caras.
Lo que hay abierto es posterior a ese cierre:
- **cola de EJECUCIÓN — cuatro entradas**, por orden de valor en el bloque (b)
del handoff del 27: A-1 · C-1 · `palabras` + `timeline` · los 7 sitios del
absoluto de cascada.
- **cola de EJECUCIÓN — TRES entradas** (eran cuatro; **A-1 está EJECUTADA**,
ver el handoff de cierre del 27 justo debajo): C-1 · `palabras` + `timeline` ·
los 7 sitios del absoluto de cascada.
- **cola de FIRMA** — la lista §5 «Lo que espera TU FIRMA», **VIVA**: once
entradas, **nueve esperando** (el bloque (c) la resume).
> **LO PRIMERO, si entras sin contexto**: lee el expediente **§16 de
> `docs/next-features.md`** (A-1, «el guard vive donde NACE el valor»). Está
> dimensionado, medido y marcado *PENDIENTE — redactado, NO ejecutado*, lleva su
> criterio de éxito escrito, y dice **qué es ejecutable ya y qué necesita tu
> firma**. Es lo único de la cola que mueve un píxel hoy. Después, §17 (C-1) —
> nunca antes: el porqué está en «Cuál va PRIMERO» de §16.
> **LO PRIMERO, si entras sin contexto**: **§16 (A-1) está EJECUTADA**
> (2026-08-27). La ley vive en `docs/canon/recipe-contract.md` §4 como
> **R-5.5**, hermana de la ley del espacio cerrado; el guard es
> `src/uix/value-channels.test.ts`; el acta con los números está en
> `docs/next-features.md` **§16-bis**. Lo siguiente de la cola es **§17 (C-1)**,
> que **sigue esperando firma** — una línea: ¿la cascada de atajos de
> `box`/`grid` es DEUDA o es IDIOMA?
>
> **Y hay un expediente NUEVO, §18**, abierto por el pase de alcance que cerró
> §16 esa misma tarde: **eidos también escribe por instancia** — 11 nombres del
> contrato y 18 formas sin dueño que R-5.5 **no barre y ahora declara por
> escrito** que no barre. Su caso urgente es el gemelo del velo del drawer,
> `--dialog-overlay-opacity`. Está **redactado y NO ejecutado**, y espera firma.
> El detalle, en §«El PASE DE ALCANCE del cierre» de este mismo handoff.
>
> *(Anotación 2026-08-27 al cierre. El párrafo original de este bloque decía:
> «lee el expediente §16 … marcado PENDIENTE — redactado, NO ejecutado … Es lo
> único de la cola que mueve un píxel hoy». Era cierto esa mañana; el velo del
> drawer ya pinta.)*
Global **73 %** (68 % antes de la clase `structural`, 69 % antes de
`bridge`/`channel`), **67 componentes al 100 %**.
## HANDOFF 2026-08-27 (cierre) — **A-1 / §16 EJECUTADA**
> El handoff de más abajo, «2026-08-27 (noche)», es de esa misma mañana y daba
> A-1 por pendiente. Se conserva entero: era cierto cuando se escribió. Esto es
> lo que pasó después.
**Lo firmado**: *toda escritura de custom property POR INSTANCIA vive en
`--_{c}-*`; si el nombre está en el contrato es un TOKEN QUE MIENTE, porque un
tema no puede ganarle jamás.* Quinta aplicación de la doctrina del canal de
valor (§14 tercera, §15 cuarta).
**Dónde quedó cada cosa:**
| pieza | dónde |
|---|---|
| la LEY | `docs/canon/recipe-contract.md` §4 — **R-5.5** en la tabla + el pasaje hermano de la ley del espacio cerrado |
| la AGUJA | `src/uix/value-channels.test.ts` — fichero propio, 5 tests, corre en `npm run test` ⇒ en el gate |
| el ACTA con los números | `docs/next-features.md` **§16-bis** |
| las DOS FORMAS, ya sin empate | `docs/architecture/soma-architecture.md` §9 — el bloque listaba las dos como si valieran las dos; ahora dice que sólo hay una y quién la hace cumplir |
**Los números de cierre**: **1.729** ficheros de código de soma + arts · **61**
nombres en **72** sitios · `--_{c}-*` **53** · **token que MIENTE 0** (era 1) ·
**forma pública sin dueño 8**, las ocho REGISTRADAS · `blocks` y `packs` en
**0**, ahora asertado. Contrato 4.693 nombres, vocabulario de sistema 1.058
(derivado restando el contrato a lo que emite la fundación, nunca a mano).
**Las dos correcciones que sólo aparecen midiendo:**
1. **El árbol tiene SEIS formas de escritura, no cuatro**, y una de las que
faltaban era **la CANÓNICA**: la clave computada
``[`--_${component}-floating-anchor-width`]`` que §14 y §15 firmaron. Un
guard ciego a ella es ciego al destino que hace cumplir. La otra,
`out['--x'] = v`. De ahí 61 nombres y no 49.
2. **«Los 15 de clase B tienen CERO lectores» era falso para dos**:
`--scroll-area-corner-{width,height}` tenían lector vivo, un `var()` inline
**dentro de soma** — invisible a un censo de lectores acotado a la capa que
lee. Barrer por STEM lo cazó; barrer por el nombre pelado no lo habría hecho.
**Mutaciones: seis caras, árbol restaurado byte a byte** — nombre sin prefijo →
rojo nombrándolo · clave DEL CONTRATO → rojo «token que MIENTE» · `--_` correcto
→ verde · nombre de SISTEMA → verde (única rama sin caso vivo, probada sólo por
inyección) · entrada de registro huérfana → rojo STALE · la forma canónica en
variante pública → rojo `--{}-…`.
**El ledger se movió, y no fue la aguja**: **1.150 → 1.148 · 0 new · 0 stale**.
Lo mueve la clase D: al dejar `--gp-current-gradient` de ser un nombre pelado,
el censo reclasificó sus dos lectores de `global` a `channel` y las dos líneas
salieron STALE. Renombrar privados no toca el ledger — por eso el resto de la
firma lo deja quieto. ⚠ La tabla (a) del handoff de la mañana dice **1.150**:
era la cifra de esa mañana y se conserva; la de hoy es 1.148.
**Lo que queda con dueño ajeno** (nada de esto es recorte silencioso): los tres
comentarios de `base.ts` (`:2530`, `:3201`, `:3268`) que citan los nombres
viejos y hoy MIENTEN, vetados por brief · 4 sitios en `web/routes/**`,
congelado · la cabecera de `scripts/theming-census-debt.ts`, que sigue diciendo
«582 of the 1150 keys» · `theming-sentinel-exceptions.ts:815`, que declara que
el velo no pinta cuando ya pinta (es un acta: se anota) · el ejemplo `'0.5'` de
`types.ts` en `drawer` **y** `dialog`, que produce exactamente el bug cerrado y
pide firma conjunta.
**Lo siguiente**: **§17 (C-1)**, que sigue esperando su firma de una línea. Gana
con esto — su violación más nítida vive en soma, el espacio que A-1 abrió.
### El PASE DE ALCANCE del cierre — la ley acotada, y §18 abierto
> Los dos pases adversariales reprodujeron los números de arriba **al dígito** y
> no encontraron una sola medida falsa. Lo que encontraron fue un **alcance sin
> escribir**, y el veredicto del crítico es la frase que ordena este bloque:
> *«es LEY sobre la mitad que barre, y sigue siendo PROSA sobre el absoluto que
> enuncia»*. Tres correcciones, ninguna un eje nuevo, todas ejecutadas el mismo
> día. La tabla de «lo que queda con dueño ajeno» de arriba se conserva entera:
> su tercera entrada (la cifra de 1150) **ya está corregida** — se anota aquí en
> vez de reescribirla.
1. **EL ALCANCE, ESCRITO.** R-5.5 enunciaba «*every* per-instance write»
mientras su aguja barría dos raíces. Hoy la ley nombra sus **nueve** —
`soma` · `arts` · `blocks` · `packs` · `libs` · `svrs` · `sema` · `morfo` ·
`active-uix` — y **declara sus dos fronteras**: `src/uix/eidos` (que NO está
vacío: 11 nombres contratados + 18 formas sin dueño) y `web/routes`
(congelado, y sin la cláusula de QUIÉN que haría juzgable una demo).
2. **LAS SALIDAS SON CUATRO.** Decía «tres, y no hay cuarta» mientras su propia
tabla de mutaciones enseñaba la cuarta en verde: el **vocabulario de
sistema**. Reescritas en el orden en que el guard las pregunta.
3. **EL REGISTRO SE QUEDA, con cuatro campos.** Es el patrón de la casa
(transitorio, sólo mengua, STALE lo vacía solo) y ahora responde por escrito
a la objeción de que `PENDING_PRIVATE_RENAME` se desmontó 24 h antes: aquélla
era una COLA de renombres ya decididos, y una cola sin plazo es un cajón;
éstas son adjudicaciones que la ley no alcanza. Cada entrada lleva **fecha ·
razón · destino · por qué NO se movió hoy**, y el guard asserta que los
cuatro están rellenos. Las 8 entradas están completas.
**Dos menores, medidos antes de decidir**: `classify` preguntaba por `--_` antes
que por el contrato, de modo que las **124** claves `_` de `base.ts` se escribían
inline gratis — **0 de 64** escrituras privadas de soma+arts y **0 de 46** de
eidos nombran una clave contratada, así que invertir el orden cerró el agujero
**sin enrojecer nada**. Y `SILENT_ROOTS` prohibía TODA escritura en
`blocks`/`packs`: un bloque que estampe geometría privada legítima nacía rojo.
Hoy esas raíces se barren bajo los mismos dos rojos, **nunca contra cero**.
**Mutaciones del pase** (backup + restauración en la misma invocación, SHA
idéntico): re-corridas **b′** (clave del contrato → ROJO «token that LIES») y
**f′** (la forma canónica computada, variante pública → ROJO `--{}-…`), que
prueban que la aguja **sigue mordiendo tras los cambios**; y tres nuevas —
**g** `--_avatar-badge-bg` (clave `_` DEL CONTRATO) → ROJO, **era verde** ·
**h** `--_hero-private-leak` en `blocks` → VERDE, **era rojo sin ser violación**
· **i** `--hero-public-leak` en `blocks` → ROJO, en una raíz que esa mañana no
barría nadie.
**§18 ABIERTO — la respuesta medida a «¿por dónde vuelve a nacer un nombre
público sin dueño?»: desde eidos.** 3.034 ficheros, 90 escrituras, **11 tokens
que MIENTEN + 18 formas sin dueño**, en dos especies que no son la misma: 13
sitios de `eidos/lib/*` son **la fundación generando su vocabulario**
(legítimos, y añadir el root los pondría rojos el primer día) y 16 de
`eidos/components/*` son **props ergonómicas del consumidor**. El caso que lo
hace urgente: **`--dialog-overlay-opacity` es el gemelo exacto del velo del
drawer** —misma línea, mismo `color-mix()`— vivo porque `dialog` no tiene canal
de progreso y no se tocó. **Mientras soma COMPONE (`knob × progress`), eidos
sigue PISANDO el knob.** Marcado **PENDIENTE — redactado, NO ejecutado**, con
opciones, «no hacer nada» y su precio, y recomendación. **No es «añadir el
root»: distinguir fundación de prop-del-consumidor exige FIRMA**, igual que §16
exigió las suyas.
**Corregido de paso**: `scripts/theming-census-debt.ts:59` decía «582 of the
**1150** keys» con **1148** vivos (verificado con `--debt`: 1148 · 0 new · 0
stale; las pistas no se mueven — `palabras` 359 + `chronos` 223 = 582). El
fichero estaba sucio sólo por el acta de clase D de esta misma firma, no por un
peer, así que se heredó y se corrigió con acta propia en su cabecera.
**Sigue esperando firma**: **§17 (C-1)**, una línea · **§18**, la especie de
eidos + el gemelo `dialog` + el `'0.5'` de los dos `types.ts` · y las **dos
adjudicaciones** que viven en el registro de `src/uix/value-channels.test.ts`.
## HANDOFF 2026-08-27 (noche) — POR AQUÍ MAÑANA
### (a) El estado REAL de hoy, con los números vivos
@ -118,6 +264,14 @@ quinto —el único movimiento respecto del 162/4 histórico— es del eje P1/mo
### (b) Lo que queda, por orden de VALOR
> *(Anotación 2026-08-27 al cierre — el punto 1, A-1, está **EJECUTADO**: ley
> R-5.5 en `canon/recipe-contract.md` §4, guard `src/uix/value-channels.test.ts`,
> acta en `next-features.md` §16-bis. Se conserva como el diagnóstico que fue.
> ⚠ Dos de sus cifras las corrige el acta por medida: son **61 nombres en 72
> sitios**, no 49/76 —el barrido previo enumeró cuatro formas de escritura y el
> árbol tiene seis— y **dos de los 15 «sin lectores» sí tenían lector**,
> `--scroll-area-corner-{width,height}`, dentro de soma.)*
1. **A-1 — «el guard vive donde NACE el valor».** El censo, el ledger, R-5.1 y
la ley del espacio cerrado leen `src/uix/eidos/components` **y nada más**
(`scripts/theming-census.ts`, `const ROOT`). Quien ESCRIBE la custom
@ -237,6 +391,12 @@ aguanta **incluso bajo `!important`**.
el 24 sin tachar). La más urgente sigue siendo la **1**: el plano
`overlay` impone tipografía a veinte componentes — hasta que se firme, **no
acuñes `font-family` ni `line-height` en ninguna superficie `overlay`**.
- *(Anotación 2026-08-27 al cierre — el bullet siguiente está **CUMPLIDO**: las
dos cosas se firmaron y se ejecutaron el mismo día. El velo pinta —medido en
Chrome real— y los 15 nombres están retirados. Lo que A-1 deja abierto no es
ninguna de esas dos: son las **dos adjudicaciones registradas**
—`--scrollbar-width` y los siete `--palabras-scheme-*`— que esperan firma en
el registro de `src/uix/value-channels.test.ts`.)*
- **De A-1, sólo dos cosas exigen firma** (el resto es ejecutable bajo doctrina
vigente, y es la QUINTA aplicación de la doctrina «la capa es la pluma, no la
dueña»; la CUARTA es **§15** de `next-features.md`): (i) arreglar el velo del
@ -2116,6 +2276,16 @@ están a 0 % **honesto** (consumen `calendar-surface`).
> como tal abajo. La numeración de origen venía desordenada (…8, 11, 10, 9);
> se corrigió a secuencial sin mover ni una entrada de sitio ni renombrar
> nada — no había referencias externas a estos números (verificado).*
>
> ⚠ *Anotación 2026-08-27 (cierre de §16): esta lista NO es ya el índice
> completo de lo que espera firma, y conviene saberlo antes de trabajarla.
> Fuera de ella esperan: las **dos adjudicaciones** que abrió §16 y que viven
> **dentro del registro de `src/uix/value-channels.test.ts`**
> (`--scrollbar-width` y los siete `--palabras-scheme-*`, cada una con fecha,
> razón, destino y por qué no se movió), y el expediente **§18** de
> `next-features.md` (eidos escribe por instancia: 11 nombres contratados + 18
> formas sin dueño). Se anotan aquí en vez de renumerar la lista, que no tiene
> hueco para una entrada que vive en un fichero de test.*
1. **El plano `overlay` impone tipografía a VEINTE componentes — y en
`tooltip` se queda además el FONDO, el BORDE y la SOMBRA**, con lo que sus

@ -56,7 +56,7 @@
* improvement, and re-signing it is what records the improvement.
*
* THE WIP TRACKS ARE IN — `palabras` (272 global + 86 literal + 1 private) and
* `chronos` (198 global + 11 literal + 14 private) are 582 of the 1150 keys, and they
* `chronos` (198 global + 11 literal + 14 private) are 582 of the 1148 keys, and they
* are registered like everyone else, for one reason: **debt is real
* wherever it lives**. `component-audit` excludes those tracks from its
* catalogue so WIP noise does not read as catalogue breakage — a rule about
@ -158,6 +158,32 @@
* corrected at its source, same reading as the `tabs` acta above. Detail and
* measurement: the ACTA over `classify` in `theming-census.ts`.
*
* ── ACTA 2026-08-27 (SS16, clase D) — two `gradient-picker` entries RETIRED ──
* `global · gradient-picker.css · {.gradient-picker-trigger-swatch,
* [data-gradient-picker-value-swatch]} · background` left by exit 4 (SYSTEM)
* read honestly, exit 1 (TOKENIZED) read strictly: NOTHING changed shape in the
* recipe — the two `background` declarations read the same channel they always
* read, under a new NAME. Same animal as the `tabs` acta above, one floor down.
* Soma stamped the user's live gradient as `--gp-current-gradient`: public in
* shape, owned by nobody, and ABBREVIATED against theming §6 r5 — so the census
* could only score it `global` and the closed-namespace law never saw it (that
* law audits `--{c}-*`; a name with no component prefix is foreign vocabulary).
* It now writes `--_gradient-picker-current-gradient`, in the component's own
* namespace and unabbreviated, so the two knobs read as what they always were:
* the value CHANNEL, out of the ratio. Measured: `gradient-picker` global 2 → 0,
* channel 0 → 2, reach 96 %, 27 knobs unchanged. No debt was forgiven — a
* misclassification was corrected at its source. The sibling flip of the same
* firma, `--tree-depth` → `--_tree-view-depth`, retires NO entry here: that
* calc also reads `--tree-view-indent`, so the census already scored it
* `bridge` and the bare name never reached this ledger.
* ⚠ AND THE HEADER FIGURE MOVED WITH IT (corrected 2026-08-27, same firma,
* at its close): the WIP-track paragraph above said «582 of the 1150 keys»,
* which was the total before those two lines were retired. The tracks
* themselves did not move — `palabras` 359 + `chronos` 223 = 582 either way —
* so only the denominator was stale: `--debt` reports **1148 · 0 new · 0
* stale**. A total quoted in prose is not regenerated by anything, which is
* exactly why it went stale in the same pass that moved the list.
*
* Generated: `node --import tsx/esm scripts/theming-census.ts --debt --write`
* Checked: `node --import tsx/esm scripts/theming-census.ts --debt`
* Guarded: `src/uix/eidos/theming-reach-floor.test.ts` (catalogue) +
@ -606,8 +632,6 @@ export const CENSUS_DEBT: Record<string, string[]> = {
'literal · gradient-builder.css · [data-gradient-builder] · inline-size'
],
'gradient-picker': [
'global · gradient-picker.css · .gradient-picker-trigger-swatch · background',
'global · gradient-picker.css · [data-gradient-picker-value-swatch] · background',
'literal · gradient-picker.css · [data-gradient-picker-preset] · inline-size'
],
'grid-list': [

@ -331,7 +331,7 @@ Lo que entró el **2026-08-23** (57 % → 95 %), y por qué:
- **`--_background-gradient-image` es CANAL DE VALOR, no superficie de tema**:
lo escribe `background-gradient.svelte` en línea, desde la prop `colors`. Un
público encima no lo alcanzaría (el inline gana) y mentiría — el mismo caso
que `--gp-current-gradient` del gradient-picker.
que `--_gradient-picker-current-gradient` del gradient-picker.
## Preferences

@ -87,7 +87,8 @@ segundo **no tiene token, a propósito**. Soma lo estampa INLINE desde la prop
`gap` del consumidor (necesita el número para el cálculo de `flex-basis`), así
que ninguna declaración de receta puede ganarle: el trío `item-gap*` que este
eje acuñó primero no movía nada y la revisión adversarial de 2026-08-21 lo
retiró con 0 diffs. Es un canal de valor de soma, como `--gp-current-gradient`;
retiró con 0 diffs. Es un canal de valor de soma, como
`--_gradient-picker-current-gradient`;
que soma lo lea de un token es decisión de otra capa (registrada en
`next-features.md` §13).

@ -46,9 +46,15 @@
display: block;
position: fixed;
inset: 0;
/* Two species, composed — never one overwriting the other:
* `--drawer-overlay-opacity` is the PUBLIC knob (a theme's percentage, 62 %
* by contract); `--_drawer-overlay-progress` is soma's per-instance value
* channel (unitless 0–1, the snap/drag progress). The drag ATTENUATES the
* theme. Fallback `1` = at rest, before soma writes anything. */
background: color-mix(
in srgb,
var(--drawer-overlay-bg) var(--drawer-overlay-opacity),
var(--drawer-overlay-bg)
calc(var(--drawer-overlay-opacity) * var(--_drawer-overlay-progress, 1)),
transparent
);
backdrop-filter: blur(var(--drawer-overlay-blur));

@ -37,14 +37,14 @@ sus dos consumidores reales).
## Parts
| Part | Composes | Notes |
| ----------------------------------------- | ---------------------- | ---------------------------------------------------------- |
| `Provider` | soma Provider | Sets a size/variant visual context. |
| `Trigger` | soma Trigger + Popover | Field-shaped pill with the gradient chip. |
| `ValueSwatch` | soma ValueSwatch | Standalone gradient chip (paints `--gp-current-gradient`). |
| `Content` | Popover + PickerShell | The floating editor: GradientBuilder + footer. |
| `Footer` / `Clear` / `Cancel` / `Close` | PickerShell | The shared footer actions (read `pickerShellContext`). |
| `Portal` / `Anchor` / `Overlay` / `Arrow` | Popover | Re-exports. |
| Part | Composes | Notes |
| ----------------------------------------- | ---------------------- | ------------------------------------------------------------------------ |
| `Provider` | soma Provider | Sets a size/variant visual context. |
| `Trigger` | soma Trigger + Popover | Field-shaped pill with the gradient chip. |
| `ValueSwatch` | soma ValueSwatch | Standalone gradient chip (paints `--_gradient-picker-current-gradient`). |
| `Content` | Popover + PickerShell | The floating editor: GradientBuilder + footer. |
| `Footer` / `Clear` / `Cancel` / `Close` | PickerShell | The shared footer actions (read `pickerShellContext`). |
| `Portal` / `Anchor` / `Overlay` / `Arrow` | Popover | Re-exports. |
## Props
@ -70,17 +70,18 @@ no loop.
Self-contained (graduated from the dev track 2026-07-10; private tokens by
choice — the chrome is mostly the composed Popover + PickerShell +
GradientBuilder recipes). The trigger is a field-shaped pill; the chip +
standalone swatch paint `--gp-current-gradient` (stamped inline by soma).
standalone swatch paint `--_gradient-picker-current-gradient` (stamped inline by
soma).
## Comparativa
| Capacidad | UIX | Figma (fill popover) | Libs web de color con tab gradiente |
| -------------------------------------------------- | --------------------------- | --------------------- | ----------------------------------- |
| Form control con valor comprometido (`bind:value`) | **✓** | No (edición in-place) | Parcial (sin transacción) |
| Footer transaccional (Clear/Cancel/Save + revert) | ✓ (PickerShell) | No | No |
| Editor accesible por teclado dentro | ✓ (builder apg slider) | No | No |
| Chip de valor vivo en el trigger | ✓ (`--gp-current-gradient`) | ✓ | Parcial |
| Overlay delegado al sistema | ✓ (Popover §2) | Propio | Propio |
| Capacidad | UIX | Figma (fill popover) | Libs web de color con tab gradiente |
| -------------------------------------------------- | ---------------------- | --------------------- | ----------------------------------- |
| Form control con valor comprometido (`bind:value`) | **✓** | No (edición in-place) | Parcial (sin transacción) |
| Footer transaccional (Clear/Cancel/Save + revert) | ✓ (PickerShell) | No | No |
| Editor accesible por teclado dentro | ✓ (builder apg slider) | No | No |
| Chip de valor vivo en el trigger | ✓ (canal de valor) | ✓ | Parcial |
| Overlay delegado al sistema | ✓ (Popover §2) | Propio | Propio |
Referencias: [`../gradient-builder/README.md`](../gradient-builder/README.md)
(el editor y su research) · patrón §2 en [`../date-picker/`](../date-picker/).
@ -137,10 +138,11 @@ PORTAL y dimensiona con `data-picker-size` —el atributo de `picker-shell`—,
el vocabulario de scopes no tiene palabra para el atributo de otro componente.
**El relleno del chip no es un knob**: soma estampa el degradado del usuario
inline (`--gp-current-gradient`, un canal de valor), y un tema no tiene nada
que decir sobre lo que el usuario eligió. Que ese nombre esté abreviado
—contra theming §6 r5— es deuda de SOMA, no de aquí; anotada en
`next-features.md` §13.
inline (`--_gradient-picker-current-gradient`, un canal de valor), y un tema no
tiene nada que decir sobre lo que el usuario eligió. La deuda del nombre
ABREVIADO —`--gp-*`, contra theming §6 r5— quedó **pagada el 2026-08-27** por la
clase D de SS16: el canal vive ahora en el namespace del componente, sin abreviar
y con `_`, que es lo que dice que no es superficie de tema.
## Gaps

@ -1,7 +1,7 @@
<script lang="ts">
/**
* Eidos `<GradientPicker.Trigger>` — paint wrapper. Soma owns the value and
* stamps `--gp-current-gradient` inline; this wrapper adds the visual
* stamps `--_gradient-picker-current-gradient` inline; this wrapper adds the visual
* data-attrs + the gradient preview chip. CSS reads the var to paint it.
*/
import { ActiveEidos } from '$uix/eidos';

@ -5,8 +5,11 @@
* editor layout reuse the shared Popover + PickerShell + GradientBuilder
* recipes; every knob of its own is a public `--gradient-picker-*`.
*
* Soma stamps `--gp-current-gradient` inline on the trigger + value swatch, so
* the chip paints purely from CSS — soma owns the value, eidos owns the paint.
* Soma stamps `--_gradient-picker-current-gradient` inline on the trigger +
* value swatch, so the chip paints purely from CSS — soma owns the value, eidos
* owns the paint. A per-instance VALUE CHANNEL, not a theme knob (SS16): the
* gradient is the picker's current value, so a theme pinning it would show every
* picker the same colours regardless of what the user chose.
*/
[data-gradient-picker] {
@ -58,7 +61,7 @@
block-size: var(--gradient-picker-chip-size);
border-radius: var(--gradient-picker-chip-radius);
border: var(--gradient-picker-border-width) solid var(--gradient-picker-chip-border);
background: var(--gp-current-gradient, transparent);
background: var(--_gradient-picker-current-gradient, transparent);
}
.gradient-picker-trigger-label {
white-space: nowrap;
@ -70,7 +73,7 @@
block-size: var(--gradient-picker-chip-size);
border-radius: var(--gradient-picker-chip-radius);
border: var(--gradient-picker-border-width) solid var(--gradient-picker-chip-border);
background: var(--gp-current-gradient, transparent);
background: var(--_gradient-picker-current-gradient, transparent);
}
/* ── Content — same width as the ColorPicker (the editor + a three-item footer

@ -56,7 +56,7 @@ alcanza **todas** (9/9, 2026-08-23).
y `font-weight` los escribe el componente INLINE en cada render, desde las props
`family` / `size` / `weight` resueltas por `eidos.resolve` (que siempre produce
un valor). Un `--s-text-virtual-list-font-*` quedaría pisado por ese inline y
mentiría — es la clase de `--gp-current-gradient` y del canal de rect del
mentiría — es la clase de `--_gradient-picker-current-gradient` y del canal de rect del
indicador de `navigation-menu`. El mando aquí ES la prop, y el tema llega por
donde viven esos tokens: la capa tipográfica. Además el triple tiene que ser
EXACTO: el canvas que mide el texto lee `getComputedStyle` del contenedor, así

@ -11,7 +11,7 @@ imperative toaster API.
| Platform | Surface | Comparison |
| -------- | ------- | ---------- |
| Air (`glm-5`) | `Toaster`, `Root`, `Title`, `Message`, `Description`, `Action`, `Close`; position/gap, default status icon by type, enter/exit behavior cues. | Covered and expanded. Eidos keeps `Toaster`, replaces legacy `type` with UIX `intent`, and uses `Status`/`Main` layout parts instead of Air's unbacked CSS classes. |
| Radix Toast | `Provider`, `Viewport`, `Root`, `Title`, `Description`, `Action`, `Close`; auto-dismiss, hover/focus pause, hotkey, swipe, `forceMount`, foreground/background urgency. | Covered by Soma. Urgency maps from UIX intents to `status`/`alert`; swipe CSS variables are exposed with `--toast-*`. |
| Radix Toast | `Provider`, `Viewport`, `Root`, `Title`, `Description`, `Action`, `Close`; auto-dismiss, hover/focus pause, hotkey, swipe, `forceMount`, foreground/background urgency. | Covered by Soma. Urgency maps from UIX intents to `status`/`alert`; the swipe displacement travels in private `--_toast-swipe-*` value channels, not as consumer API. |
| Ark UI Toast | `createToaster`, `Toaster`, `Root`, `Title`, `Description`, `Action`, `Close`; max, placement, overlap, offsets, promise/update APIs, hotkey. | Covered for queue/promise/update/hotkey/placement. `overlap` and structured `offsets` are deferred to layout recipes. |
| Sonner / shadcn-svelte | Imperative `toast()` API and `<Toaster>`, positions, rich colors, actions, promise toasts. | Covered with `createToaster()` + `<Toaster>`. Eidos uses intents instead of `success/error/warning/info`. |

@ -98,7 +98,7 @@ igual que `table` y `tree-grid`. En xs el `row-padding-block` colapsa a `0`: a
esa talla la altura de fila lleva sola el ritmo.
**El `indent` es un eje aparte del `row-padding-inline`, y tenía que serlo.**
Los dos se suman en el mismo `calc` (`--tree-depth × indent + padding`), y la
Los dos se suman en el mismo `calc` (`--_tree-view-depth × indent + padding`), y la
propuesta generada los fundió en un solo nombre por talla — con eso, tematizar
la sangría habría movido también el padding. Son dos knobs.

@ -10,7 +10,7 @@
* [data-tree-view-item] → leaf row <li role="treeitem">
* [data-tree-view-label] → text label slot
*
* Indentation uses the `--tree-depth` custom property that soma writes
* Indentation uses the `--_tree-view-depth` custom property that soma writes
* on every Branch / Item — multiplied by `--tree-view-indent` (resolved
* per `data-size` by the TSC). Wrapper `<div data-tree-view-root>` carries the
* eidos visual data-attrs (variant / size / color / block / scroll /
@ -83,7 +83,9 @@
/* ── Branch + Item rows ─
*
* `--tree-depth` is written by soma on each Branch / Item. The control's
* `--_tree-view-depth` is written by soma on each Branch / Item — a per-instance
* VALUE CHANNEL, not a theme knob (SS16): the depth is a fact of each row, so a
* theme pinning it would flatten every level onto the same indent. The control's
* inline padding-start follows the depth, indenting children visually.
*/
[data-tree-view-branch-control],
@ -96,7 +98,7 @@
padding-block: var(--tree-view-row-padding-block);
padding-inline-end: var(--tree-view-row-padding-inline);
padding-inline-start: calc(
var(--tree-depth, 0) * var(--tree-view-indent) + var(--tree-view-row-padding-inline)
var(--_tree-view-depth, 0) * var(--tree-view-indent) + var(--tree-view-row-padding-inline)
);
border-radius: var(--tree-view-row-radius);
color: inherit;
@ -176,7 +178,7 @@
DOUBLE flip — the logical inset had already mirrored — and it parked the
guide on the far side of its own subtree. RTL-1 cannot see this shape. */
inset-inline-start: calc(
var(--tree-depth, 0) * var(--tree-view-indent) +
var(--_tree-view-depth, 0) * var(--tree-view-indent) +
var(--tree-view-indent) / 2 +
var(--tree-view-row-padding-inline)
);

@ -211,7 +211,7 @@ describe('lintRtlMirror — RTL-2', () => {
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));
inset-inline-end: calc(var(--_tree-view-depth, 0) * var(--_indent));
}
`;

@ -32,7 +32,7 @@ A command palette with fuzzy scoring, keyboard navigation, groups, empty/loading
| `Provider` | `<div>` | Root context. Manages value, search, filtering, and navigation. |
| `Input` | `<input>` | Combobox input. Drives search filtering and keyboard navigation. |
| `List` | `<div>` | Listbox container for items, groups, and empty state. |
| `Viewport` | `<div>` | Measures child height and exposes `--command-list-height`. |
| `Viewport` | `<div>` | Measures child height and exposes `--_command-list-height`. |
| `Item` | `<div>` | Selectable option. Only renders when visible (passes filter). |
| `LinkItem` | `<a>` | Same as Item but renders an anchor for navigation. |
| `Group` | `<div>` | Groups related items. Always renders; sets `data-empty` for CSS. |
@ -100,9 +100,19 @@ A command palette with fuzzy scoring, keyboard navigation, groups, empty/loading
## CSS Variables
| Variable | Part | Description |
| ---------------------------- | -------- | -------------------------------- |
| `--command-list-height` | Viewport | Measured height of child element |
Soma writes the viewport's measured geometry into a **private value channel**:
| Channel | Part | Meaning |
| ------------------------ | -------- | -------------------------------- |
| `--_command-list-height` | Viewport | Measured height of child element |
**This is not consumer API.** The leading `_` is the house mark of a value
channel. These are internal — soma writes them inline per instance, so no theme
can win them (only an author `!important` rule overrides the inline write, and
that freezes the live geometry). No recipe reads this one today; it is listed to
describe the mechanism, not to invite a `var()`. Style the **part**
(`[data-command-viewport]`, `[data-command-list]`) and the public `--command-*`
knobs instead.
## Keyboard

@ -588,7 +588,7 @@ export class CommandListProvider {
);
}
// ── Viewport — exposes --command-list-height ───────────────────────────
// ── Viewport — exposes --_command-list-height ──────────────────────────
interface CommandViewportOpts extends WithRefOpts {}
@ -624,7 +624,7 @@ export class CommandViewportProvider {
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
style: this.height !== undefined ? `--command-list-height: ${this.height}px;` : undefined
style: this.height !== undefined ? `--_command-list-height: ${this.height}px;` : undefined
} as const)
);
}

@ -70,10 +70,20 @@ Content and Overlay render where placed. Wrap in a Portal component for body-lev
## CSS Variables
| Variable | Part | Description |
| ---------------------------- | ---------------- | ---------------------------------- |
| `--dialog-depth` | Content, Overlay | Nesting depth (0 for first dialog) |
| `--dialog-nested-count` | Content, Overlay | Number of nested dialogs open |
Soma writes the nesting bookkeeping into **private value channels**:
| Channel | Part | Meaning |
| ------------------------- | ---------------- | ---------------------------------- |
| `--_dialog-depth` | Content, Overlay | Nesting depth (0 for first dialog) |
| `--_dialog-nested-count` | Content, Overlay | Number of nested dialogs open |
**These are not consumer API.** The leading `_` is the house mark of a value
channel. These are internal — soma writes them inline per instance, so no theme
can win them (only an author `!important` rule overrides the inline write, and
that freezes the live value). No recipe reads them today; they are listed to
describe the mechanism, not to invite a `var()`. To style a nested dialog use
the **part** and its attributes — `[data-dialog-content]` with `data-nested` /
`data-nested-open` — plus the public `--dialog-*` knobs.
## Keyboard
@ -132,7 +142,7 @@ Focus is automatically moved to the first focusable element on open. On close, f
<Dialog.Provider bind:open={inner}>
<Dialog.Trigger>Open Nested</Dialog.Trigger>
<Dialog.Content>
<!-- data-nested, --dialog-depth: 1 -->
<!-- data-nested, --_dialog-depth: 1 -->
</Dialog.Content>
</Dialog.Provider>
</Dialog.Content>

@ -478,8 +478,8 @@ export class DialogContentProvider {
'data-nested-open': this.provider.hasNestedOpen ? '' : undefined,
style: {
'pointer-events': 'auto',
'--dialog-depth': `${this.provider.depth}`,
'--dialog-nested-count': `${this.provider.nestedOpenCount.current}`
'--_dialog-depth': `${this.provider.depth}`,
'--_dialog-nested-count': `${this.provider.nestedOpenCount.current}`
},
...this.provider.contentPresence.transitionAttrs,
...this.focusScope.props,
@ -519,8 +519,8 @@ export class DialogOverlayProvider {
'data-nested-open': this.provider.hasNestedOpen ? '' : undefined,
style: {
'pointer-events': 'auto',
'--dialog-depth': `${this.provider.depth}`,
'--dialog-nested-count': `${this.provider.nestedOpenCount.current}`
'--_dialog-depth': `${this.provider.depth}`,
'--_dialog-nested-count': `${this.provider.nestedOpenCount.current}`
},
...this.provider.overlayPresence.transitionAttrs
}));

@ -68,13 +68,25 @@ A panel that slides in from the edge of the screen. Supports snap-point resize,
## CSS Variables
| Variable | Part | Description |
| -------------------------- | ---------------- | ------------------------------------- |
| `--drawer-progress` | Content | Drag progress 0–1 |
| `--drawer-offset-x` | Content | Horizontal drag offset in px |
| `--drawer-offset-y` | Content | Vertical drag offset in px |
| `--drawer-depth` | Content, Overlay | Nesting depth (0 for first drawer) |
| `--drawer-overlay-opacity` | Overlay | Computed opacity based on snap points |
The provider publishes per-instance gesture and nesting values as a **value
channel in the component's own private namespace** (§16, 2026-08-27). These are
internal — soma writes them inline per instance, so no theme can win them; the
eidos recipe consumes them. Style the **part**
(`[data-drawer-content]`, `data-side` / `data-state` / `data-dragging`) and the
public `--drawer-*` knobs instead.
| Variable | Part | Description |
| ---------------------------- | ---------------- | ---------------------------------- |
| `--_drawer-progress` | Content | Drag progress 0–1 |
| `--_drawer-offset-x` | Content | Horizontal drag offset in px |
| `--_drawer-offset-y` | Content | Vertical drag offset in px |
| `--_drawer-depth` | Content, Overlay | Nesting depth (0 for first drawer) |
| `--_drawer-overlay-progress` | Overlay | Snap/drag progress 0–1, unitless |
`--drawer-overlay-opacity` is NOT one of these: it stays the **public knob** the
theme owns (`62%` by contract). The recipe composes the two —
`calc(var(--drawer-overlay-opacity) * var(--_drawer-overlay-progress, 1))` — so
the gesture attenuates the theme instead of replacing it.
## Keyboard
@ -168,7 +180,7 @@ resets it behind your back — that would have been a silent write, visible to
<Drawer.Provider bind:open={inner}>
<Drawer.Trigger>Open Nested</Drawer.Trigger>
<Drawer.Content>
<!-- data-nested, --drawer-depth: 1 -->
<!-- data-nested, --_drawer-depth: 1 -->
</Drawer.Content>
</Drawer.Provider>
</Drawer.Content>

@ -368,8 +368,13 @@ export class DrawerProvider {
this.overlayRef.current = el;
}
/** Overlay opacity: 1 fully open, scales toward 0 as the panel shrinks via snap. */
get overlayOpacity(): number {
/**
* Overlay progress: 1 fully open, scales toward 0 as the panel shrinks via
* snap. This is a unitless MULTIPLIER over the themed
* `--drawer-overlay-opacity` knob — never the opacity itself. The recipe
* composes the two; soma only holds the pen on the value channel.
*/
get overlayProgress(): number {
if (!this.opts.open.current) return 0;
const snap = this.opts.activeSnapPoint.current;
if (snap === null || typeof snap !== 'number' || snap > 1) return 1;
@ -1074,10 +1079,10 @@ export class DrawerContentProvider {
style: {
'pointer-events': 'auto',
'touch-action': 'none',
'--drawer-progress': `${progress}`,
'--drawer-offset-x': `${this.usesAxialResize() ? axialOffsetX : this.gesture.offsetX}px`,
'--drawer-offset-y': `${this.usesAxialResize() ? axialOffsetY : this.gesture.offsetY}px`,
'--drawer-depth': `${this.provider.depth}`,
'--_drawer-progress': `${progress}`,
'--_drawer-offset-x': `${this.usesAxialResize() ? axialOffsetX : this.gesture.offsetX}px`,
'--_drawer-offset-y': `${this.usesAxialResize() ? axialOffsetY : this.gesture.offsetY}px`,
'--_drawer-depth': `${this.provider.depth}`,
// Resize mode: inline height/width derived from AxialDrag's
// offsetPx (live during drag, settled after release).
...(sizeStyle ? { [sizeStyle.property]: sizeStyle.value } : {}),
@ -1141,8 +1146,10 @@ export class DrawerOverlayProvider {
'data-nested-open': this.provider.hasNestedOpen ? '' : undefined,
style: {
'pointer-events': 'auto',
'--drawer-depth': `${this.provider.depth}`,
'--drawer-overlay-opacity': `${this.provider.overlayOpacity}`
'--_drawer-depth': `${this.provider.depth}`,
// Value channel, NOT the themed knob: the recipe multiplies
// `--drawer-overlay-opacity` (public, from the theme) by this.
'--_drawer-overlay-progress': `${this.provider.overlayProgress}`
},
onclick: this.onclick,
...this.provider.overlayPresence.transitionAttrs

@ -154,6 +154,7 @@ Both are in `ACTIVE_DEV_TRACK` in `src/uix/contracts.test.ts` (catalogue guards
- Identity wrapper over a composed Popover (`PopoverProvider` shares the `open` writable). `GradientPickerProvider` exposes `commit()/cancel()/clear()` via `pickerShellContext` so `<PickerShell.Clear/Cancel/Close>` drive it. `valueOnOpen` snapshot (captured on the open edge via `watch`) powers `cancel()`'s revert.
- The eidos **Content** composes `PopoverContent → PickerShell → GradientBuilder body + Footer(Clear/Cancel/Close)`. The GradientBuilder edits a local `draft` ($state); its `onValueCommit` pushes settled values to `provider.setValue`; an `$effect` (`if (v !== draft) draft = v`) flows external resets (cancel/clear) back. Commit-only push + the ref guard = no loop.
- Trigger + ValueSwatch paint `--gp-current-gradient` (stamped inline by soma) — the chip shows the committed gradient.
- *(Anotación 2026-08-27 — SS16, clase D: ese canal se llama hoy `--_gradient-picker-current-gradient`. El nombre abreviado de arriba es el de su día y no se reescribe.)*
- Verified in-browser: one click opens → builder renders inside (2 stops) → footer `Borrar/Cancelar/Listo` → AddStop commits live (chip updates) → Cancel reverts chip to the open-edge value + closes. `aria-expanded`/`data-state` correct, no console errors. svelte-check clean, contracts.test unchanged (12 pre-existing fails, none gradient-*).
### What the eidos layer ships (the build composed, never re-implemented)

@ -28,7 +28,7 @@ the Trigger composes `PopoverTriggerProvider` for free.
| ------------- | ---------- | ---------------------------------------------------------------- |
| `Provider` | `<div>` | Owns `open` + commit/cancel/clear (via `pickerShellContext`). |
| `Trigger` | `<button>` | Opens the popover. `aria-haspopup="dialog"` + `aria-expanded`. |
| `ValueSwatch` | `<div>` | The gradient preview chip (paints `--gp-current-gradient`). |
| `ValueSwatch` | `<div>` | The gradient preview chip (paints `--_gradient-picker-current-gradient`). |
`Content` / footer (`Clear` / `Cancel` / `Close`) are the composed **Popover** +
shared **PickerShell** parts — re-exported under the eidos namespace.

@ -192,8 +192,14 @@ export class GradientPickerTriggerProvider {
});
}
/** Stamp the live gradient so eidos paints the swatch purely via CSS. */
readonly inlineStyle = $derived.by<string>(() => `--gp-current-gradient: ${this.provider.css}`);
/**
* Stamp the live gradient so eidos paints the swatch purely via CSS. A
* per-instance value channel in the component's own namespace (SS16): soma
* holds the pen, the name belongs to gradient-picker.
*/
readonly inlineStyle = $derived.by<string>(
() => `--_gradient-picker-current-gradient: ${this.provider.css}`
);
get props() {
const ariaLabel = this.opts.ariaLabel.current;
@ -227,7 +233,8 @@ export class GradientPickerValueSwatchProvider {
}
readonly backgroundStyle = $derived.by<string>(
() => `background: ${this.provider.css}; --gp-current-gradient: ${this.provider.css};`
() =>
`background: ${this.provider.css}; --_gradient-picker-current-gradient: ${this.provider.css};`
);
get props() {

@ -76,13 +76,34 @@ Native scrollbars are hidden via CSS. Custom scrollbars are positioned absolutel
## CSS Variables
| Variable | Part | Description |
| ----------------------------------- | ------------------------ | ----------------------------------------------------- |
| `--scroll-area-corner-width` | Provider | Corner width (scrollbar size when both axes overflow) |
| `--scroll-area-corner-height` | Provider | Corner height |
| `--scroll-area-scrollbar-size` | Provider (consumer sets) | Scrollbar track width/height (default 8px) |
| `--scroll-area-thumb-width` | Thumb | Computed thumb width |
| `--scroll-area-thumb-height` | Thumb | Computed thumb height |
Soma measures the overflow and writes the resulting geometry into **private
value channels**:
| Channel | Part | Meaning |
| ------------------------------ | -------- | ----------------------------------------------------- |
| `--_scroll-area-corner-width` | Provider | Corner width (scrollbar size when both axes overflow) |
| `--_scroll-area-corner-height` | Provider | Corner height |
| `--_scroll-area-thumb-width` | Thumb | Computed thumb width |
| `--_scroll-area-thumb-height` | Thumb | Computed thumb height |
**These are not consumer API.** The leading `_` is the house mark of a value
channel. These are internal — soma writes them inline per instance, so no theme
can win them (only an author `!important` rule overrides the inline write, and
that freezes the live geometry). The two `corner-*` channels are read back by
the `Corner` part, which sizes itself from what the Provider measured; the two
`thumb-*` channels have no reader today. They are listed to describe the
mechanism, not to invite a `var()`.
What a consumer sets is the public knob:
| Token | Meaning |
| ------------------------------ | ------------------------------------------ |
| `--scroll-area-scrollbar-size` | Scrollbar track width/height (default 8px) |
Otherwise style the **part** — `[data-scroll-area-scrollbar]`,
`[data-scroll-area-thumb]`, `[data-scroll-area-corner]` and their `data-state` /
`data-orientation` attributes — plus the rest of the public `--scroll-area-*`
knobs.
## Keyboard

@ -178,8 +178,8 @@ describe('ScrollAreaProvider', () => {
});
expect(result.provider.props.style).toMatchObject({
position: 'relative',
'--scroll-area-corner-width': 'var(--scroll-area-scrollbar-size, 8px)',
'--scroll-area-corner-height': 'var(--scroll-area-scrollbar-size, 8px)'
'--_scroll-area-corner-width': 'var(--scroll-area-scrollbar-size, 8px)',
'--_scroll-area-corner-height': 'var(--scroll-area-scrollbar-size, 8px)'
});
expect(result.viewport.props).toMatchObject({
id: 'scroll-area-viewport',
@ -341,8 +341,8 @@ describe('ScrollAreaProvider', () => {
top: '20px',
height: '20px',
width: '100%',
'--scroll-area-thumb-width': '100%',
'--scroll-area-thumb-height': '20px'
'--_scroll-area-thumb-width': '100%',
'--_scroll-area-thumb-height': '20px'
});
expect(result.corner.shouldShow).toBe(true);
expect(result.corner.props).toMatchObject({

@ -181,11 +181,11 @@ export class ScrollAreaProvider {
...this.runtimePart.props,
style: {
position: 'relative',
'--scroll-area-corner-width':
'--_scroll-area-corner-width':
this.hasOverflowX && this.hasOverflowY
? 'var(--scroll-area-scrollbar-size, 8px)'
: '0px',
'--scroll-area-corner-height':
'--_scroll-area-corner-height':
this.hasOverflowX && this.hasOverflowY
? 'var(--scroll-area-scrollbar-size, 8px)'
: '0px'
@ -642,8 +642,8 @@ export class ScrollAreaThumbProvider {
// `--scroll-area-thumb-radius` token. Setting it inline
// would `!important`-shadow the recipe and break the
// `radius` prop.
'--scroll-area-thumb-width': `${this.isVertical ? '100%' : `${this.thumbSize}px`}`,
'--scroll-area-thumb-height': `${this.isVertical ? `${this.thumbSize}px` : '100%'}`
'--_scroll-area-thumb-width': `${this.isVertical ? '100%' : `${this.thumbSize}px`}`,
'--_scroll-area-thumb-height': `${this.isVertical ? `${this.thumbSize}px` : '100%'}`
},
onpointerdown: this.onpointerdown,
onpointermove: this.onpointermove,
@ -684,8 +684,8 @@ export class ScrollAreaCornerProvider {
...this.runtimePart.props,
'data-state': this.shouldShow ? 'visible' : 'hidden',
style: {
width: 'var(--scroll-area-corner-width)',
height: 'var(--scroll-area-corner-height)'
width: 'var(--_scroll-area-corner-width)',
height: 'var(--_scroll-area-corner-height)'
}
} as const)
);

@ -120,12 +120,24 @@ Risk and threat toasts use `role="alert"` with `aria-live="assertive"` for immed
## CSS Variables
| Variable | Part | Description |
| --------------------------- | ---- | -------------------------------------------------- |
| `--toast-swipe-move-x` | Item | Horizontal swipe displacement in px |
| `--toast-swipe-move-y` | Item | Vertical swipe displacement in px |
| `--toast-swipe-end-x` | Item | Final horizontal position on dismiss (e.g. `100%`) |
| `--toast-swipe-end-y` | Item | Final vertical position on dismiss (e.g. `100%`) |
Soma tracks the swipe gesture and writes its live displacement into **private
value channels**:
| Channel | Part | Meaning |
| ----------------------- | ---- | -------------------------------------------------- |
| `--_toast-swipe-move-x` | Item | Horizontal swipe displacement in px |
| `--_toast-swipe-move-y` | Item | Vertical swipe displacement in px |
| `--_toast-swipe-end-x` | Item | Final horizontal position on dismiss (e.g. `100%`) |
| `--_toast-swipe-end-y` | Item | Final vertical position on dismiss (e.g. `100%`) |
**These are not consumer API.** The leading `_` is the house mark of a value
channel. These are internal — soma writes them inline per instance, so no theme
can win them (only an author `!important` rule overrides the inline write, and
that freezes the live geometry). No recipe reads them today — the eidos recipe
styles the swipe through the `data-swipe` states instead. They are listed to
describe the mechanism, not to invite a `var()`. Style the **part** —
`[data-toast-item]` with `data-swipe` / `data-state` — and the public
`--toast-*` knobs.
## Keyboard
@ -227,12 +239,15 @@ The hotkey is configurable via `createToaster({ hotkey: ['F8'] })`.
### Swipe animation CSS
Recipe-level code, not consumer code: `--_toast-swipe-move-x` is a private value
channel (see **CSS Variables** above), so this is what the eidos recipe owns.
```css
[data-toast-item] {
transition: transform 200ms ease;
}
[data-toast-item][data-swipe='move'] {
transform: translateX(var(--toast-swipe-move-x));
transform: translateX(var(--_toast-swipe-move-x));
transition: none;
}
[data-toast-item][data-swipe='cancel'] {

@ -486,13 +486,13 @@ export class ToastItemProvider {
...this.runtimePart.props,
tabindex: 0,
style: {
'--toast-swipe-move-x': `${this.swipeDeltaX}px`,
'--toast-swipe-move-y': `${this.swipeDeltaY}px`,
'--toast-swipe-end-x':
'--_toast-swipe-move-x': `${this.swipeDeltaX}px`,
'--_toast-swipe-move-y': `${this.swipeDeltaY}px`,
'--_toast-swipe-end-x':
this.swipeState === 'end'
? `${this.isHorizontalSwipe ? (this.swipeDirection === 'right' ? '100%' : '-100%') : '0'}`
: undefined,
'--toast-swipe-end-y':
'--_toast-swipe-end-y':
this.swipeState === 'end'
? `${!this.isHorizontalSwipe ? (this.swipeDirection === 'down' ? '100%' : '-100%') : '0'}`
: undefined,

@ -96,9 +96,15 @@ Branches can nest arbitrarily deep. Leaf items use `Item`, expandable folders us
## CSS Variables
| Variable | Part | Description |
| ------------------- | ------------ | ------------------------------------------------------------------------------------------------- |
| `--tree-depth` | Branch, Item | Nesting depth (0-based). Use for indentation: `padding-left: calc(var(--tree-depth) * 1rem)` |
Soma publishes the nesting depth as a **value channel in the component's own
namespace** (SS16, 2026-08-27): `--_tree-view-depth` on every Branch and Item.
It is internal — soma writes it inline per instance, so no theme can win it
(only an author `!important` rule overrides the inline write, and that flattens
every level onto one indent); the eidos recipe consumes it for the row's
`padding-inline-start` and for the indent guide. Style the **part**
(`[data-tree-view-branch-control]`, `[data-tree-view-item]`, `data-depth`) and
the public `--tree-view-*` knobs — `--tree-view-indent` is the themeable step —
instead.
## Keyboard
@ -161,7 +167,7 @@ Each Branch and Item receives a `depth` prop (0-based). This becomes:
- `aria-level` (1-based, as required by ARIA spec)
- `data-depth` attribute
- `--tree-depth` CSS variable for indentation
- `--_tree-view-depth` CSS variable for indentation
## Usage
@ -201,10 +207,14 @@ Each Branch and Item receives a `depth` prop (0-based). This becomes:
### Indentation via CSS variable
Headless consumers that bring their own CSS (no eidos recipe) read the channel
off the part. With the eidos recipe in play, move `--tree-view-indent` instead —
the recipe already builds this `calc`, and a second one would double the step.
```css
[data-tree-view-branch-control],
[data-tree-view-item] {
padding-left: calc(var(--tree-depth) * 1.5rem + 0.5rem);
padding-inline-start: calc(var(--_tree-view-depth, 0) * 1.5rem + 0.5rem);
}
```
@ -225,7 +235,7 @@ Each Branch and Item receives a `depth` prop (0-based). This becomes:
| Home/End | Yes | Yes | Yes | Yes |
| `*` expand all siblings | Yes | No | No | Yes |
| Typeahead | Yes | No | No | Yes |
| `--depth` CSS variable | Yes | data-indent | --tree-item-level | Yes (`--tree-depth`) |
| `--depth` CSS variable | Yes | data-indent | --tree-item-level | Yes (`--_tree-view-depth`) |
| `data-state` open/closed | Yes | Yes | No | Yes |
| `data-selected` | Yes | Yes | No | Yes |
| `data-depth` | No | Yes (data-indent) | No | Yes |

@ -418,7 +418,9 @@ export class TreeViewBranchProvider {
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-depth': this.opts.depth.current,
style: {
'--tree-depth': this.opts.depth.current
// Per-instance value channel in the component's own namespace
// (SS16): soma holds the pen, the name belongs to tree-view.
'--_tree-view-depth': this.opts.depth.current
}
} as const)
);
@ -648,7 +650,9 @@ export class TreeViewItemProvider {
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-depth': this.opts.depth.current,
style: {
'--tree-depth': this.opts.depth.current
// Per-instance value channel in the component's own namespace
// (SS16): soma holds the pen, the name belongs to tree-view.
'--_tree-view-depth': this.opts.depth.current
},
onclick: this.onclick
} as const)

@ -0,0 +1,648 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { renderGeneratedBaseEidosCss } from './eidos/lib/generated-css';
import { THEME_BASE_RECIPE_TOKENS } from './eidos/lib/recipes/base';
import type { RecipeTokenValue } from './eidos/lib/config-types';
/**
* VALUE CHANNELS — the guard on the side that WRITES (firma §16, 2026-08-27;
* its SCOPE written down the same day, see THE BORDER below).
*
* THE LAW. In the roots this file sweeps, a per-instance custom-property write
* lives in `--_{c}-*` and `base.ts` does not declare that name. If the name IS
* in the contract — public `--{c}-x` or private `--_{c}-x`, the same species
* either way — it is a TOKEN THAT LIES: an inline per-instance write beats
* every normal rule, so the theme entitled to that name moves nothing.
*
* THE BORDER, because a law whose guard does not reach where it points is
* prose. Two roots are OUT, and neither is empty:
*
* · `src/uix/eidos` — 11 writes of CONTRACTED names and 18 ownerless public
* forms live there TODAY (measured 2026-08-27 by pointing this same scanner
* at it). They are not one species but two, and that is why the root is not
* simply added here: `lib/render-css`, `lib/build-scheme`,
* `lib/build-space-scale` are the FOUNDATION emitting its own vocabulary,
* which is what a foundation is for; the ten in `components/*.svelte` are
* ergonomic props a wrapper offers its CONSUMER, which is a different
* question — one of them, `--dialog-overlay-opacity`, is the exact twin of
* the drawer veil this firma just fixed. Telling the two apart takes a
* signature. The expediente is `docs/next-features.md` §18.
* · `web/routes` — FROZEN (`05dcb4db7`) and rebuilt whole; its 204 writes are
* a tree that is leaving. Judging them would also need the clause this law
* does not have: a demo writing `--button-palette-solid` inline is a
* CONSUMER using a token for what it is, not a framework publishing a name.
*
* So the law's subject is THE FRAMEWORK'S WRITING SIDE, not «everyone». Seven
* of the nine roots swept write nothing at all today (`libs` 257 files, `svrs`
* 79, `sema` 100, `morfo` 181, `active-uix` 11, `blocks` 143, `packs` 37 — zero
* writes between them); they are swept so a first write cannot be born unseen,
* and they are NOT held to zero: a block that tomorrow stamps its own
* `--_{c}-*` geometry is legal, and reddening it would enforce a census instead
* of the law.
*
* WHY THIS FILE EXISTS AT ALL. The theming axis reads
* `src/uix/eidos/components` and nothing else (`scripts/theming-census.ts`,
* `const ROOT`) — the census, the debt ledger, R-5.1 and the closed-namespace
* law all live on the side that READS. Whoever WRITES the value lives in
* `src/uix/soma` and `src/arts`, and no instrument of the axis had ever
* enumerated it. This is `05dcb4db7` («el guard vive donde NACE el valor»)
* applied to theming: a guard aimed at the observed artefact measures the side
* that reads and reports the number as if it were the whole.
*
* WHY A VITEST FILE AND NOT A SCRIPT. `npm run gate` ends in `npm run test`,
* so the suite is what turns a doctrine into a law; a `scripts/*.ts` needs a
* new gate entry and an author who remembers to add it. And why at `src/uix/`
* root rather than under `eidos/`: its SUBJECT is the writing side and its
* JUDGE is the eidos contract, so it belongs to no single layer — the same
* reason `src/uix/contracts.test.ts` sits here.
*
* WHAT IT DOES NOT DO. It never reuses the census's contract reader, and it
* never opens a `.css` file: this guard has no opinion about CONSUMPTION.
* A name with zero readers and a name with ten are judged identically, by the
* shape of the name and nothing else — which is the whole point, since the
* fifteen names this firma retired had zero readers and were still API.
*
* And two things it does not CHECK, written here so nobody infers them from a
* green — each one a place where the sentence and the instrument do not meet:
*
* · «PER INSTANCE» is not statically decidable, so this file does not decide
* it. Its antecedent is WIDER than the doctrine's: any write of a
* contracted or ownerless name in these roots is red, per-instance or not.
* That is what the registry is FOR rather than a leak in it —
* `--scrollbar-width` is one write on the single `<body>`, so the doctrine
* does not reach it while this guard does, and the entry carries the
* difference in writing.
* · The `{c}` of `--_{c}-*` is NOT enforced — only the underscore is.
* `--_totally-invented-thing` passes here. The component segment is naming
* doctrine (THEMING §6) with its own enforcement, and the tree holds
* legitimate exceptions to the letter besides: a shared LAYER writes in the
* HOST's namespace (`--_{host}-floating-*`, §15) and some privates are
* layer-scoped rather than component-scoped
* (`--_viewport-placement-offset`). Closing it is a rename axis, not a line.
*/
/**
* The roots the law reaches — its scope, written down rather than implied by
* whatever this array happened to hold. `src/uix/eidos` and `web/routes` are
* OUT by declaration, not by omission: see THE BORDER above.
*
* The first two WRITE today (soma 1281 files, arts 448). The other seven wrote
* nothing on 2026-08-27 and are here so a first write cannot be born unseen —
* held to the same two reds as everybody else, never to zero.
*/
const LAW_ROOTS = [
'src/uix/soma',
'src/arts',
'src/uix/blocks',
'src/packs',
'src/libs',
'src/svrs',
'src/uix/sema',
'src/uix/morfo',
'src/uix/active-uix'
] as const;
const SOURCE_FILE = /\.(ts|js|svelte)$/;
/**
* Tests are EXCLUDED, with a reason: a spec asserting `'--_toast-swipe-move-x'`
* OBSERVES a writer, it is not one, and the writer it observes is already in
* scope. Nothing a test file writes reaches a user's DOM. (Measured cost of the
* choice over soma + arts, 2026-08-27: 2014 files with tests, 1729 without —
* the whole sweep is 2537 files across the nine roots; the only name that
* lives exclusively in a test is `--ty`, the spring fixture the sizing census
* had already classified as noise.)
*/
const TEST_FILE = /\.(test|spec)\.(ts|js)$/;
type WriteForm =
| 'object-key'
| 'computed-key'
| 'declaration'
| 'property-call'
| 'member-assign'
| 'style-directive';
interface Write {
readonly name: string;
readonly file: string;
readonly line: number;
readonly form: WriteForm;
}
// ── The registry of open adjudications ──────────────────────────────────────
//
// It KEEPS its registry, and the doctrine is the debt ledger's
// (`scripts/theming-census-debt.ts`), not the exception file's: it may only
// SHRINK, an entry whose name stops being an ownerless write is reported STALE
// and stays red until the line is deleted, and nothing regenerates it. An
// exception says «this is fine»; every entry here says the opposite — «this one
// is outside the law's antecedent, we measured why, and it is waiting for a
// signature».
//
// ⚠ THE OBJECTION IT HAS TO ANSWER, because it is the house's own and it is 24
// hours old: `PENDING_PRIVATE_RENAME` was DISMANTLED on 2026-08-26 over this
// same species of name («there is no third state»). The difference that keeps
// this registry alive is not permissiveness, it is FORM. That one held names
// awaiting a rename already decided — a queue, and a queue with no deadline is
// a cupboard. These hold names the law does not reach (`--scrollbar-width` is
// not per-instance) or cannot judge alone (the `palabras` track), which is the
// ledger's own case. What stops it becoming that cupboard is the shape of an
// ENTRY: four fields, and the fourth is the one a drawer can never fill.
//
// since — the date it entered. An undated registry cannot age.
// reason — why the law's antecedent does not close over it. MEASURED.
// destination — where it goes, named. Not «to be decided».
// heldBecause — why it did not move on the day it was written. This is the
// field that makes a stalled entry visible AS stalled: an
// entry that cannot say what blocks it has nothing blocking it,
// and belongs in the commit rather than in this list.
interface Adjudication {
readonly since: string;
readonly reason: string;
readonly destination: string;
readonly heldBecause: string;
}
const PALABRAS_SCHEME: Adjudication = {
since: '2026-08-27',
reason:
"The `palabras` WIP track, found by the `member-assign` form (`out['--x'] = v` on a " +
'style object) that the sizing census of §16 did not scan. Each name is READ by ' +
'`eidos/components/palabras/palabras.css` with a fallback and re-declared inline per ' +
'BLOCK on purpose — the engine comment states the intent: beat the doc-root value. ' +
'So it is the species by shape, and arguably the designed cascade of a document ' +
'editor by function; that is precisely what has to be adjudicated, not asserted.',
destination:
"The `palabras` axis, which is already in the author's tray. Registered rather than " +
'excluded as a WIP track (the exclusion `recipe-css-contract.test.ts` grants it on ' +
'the READ side) because the debt ledger settled this the other way: «THE WIP TRACKS ' +
'ARE IN … debt is real». An eighth sibling cannot slip in unsigned.',
heldBecause:
"Renaming them is not this axis's call to make: the seven are READ by `palabras.css` " +
'with a fallback, so the flip is writer + reader in one edit inside a track whose own ' +
'codemod is already adjudicated and waiting in that tray. Moving them here would ' +
'collide with it, and the collision would be silent — the same file, two hands.'
} as const;
const ADJUDICATION_PENDING: Readonly<Record<string, Adjudication>> = {
'--palabras-scheme-list-item-gap': PALABRAS_SCHEME,
'--palabras-scheme-list-marker-color': PALABRAS_SCHEME,
'--palabras-scheme-list-check-box': PALABRAS_SCHEME,
'--palabras-scheme-list-check-box-color': PALABRAS_SCHEME,
'--palabras-scheme-table-border-inner': PALABRAS_SCHEME,
'--palabras-scheme-columns-rule': PALABRAS_SCHEME,
'--palabras-scheme-columns-col-pad': PALABRAS_SCHEME,
'--scrollbar-width': {
since: '2026-08-27',
reason:
'NOT per-instance, so the law does not reach it: `arts/adom/body-scroll-lock` ' +
'writes it ONCE on `document.body` (N locks collapse into a single write against ' +
'the one body) and its value is a fact of the VIEWPORT — `innerWidth - ' +
'clientWidth` — not of any component instance. Zero readers in the repo; the ' +
'compensation the lock actually performs is the `padding-right` on the line above.',
destination:
'A SIGNATURE choosing one of two branches: DECLARE it as system vocabulary, or ' +
'WITHDRAW it. There is no third branch — renaming it to `--_adom-scrollbar-width` ' +
'would apply a rule whose condition is not met and hide a global measurement ' +
'behind a private name for no reader.',
heldBecause:
'Both branches cost exactly what §16 says must be signed, in opposite directions: ' +
'declaring it publishes API that nobody reads (the defect this axis chases), and ' +
'withdrawing it DELETES a name, which this repo does not do without an explicit ' +
'instruction. A pass with no signature can only take a third option, and the third ' +
'option is the wrong one — so the 2026-08-27 pass took none and said so here.'
}
};
// ── Reading the contract ────────────────────────────────────────────────────
const RECIPE_TOKENS = THEME_BASE_RECIPE_TOKENS as unknown as Readonly<
Record<string, Readonly<Record<string, RecipeTokenValue>>>
>;
/** `{c}` + `{key}` → the custom property the generator emits. */
function recipeTokenName(component: string, key: string): string {
if (key.startsWith('_')) return `--_${component}-${key.slice(1)}`;
return `--${component}-${key}`;
}
/** Every name `lib/recipes/base.ts` declares — the token that LIES if written. */
function contractNames(): ReadonlySet<string> {
const names = new Set<string>();
for (const [component, tokens] of Object.entries(RECIPE_TOKENS)) {
for (const key of Object.keys(tokens)) {
if (key === 'composition') continue;
names.add(recipeTokenName(component, key));
}
}
return names;
}
/**
* The legitimate SYSTEM vocabulary — DERIVED, never hand-written: every custom
* property the foundation emits, minus the recipe contract. That subtraction is
* what keeps the two reds apart, since a contract token comes out of the same
* renderer. An art writing `--color-*` / `--space-*` / `--motion-*` speaks the
* foundation's language; it is not inventing a surface.
*/
function systemNames(contract: ReadonlySet<string>): ReadonlySet<string> {
const names = new Set<string>();
for (const match of renderGeneratedBaseEidosCss().matchAll(/(--[A-Za-z_][\w-]*)\s*:/g)) {
if (!contract.has(match[1])) names.add(match[1]);
}
return names;
}
// ── The scanner ─────────────────────────────────────────────────────────────
//
// SIX write forms. The firma named four; the other two were found by sweeping
// the tree instead of the pattern, and one of them is the CANONICAL form:
// · `computed-key` — ``[`--_${component}-floating-anchor-width`]:``, the
// `component: string` shape §14 and §15 signed. A guard that could not see
// it would be blind to the very destination it enforces.
// · `member-assign` — `out['--x'] = v` onto a style object (`palabras`).
//
// TWO shapes it deliberately does NOT resolve, said out loud rather than
// implied by a green: (1) a name ASSEMBLED across statements — `const c =
// \`--_${this.component}-indicator-\`` then ``[`${c}x`]`` in
// `soma/layers/measured-indicator` — which no static reader can rebuild; (2) a
// name reaching `setProperty` through a variable, as in `body-scroll-lock`'s
// `MANAGED_PROPERTIES` loop, whose name is nonetheless caught at the literal
// `setProperty` site three lines down. A bare-literal sweep was tried for (2)
// and REVERTED: it red-flagged `--floating-anchor-{id}`, which is an
// `anchor-name` VALUE — a dashed-ident, identical in grammar to a property
// name and never written as one.
//
// A custom-property name, in the grammar the repo actually uses: `--` then a
// letter or `_`. `$` is admitted INSIDE the name because that is the character
// this scanner paints over a template interpolation — see `scanScript`. It is
// deliberately NOT `_`: painting `--${c}-x` with underscores would forge a
// private prefix and turn the guard's own red into a green.
const NAME = String.raw`--[A-Za-z_$][\w$-]*`;
/** Render a scanned name for a human: the interpolation runs come back as `{}`. */
const readable = (name: string) => name.replace(/\$+/g, '{}');
/** `--x: …` inside a style string — the DECLARATION form. */
const DECLARATION = new RegExp(String.raw`(?:^|[;{\s])(${NAME})\s*:`, 'g');
/** `'--x':` — the object-literal KEY form. */
const OBJECT_KEY = new RegExp(String.raw`(['"\`])(${NAME})\1\s*:`, 'g');
/** ``[`--_${c}-x`]:`` — the COMPUTED key, which is the canonical §14/§15 form. */
const COMPUTED_KEY = new RegExp(String.raw`\[\s*(['"\`])(${NAME})\1\s*\]\s*:`, 'g');
/** `setProperty('--x', …)` and the `$adom` helper `writeProperty(el, '--x', …)`. */
const PROPERTY_CALL = new RegExp(
String.raw`(?:set|write)Property\(\s*(?:[^,()]*,\s*)?(['"\`])(${NAME})\1`,
'g'
);
/** `out['--x'] = v` — the member ASSIGNMENT onto a style object. */
const MEMBER_ASSIGN = new RegExp(String.raw`\[\s*(['"\`])(${NAME})\1\s*\]\s*=[^=]`, 'g');
/** Svelte's `style:--x={…}` directive. */
const STYLE_DIRECTIVE = new RegExp(String.raw`style:(${NAME})`, 'g');
/** A `style` attribute in svelte markup, in its three spellings. */
const STYLE_ATTRIBUTE = /style=(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/g;
const SCRIPT_BLOCK = /<script[^>]*>([\s\S]*?)<\/script>/g;
const HTML_COMMENT = /<!--[\s\S]*?-->/g;
interface ScriptScan {
/** Comments blanked to spaces; every `${…}` rendered as `{}`. Same length. */
readonly normalized: string;
/** Content span of every string / template literal, in `normalized`. */
readonly strings: readonly { readonly start: number; readonly end: number }[];
/** Content span of every `${…}` interpolation, in the ORIGINAL source. */
readonly interpolations: readonly { readonly start: number; readonly end: number }[];
}
/**
* Walk a script and separate CODE from COMMENTS from STRING LITERALS.
*
* A regex alone cannot do this, and the difference is not academic: the census
* that sized this firma counted `--ty` (a JSDoc `@default` example in
* `arts/motion/drivers.ts`), `--state` (prose in a comment) and `---` (a
* markdown alignment marker in `palabras`) among its writes. A guard whose reds
* include prose teaches its reader to skim them.
*
* Interpolations are rendered as `{}` rather than dropped, so a name BUILT from
* a variable keeps its shape: ``--_${component}-floating-anchor-width`` reads
* as `--_{}-floating-anchor-width` and stays private, while a hypothetical
* ``--${component}-indicator-x`` reads as `--{}-indicator-x` and is red. That
* is the canonical form of §14/§15 — a guard blind to it would be blind to the
* destination it is enforcing.
*/
function scanScript(source: string): ScriptScan {
const out = source.split('');
const strings: { start: number; end: number }[] = [];
const interpolations: { start: number; end: number }[] = [];
let i = 0;
/** Blank a span to spaces, keeping newlines so line numbers survive. */
const blank = (from: number, to: number) => {
for (let k = from; k < to; k += 1) if (out[k] !== '\n') out[k] = ' ';
};
/** Paint a `${…}` span with `$`, keeping length AND newlines. */
const stamp = (from: number, to: number) => {
for (let k = from; k < to; k += 1) if (out[k] !== '\n') out[k] = '$';
};
/** Index just past the string literal that starts at `at`. */
const skipString = (at: number): number => {
const quote = source[at];
let k = at + 1;
while (k < source.length) {
const ch = source[k];
if (ch === '\\') {
k += 2;
continue;
}
if (ch === quote) return k + 1;
if (quote !== '`' && ch === '\n') return k;
if (quote === '`' && ch === '$' && source[k + 1] === '{') {
k = skipBraces(k + 1);
continue;
}
k += 1;
}
return k;
};
/** Index just past the `{…}` that starts at `at`, string-aware. */
const skipBraces = (at: number): number => {
let depth = 0;
let k = at;
while (k < source.length) {
const ch = source[k];
if (ch === "'" || ch === '"' || ch === '`') {
k = skipString(k);
continue;
}
if (ch === '{') depth += 1;
else if (ch === '}') {
depth -= 1;
if (depth === 0) return k + 1;
}
k += 1;
}
return k;
};
while (i < source.length) {
const c = source[i];
const next = source[i + 1];
if (c === '/' && next === '/') {
const end = source.indexOf('\n', i);
const stop = end === -1 ? source.length : end;
blank(i, stop);
i = stop;
continue;
}
if (c === '/' && next === '*') {
const end = source.indexOf('*/', i + 2);
const stop = end === -1 ? source.length : end + 2;
blank(i, stop);
i = stop;
continue;
}
if (c === "'" || c === '"' || c === '`') {
const end = skipString(i);
// `end` is past the closing quote unless the literal was unterminated.
const closed = source[end - 1] === c && end - 1 > i;
strings.push({ start: i + 1, end: closed ? end - 1 : end });
if (c === '`') {
// Stamp each interpolation and remember it for the recursive pass.
let k = i + 1;
while (k < end) {
if (source[k] === '\\') {
k += 2;
continue;
}
if (source[k] === '$' && source[k + 1] === '{') {
const close = skipBraces(k + 1);
interpolations.push({ start: k + 2, end: Math.max(k + 2, close - 1) });
stamp(k, close);
k = close;
continue;
}
k += 1;
}
}
i = end;
continue;
}
i += 1;
}
return { normalized: out.join(''), strings, interpolations };
}
const lineAt = (source: string, index: number) => source.slice(0, index).split('\n').length;
function collectFromScript(source: string, file: string, lineOffset = 0): Write[] {
const writes: Write[] = [];
const { normalized, strings, interpolations } = scanScript(source);
const at = (index: number) => lineOffset + lineAt(normalized, index);
const push = (name: string, index: number, form: WriteForm) =>
writes.push({ name, file, line: at(index), form });
for (const m of normalized.matchAll(OBJECT_KEY)) push(m[2], m.index, 'object-key');
for (const m of normalized.matchAll(COMPUTED_KEY)) push(m[2], m.index, 'computed-key');
for (const m of normalized.matchAll(PROPERTY_CALL)) push(m[2], m.index, 'property-call');
for (const m of normalized.matchAll(MEMBER_ASSIGN)) push(m[2], m.index, 'member-assign');
for (const { start, end } of strings) {
for (const m of normalized.slice(start, end).matchAll(DECLARATION)) {
push(m[1], start, 'declaration');
}
}
// The interpolations were painted over above, so their own contents are only
// visible to a second, independent pass over the same bytes.
for (const { start, end } of interpolations) {
writes.push(
...collectFromScript(source.slice(start, end), file, lineOffset + lineAt(source, start) - 1)
);
}
return writes;
}
function collectWrites(file: string): Write[] {
const source = readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
const path = file.replace(/\\/g, '/');
if (!path.endsWith('.svelte')) return collectFromScript(source, path);
const writes: Write[] = [];
const markup = source.replace(HTML_COMMENT, (m) => m.replace(/[^\n]/g, ' ')).split('');
for (const m of source.matchAll(SCRIPT_BLOCK)) {
writes.push(...collectFromScript(m[1], path, lineAt(source, m.index) - 1));
// A script body is not markup: blank it before the directive sweep.
for (let k = m.index; k < m.index + m[0].length; k += 1) {
if (markup[k] !== '\n') markup[k] = ' ';
}
}
const html = markup.join('');
for (const m of html.matchAll(STYLE_DIRECTIVE)) {
writes.push({ name: m[1], file: path, line: lineAt(html, m.index), form: 'style-directive' });
}
for (const m of html.matchAll(STYLE_ATTRIBUTE)) {
for (const d of (m[1] ?? m[2] ?? m[3] ?? '').matchAll(DECLARATION)) {
writes.push({ name: d[1], file: path, line: lineAt(html, m.index), form: 'declaration' });
}
}
return writes;
}
function sourceFiles(root: string): string[] {
const out: string[] = [];
const walk = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (SOURCE_FILE.test(entry.name) && !TEST_FILE.test(entry.name)) out.push(full);
}
};
if (statSync(root).isDirectory()) walk(root);
return out;
}
const sweep = (roots: readonly string[]) =>
roots.flatMap((root) => sourceFiles(root).flatMap(collectWrites));
// ── The classification ──────────────────────────────────────────────────────
type Verdict = 'private' | 'system' | 'contract-lie' | 'ownerless';
/**
* FOUR verdicts, two green and two red — and the ORDER of these lines is the
* law, not an implementation detail.
*
* THE CONTRACT IS ASKED FIRST, and it was not always: until the scope pass of
* 2026-08-27 the `--_` prefix answered first, so the 124 `_`-prefixed keys
* `base.ts` declares could be written inline for free. That was a hole, because
* a private recipe token is a THEME'S token too — `render-css.ts:1900` emits a
* `_x` key as `--_{c}-x` out of the same `EidosConfig.recipes` a theme
* supplies, so an inline per-instance write beats it exactly as it beats a
* public one. The species is «the theme is entitled to this name and the write
* outranks it», and the underscore has nothing to say about that.
*
* Measured before flipping, on both sides of the wall: of 64 private writes in
* soma + arts and 46 in eidos, ZERO name a key the contract declares. The flip
* closes the hole and reddens nothing — which is why it is a correction and not
* an axis.
*/
function classify(
name: string,
contract: ReadonlySet<string>,
system: ReadonlySet<string>
): Verdict {
if (contract.has(name)) return 'contract-lie';
if (name.startsWith('--_')) return 'private';
if (system.has(name)) return 'system';
return 'ownerless';
}
const show = (write: Write) =>
`${readable(write.name)} ← ${write.file}:${write.line} (${write.form})`;
describe('value channels — the guard on the side that WRITES (§16)', () => {
const contract = contractNames();
const system = systemNames(contract);
const writes = sweep(LAW_ROOTS);
const verdicts = (want: Verdict) =>
writes.filter((write) => classify(write.name, contract, system) === want);
it('sweeps a non-empty space', () => {
// A guard that inspects nothing passes. Assert the instrument found the
// tree before believing a word it says about it. Floors, not equalities:
// this file must not fail because a component was added.
expect(LAW_ROOTS.flatMap(sourceFiles).length).toBeGreaterThan(2400);
expect(writes.length).toBeGreaterThan(50);
expect(new Set(writes.map((write) => write.name)).size).toBeGreaterThan(40);
expect(contract.size).toBeGreaterThan(1000);
expect(system.size).toBeGreaterThan(100);
// Every form the scanner claims to see must actually be seen somewhere,
// or the claim is untested surface.
expect(new Set(writes.map((write) => write.form))).toEqual(
new Set<WriteForm>([
'object-key',
'computed-key',
'declaration',
'property-call',
'member-assign',
'style-directive'
])
);
});
it('never writes a name the contract declares — a token that LIES', () => {
expect(
verdicts('contract-lie').map(show),
'A token that LIES: the name is declared in `lib/recipes/base.ts`, so a theme is ' +
'entitled to move it — and an inline per-instance write beats every normal rule, ' +
'so the theme moves NOTHING. Write the value into `--_{c}-*` and let the recipe ' +
'COMPOSE the two, the way the drawer veil now does: `var(--drawer-overlay-opacity) ' +
'* var(--_drawer-overlay-progress, 1)`.'
).toEqual([]);
});
it('writes no public form without an owner', () => {
expect(
verdicts('ownerless')
.filter((write) => !(write.name in ADJUDICATION_PENDING))
.map(show),
'A public FORM with no owner: a `--{c}-x` that no contract declares and no theme ' +
'can reach, published as API by its spelling alone. Rename it to `--_{c}-*` — ' +
'writer and every reader in ONE edit — or declare it in the contract if it ' +
'really is theming surface.'
).toEqual([]);
});
it('keeps the adjudication registry SHRINKING — no stale entry', () => {
const written = new Set(writes.map((write) => write.name));
expect(
Object.keys(ADJUDICATION_PENDING).filter(
(name) => !written.has(name) || classify(name, contract, system) !== 'ownerless'
),
'STALE registry entry: this name is no longer an ownerless write — it was renamed, ' +
'contracted, adopted by the system, or deleted. Delete the line in the same ' +
'commit that earned it; the registry only ever shrinks.'
).toEqual([]);
});
it('keeps every registry entry DATED, reasoned, aimed and held', () => {
// The four fields are what separate a registry from a cupboard, so they are
// asserted rather than trusted to the type: a required field satisfied by
// `''` is a field nobody filled. `heldBecause` is the load-bearing one —
// an entry that cannot say what blocks it has nothing blocking it.
expect(
Object.entries(ADJUDICATION_PENDING)
.filter(([, entry]) =>
[entry.since, entry.reason, entry.destination, entry.heldBecause].some(
(field) => field.trim().length < 4
)
)
.map(([name]) => name),
'A registry entry with an empty field. Every one carries WHEN it entered, WHY the ' +
"law's antecedent misses it, WHERE it is going, and WHAT stopped it moving that " +
'day. Without the fourth, a name parks here for free.'
).toEqual([]);
});
it('walks every root it claims to sweep', () => {
// A guard that inspects nothing passes, and a root that stops resolving is
// exactly that: seven of the nine write nothing today, so no other
// assertion in this file would notice one of them going empty. This is the
// only check that fails when the SWEEP shrinks rather than the tree.
expect(
LAW_ROOTS.filter((root) => sourceFiles(root).length === 0),
'A swept root resolved to zero source files. Either it moved and this list is ' +
'stale, or the walk broke — both make every green above a green over a hole.'
).toEqual([]);
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.