fix(eidos): E2+E3 — el modo de sistema pasa por ActiveDom y un re-derive fallido ya no desviste la pagina

E2. `createSystemColorSchemeSource` usaba `globalThis.matchMedia` +
`addEventListener` crudos: la regla de la casa (listeners de window/document
pasan por ActiveDom) y la ventana EQUIVOCADA en iframe/popup — globalThis es la
global, no la del dom. Ahora, con dom inyectado, el media query sale de
`dom.getWindow().matchMedia` y la suscripcion va por `dom.listen` (ciclo de
vida gestionado); sin dom, el camino crudo queda de fallback (serializacion
CSS / SSR). Descartada la opcion preferida de la ficha (tracker en adom,
espejo de prefers-reduced-motion) por la regla de ≥2 consumidores: leidos los
6 boots reales de web/, TODOS pasan su propio modeSource — el camino de
sistema tiene hoy cero consumidores vivos; si algun dia gana un segundo, se
promociona, y queda dicho en el comentario.

E3. `#renderSchemeCss` atrapaba el error del re-derive y devolvia '' — y
`apply()` lee '' como «sin esquema» y BORRA el <style> anterior: un cambio de
modo con semilla que no deriva no solo fallaba sin log, desvestia la pagina
del bloque que ya estaba bien puesto. Ahora `#lastSchemeCss` conserva el
ultimo bloque bueno, el catch avisa por `#uix?.logger.warn('eidos.scheme',…)`
(la superficie que ya usa sema; eidos no tiene logger propio), y retirar el
spec sigue limpiando. `applyColorScheme` no cambia: construye EAGER y una
semilla invalida sigue reventando en la cara del llamador — el silencio era
solo del re-derive.

Dos hallazgos del proceso, anotados: el validador de config es FAIL-CLOSED y
rechaza escalas donantes rotas en la puerta (por eso el rojo de E3 no puede
fabricarse via config: la inyeccion va sobre `buildScheme` mismo, passthrough
real hasta que el flag del test lo revienta — el sitio exacto del throw que la
ficha nombraba); y el stub de matchMedia del test de E2 debe dar un mql POR
QUERY, porque el tracker de reduced-motion del propio dom consulta la misma
ventana.

Ambos tests vistos MORDER el codigo viejo (stash de la implementacion, no del
test): E2 porque el query de color iba a la ventana global (jsdom: sin
matchMedia), E3 porque el bloque desaparecia.

Verificado: eidos 28/28 · sema+morfo+eidos+contracts 943 ✓ / 6 ajenos · check
69 = base aislada, diff VACIO · prettier: active-eidos.svelte.ts estaba limpio
en HEAD y queda limpio; el test ya fallaba en HEAD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 58695cd08a
commit 846d201693

@ -253,8 +253,19 @@ por los dos caminos.
`…Required…` CON el guard S-34 actualizado en el mismo commit, los 2
`as never` de `compile.ts` retirados (vestigio pre-`DepSink`), y
`_resetCompileCache` resultó YA borrado — fuera de la cola ·
`E2` (`globalThis.matchMedia` crudo en eidos) · `E3` (catch mudo en
`#renderSchemeCss`).
~~`E2`~~ · ~~`E3`~~ **EJECUTADOS 2026-08-13** — E2: el mode-source de sistema
consulta `dom.getWindow().matchMedia` y se suscribe por `dom.listen`
(ventana CORRECTA en iframe/popup; fallback crudo sin dom); descartado el
tracker en adom por la regla de ≥2 consumidores (los 6 boots reales de web/
pasan todos su `modeSource` — el camino de sistema tiene hoy CERO
consumidores vivos). E3: `#lastSchemeCss` — un re-derive que falla conserva
el ultimo bloque bueno y avisa por `#uix?.logger` en vez de DESVESTIR la
pagina (`apply()` leia '' como «sin esquema» y borraba el `<style>`);
`applyColorScheme` sigue lanzando eager en la cara del llamador. Hallazgo
del rojo: el validador de config es FAIL-CLOSED y rechaza escalas rotas en
la puerta — la inyeccion del test va sobre `buildScheme` mismo, passthrough
real hasta que el flag lo revienta. Ambos tests vistos morder el codigo
viejo via stash.
5. **§3.6 blocks** — `A-47` (anillo de foco 1,43:1 sobre panel sólido) ·
`A-69` (`duration` de count-up no significa segundos).
6. **§3.3 eje B** — 4 continuos sin throttle (gradient-builder, color-picker

@ -1075,20 +1075,40 @@ export class ActiveEidos {
}
}
/** Last successfully rendered scheme block — the E3 keep on a failed re-derive. */
#lastSchemeCss = '';
#renderSchemeCss(): string {
if (!this.#schemeSpec) return '';
if (!this.#schemeSpec) {
this.#lastSchemeCss = '';
return '';
}
let result: BuildSchemeResult;
try {
result = this.#buildSchemeResult();
} catch {
return '';
} catch (error) {
// A re-derive that fails (a mode / theme change whose donor scales
// the seed cannot build against) must not STRIP the page of the
// block that was already good: `apply()` reads '' as "no scheme"
// and deletes the <style>. Keep the last good CSS and say why — a
// bad path does not un-invent in silence (E3, the S-09 doctrine).
// `applyColorScheme` itself still throws at the caller: it builds
// eagerly before `apply()` ever runs.
this.#uix?.logger.warn(
'eidos.scheme',
'color-scheme re-derive failed; keeping the previous block',
{ context: { error } }
);
return this.#lastSchemeCss;
}
const selector = this.#schemeSpec.options.selector ?? DEFAULT_SCHEME_SELECTOR;
// Dual stack per opaque step: hex fallback + oklch() wide-gamut override.
const body = schemeDeclarations(result)
.map((line) => `\t${line}`)
.join('\n');
return body ? `${selector} {\n${body}\n}` : '';
const css = body ? `${selector} {\n${body}\n}` : '';
this.#lastSchemeCss = css;
return css;
}
#renderTypeScaleCss(): string {
@ -1305,7 +1325,9 @@ function resolvePreferences(options: ActiveEidosOptions): ActiveEidosPreferenceS
theme: options.theme,
modeSource:
options.modeSource ??
(options.mode ? createStaticValueSource(options.mode) : createSystemColorSchemeSource()),
(options.mode
? createStaticValueSource(options.mode)
: createSystemColorSchemeSource(options.dom ?? options.uix?.dom)),
densitySource:
options.densitySource ?? createStaticValueSource(options.density ?? DEFAULT_DENSITY),
scalingSource:
@ -1345,14 +1367,20 @@ function createStaticValueSource<T>(value: T): ActiveEidosValueSource<T> {
}
function createSystemColorSchemeSource(
dom?: ActiveDom,
fallback: ThemeEffective = DEFAULT_MODE
): ActiveEidosValueSource<ThemeEffective> {
const media = getColorSchemeMedia();
const media = getColorSchemeMedia(dom);
return {
get: () => (media ? (media.matches ? 'dark' : 'light') : fallback),
onChange(handler) {
if (!media) return () => {};
const listener = () => handler(media.matches ? 'dark' : 'light');
// Managed subscription through the injected dom — the house rule
// (window/document listeners go through ActiveDom), and the same
// lifecycle every other eidos listener already has (E2).
if (dom) return dom.listen(media, 'change', listener);
// Domless boot (bare CSS serialization / SSR): raw fallback.
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
@ -1363,7 +1391,15 @@ function createSystemColorSchemeSource(
};
}
function getColorSchemeMedia(): MediaQueryList | undefined {
function getColorSchemeMedia(dom?: ActiveDom): MediaQueryList | undefined {
if (dom) {
// The dom's window, not the global one — an eidos booted for an
// iframe / popup must watch ITS window's color scheme (E2).
const win = dom.getWindow();
return typeof win.matchMedia === 'function'
? win.matchMedia('(prefers-color-scheme: dark)')
: undefined;
}
if (typeof globalThis.matchMedia !== 'function') return undefined;
return globalThis.matchMedia('(prefers-color-scheme: dark)');
}

@ -1,5 +1,19 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
// E3 failure injection — `buildScheme` is passthrough-real until a test flips
// the flag, then it throws exactly where a bad donor-scale re-derive would.
const schemeBuildFailure = vi.hoisted(() => ({ fail: false }));
vi.mock('./lib/build-scheme', async (importOriginal) => {
const real = await importOriginal<typeof import('./lib/build-scheme')>();
return {
...real,
buildScheme: (...args: Parameters<typeof real.buildScheme>) => {
if (schemeBuildFailure.fail) throw new Error('donor scales exploded');
return real.buildScheme(...args);
}
};
});
import { createActiveDom } from '$adom';
import { ActiveUixDomDisabledError } from '$active-uix/errors';
import type { Density } from '$libs/density';
@ -529,3 +543,85 @@ describe('ActiveEidos forced-colors (a11y)', () => {
expect(css).toContain(':root:root');
});
});
describe('ActiveEidos system mode source goes through ActiveDom (E2)', () => {
it('resolves the media query from dom.getWindow() and subscribes via dom.listen', () => {
// jsdom has no `globalThis.matchMedia`, which is exactly the E2 defect's
// blind spot: the raw path reads the GLOBAL window, so an eidos booted
// for an iframe/popup (dom.targetWindow) would watch the wrong window —
// and in this test, none. Routing through the injected dom makes the
// stub window's matchMedia the one that is read and listened to.
// One mql PER query — the dom's reduced-motion tracker also consults
// matchMedia on this window, and a shared stub would count ITS listener.
const mqls = new Map<string, { matches: boolean; listeners: Array<() => void> }>();
const stubWin = {
innerWidth: 1024,
innerHeight: 768,
devicePixelRatio: 1,
document,
matchMedia: (query: string) => {
const entry = mqls.get(query) ?? { matches: query.includes('dark'), listeners: [] };
mqls.set(query, entry);
return {
matches: entry.matches,
addEventListener: (_: string, fn: () => void) => void entry.listeners.push(fn),
removeEventListener: () => {}
};
},
addEventListener: () => {},
removeEventListener: () => {}
} as unknown as Window;
const dom = createActiveDom({ targetWindow: stubWin });
const listenSpy = vi.spyOn(dom, 'listen');
// No `mode`, no `modeSource`, no `preferences` — the boot falls to the
// system color-scheme source, which must consult the injected dom.
createActiveEidos({ dom, styleHost: document.head, styleId: 'e2-probe' });
const scheme = mqls.get('(prefers-color-scheme: dark)');
expect(scheme).toBeDefined();
expect(scheme!.listeners).toHaveLength(1);
expect(listenSpy).toHaveBeenCalledWith(
expect.objectContaining({ matches: true }),
'change',
expect.any(Function)
);
});
});
describe('ActiveEidos scheme re-derive failure keeps the last good block (E3)', () => {
it('keeps the previous scheme CSS and does not strip the page', () => {
// The silent path the audit named: applyColorScheme builds EAGERLY at
// call time (an invalid seed throws in the caller's face — correct),
// but the RE-derive inside apply() on a preference change used to
// catch, return '' and thereby DELETE the previously good <style>
// block. Injecting the failure through config is IMPOSSIBLE — the
// config validator is fail-closed and rejects broken donor scales at
// the door — so the failure is injected at the exact throw site the
// audit named: `buildScheme` blowing up on the re-derive only.
const { preferences, setMode } = preferencesHarness();
const dom = createActiveDom();
const eidos = createActiveEidos({
preferences,
dom,
styleHost: document.head,
styleId: 'e3-probe'
});
eidos.applyColorScheme('#3e63dd');
const good = document.getElementById('e3-probe-scheme')?.textContent;
expect(good).toBeTruthy();
schemeBuildFailure.fail = true;
try {
setMode('dark');
} finally {
schemeBuildFailure.fail = false;
}
const after = document.getElementById('e3-probe-scheme')?.textContent;
expect(after).toBe(good);
eidos.dispose();
});
});

Loading…
Cancel
Save

Powered by TurnKey Linux.