test(uix): audit Sticky native behavior and document deferred gaps

astra
dev 1 week ago
parent f4a94397ab
commit 6faf00b375

@ -200,16 +200,29 @@ diferidas. Morfo/Soma/wrapper/tipos/capa CSS/API intactos. Guard PASS
docs 971/arts 25 limpios, `check:gate` 89 históricos/cero nuevos
src/scripts, apps/base check/build y smoke foundation 1/1 + boot 27/27.
Después, [Sticky](./audit-uix-2026-09-19/sticky.md) reviewed/censo 151:
14 canarios positivos, una sonda `AssertionError` Q34 y cero pendientes.
Huella positiva original/final exacta, 15 PNG originales inspeccionados/
SHA (bottom, viewport, Q34 y uno a 350px). Q34 volvió a reproducirse
con sección corta; otra sonda `<Sticky bind:ref>` devolvió `null` y
extiende L-152. Morfo/Soma/wrapper/tipos/CSS/receta/API intactos.
Guard PASS 1/cero errores/avisos. La primera gate completa tuvo una
falla de Section Header entre 7112 pruebas; Section pasó dirigida
17/17 sin cambios. La segunda gate completa salió verde: 590/590
archivos, 7112/7112 pruebas, docs 971/arts 25, `check:gate` 89
históricos/cero nuevos src/scripts, apps/base check/build y smoke
foundation 1/1 + boot 27/27.
Esta continuación documenta las fichas de Heading,
Text, Display, Link, Prose, Label, Box, Stack, Group, Container, Section, AutoGrid, Float y Affix, plan/registro/handoffs, README de Text,
Text, Display, Link, Prose, Label, Box, Stack, Group, Container, Section, AutoGrid, Float, Affix y Sticky, plan/registro/handoffs, README de Text,
Display, Prose y Group, comentario de tipo Prose y tests/helpers nuevos.
`.claude/settings.local.json` y `.vitest-attachments/` son del usuario
y siguen excluidos.
## Continuación segura
Sticky/Q34 es la siguiente ficha pendiente; su
[ficha previa](./audit-uix-2026-09-19/sticky.md) documenta el estado `data-stuck` al terminar una sección corta. Releer antes las cuatro guías
Wrap es la siguiente ficha de layout pendiente; su
[ficha previa](./audit-uix-2026-09-19/wrap.md) documenta la composición con Flex/Box. Releer antes las cuatro guías
obligatorias de `AGENTS.md`. Mantener el alcance neutral autorizado:
baseline nativa, huella exacta, capturas originales inspeccionadas y
registro separado de defectos. No editar manualmente el árbol congelado

@ -11,7 +11,7 @@ status: reviewed
Workspace `G:\dev\svelte\vicen`, rama `astra`, remoto `gita/astra`.
El [handoff del 30](./HANDOFF-uix-quality-2026-09-30.md) conserva el
contexto de Highlight y del commit previo `5045216d6`. Este handoff
recoge el tramo tipográfico y la continuación hasta Affix. Al retomar,
recoge el tramo tipográfico y la continuación hasta Sticky. Al retomar,
comprobar `HEAD`, `gita/astra` y el estado local antes de seguir; no
depender de un hash escrito dentro de este documento. La fase 6 sigue
abierta; fases 0–5 no cerradas y fase 7
@ -35,31 +35,37 @@ pendiente.
| [AutoGrid](./audit-uix-2026-09-19/auto-grid.md) | reviewed/148 | 18 positivos, 0 negativos/pendientes | 16 PNG/SHA, 1 a 350px | Ninguno propio nuevo | 587/7058 |
| [Float](./audit-uix-2026-09-19/float.md) | reviewed/149 | 16 positivos, 0 negativos/pendientes | 15 PNG/SHA, 2 a 350px | Ninguno propio nuevo | 588/7074 |
| [Affix](./audit-uix-2026-09-19/affix.md) | reviewed/150 | 24 positivos, 0 negativos/pendientes | 23 PNG/SHA, 1 a 350px | Ninguno propio nuevo | 589/7098 |
| [Sticky](./audit-uix-2026-09-19/sticky.md) | reviewed/151 | 14 positivos, 1 `AssertionError`, 0 pendientes | 15 PNG/SHA, 1 a 350px | Q34 y L-152 extendidos | 590/7112 |
Los positivos promovidos conservaron nombres/estados exactos frente a
cada baseline. Los tests y dos helpers por componente viven fuera de
los wrappers; cero bolsas, conexiones o líneas de lógica extraídas.
Morfo, CSS, recetas y APIs ejecutables quedaron intactos. El wrapper
de Box sólo corrigió un comentario obsoleto.
Stack, Group, Container, Section, AutoGrid, Float y Affix dejaron intactos wrapper, tipos, CSS, receta y API.
Stack, Group, Container, Section, AutoGrid, Float, Affix y Sticky dejaron intactos wrapper, tipos, CSS, receta y API.
README de Text/Display sólo corrigió comparativas; README y comentario
de tipo de Prose aclaran que `lg` es fluido entre 18 y 20px con scaling
predeterminado (19,55px medidos a 1100px). Group actualizó la referencia
de Chakra HStack a Chakra Group en README y ficha. Los defectos Q22–Q33
seguían en el registro y se extendieron con sondas, sin reparación.
La última gate completa, de Affix, salió con exit 0: 589 archivos/7098
La gate de Affix salió con exit 0: 589 archivos/7098
tests, docs 971/arts 25 limpios, `check:gate` 89 errores históricos
dentro del ledger y cero nuevos src/scripts, apps/base check/build y
smoke foundation 1/1 + boot 27/27. Tras el cierre documental, repetir
`npm run docs:check` y `git diff --check`; el pre-push ejecuta su gate
completa. Ningún canario acredita AT, touch, tema oscuro, contraste ni
paridad externa.
smoke foundation 1/1 + boot 27/27. La primera gate de Sticky llegó a
590 archivos/7112 pruebas y falló sólo Section Header; ese archivo
pasó dirigido 17/17 sin cambios. La segunda gate completa salió con
exit 0: 590/590 archivos y 7112/7112 pruebas, docs 971/arts 25,
`check:gate` 89 históricos/cero nuevos src/scripts, apps/base
check/build y smoke foundation 1/1 + boot 27/27. Tras el cierre
documental, repetir `npm run docs:check` y `git diff --check`; el
pre-push ejecuta su gate completa. Ningún canario acredita AT, touch,
tema oscuro, contraste ni paridad externa.
## Continuación segura
[Sticky](./audit-uix-2026-09-19/sticky.md) es el siguiente candidato;
la ficha previa documenta Q34. Antes de
[Wrap](./audit-uix-2026-09-19/wrap.md) es el siguiente candidato de layout.
Sticky amplió Q34 y L-152 en sondas Chromium. Antes de
crear o modificar el componente, releer las cuatro guías obligatorias
de `AGENTS.md`. Mantener auditoría neutral: preflight, baseline nativa,
huella exacta, capturas originales inspeccionadas y hallazgos diferidos

@ -3874,3 +3874,26 @@ propia exit 0: 589 archivos/7098 pruebas, docs 971/arts 25 limpios,
check/build y smoke foundation 1/1 + boot 27/27. Fase 6 abierta,
fases 0–5 sin cerrar y fase 7 pendiente. Sticky/Q34 es el siguiente
candidato; no se inició su baseline en este lote.
### Sticky — cierre neutral global (2026-09-30)
Sticky se auditó con baseline nativa propia: 14 canarios positivos,
una sonda `AssertionError` Q34, cero pendientes y 15 nombres únicos.
Los positivos promovidos conservaron nombres y estados exactos; dos
helpers y los tests viven fuera del wrapper. Quince PNG originales
inspeccionados y archivados/SHA, incluidos bottom, viewport, 350 px y
Q34. La sonda volvió a medir `data-stuck` activo después de terminar
la sección corta. Otra sonda de `bind:ref` devolvió `null` en Eidos y
extiende L-152, ya registrado. Ambas reparaciones quedan diferidas.
Morfo, Soma, wrapper, tipos, CSS, receta, barrel, README y API intactos;
cero extracción de lógica.
[Ficha](./audit-uix-2026-09-19/sticky.md) reviewed/censo 151.
`component:audit --only sticky` PASS 1/cero errores/avisos. La primera
gate ejecutó 590 archivos/7112 pruebas y falló sólo Section Header;
su ejecución dirigida pasó 17/17 sin cambios. La segunda gate completa
salió con exit 0: 590 archivos/7112 pruebas, docs 971/arts 25 limpios,
`check:gate` 89 históricos/cero nuevos src/scripts, apps/base
check/build y smoke foundation 1/1 + boot 27/27. Fase 6 abierta,
fases 0–5 sin cerrar y fase 7 pendiente. Wrap es el siguiente candidato
de layout.

@ -2803,3 +2803,22 @@ frente a tinta heredada `rgb(180, 190, 200)`. Diferir corrección del
selector; no inferir contraste o AT. `component:audit --only prose` PASS
1/cero errores/avisos. Gate: docs 970/arts 25 limpios, check:gate 89
históricos/cero nuevos src/scripts, smoke foundation 1/1 + boot 27/27.
## Sticky — baseline nativa neutral
14 canarios positivos, una sonda `AssertionError` Q34, cero pendientes
y 15 nombres únicos. Los positivos promovidos conservan la huella
nombre/estado exacta; dos helpers y tests viven fuera del wrapper.
Quince PNG originales inspeccionados/SHA, incluido uno a 350 px y uno
tras salir de la sección. [Ficha](./sticky.md) reviewed/censo 151.
Q34 se extiende con scroll-host de 200 px, sección de 280 px y offset
12 px: al desplazar 400 px, sección y box quedan por encima del host,
pero `data-stuck` persiste. Una sonda adicional de `bind:ref` en el
wrapper Eidos devolvió `null` mientras el box DOM existía; se añade a
L-152, sin ID nuevo. Ambas sondas negativas siguen sólo en la evidencia
temporal. `component:audit --only sticky` PASS 1/cero errores/avisos.
Morfo, Soma, wrapper, tipos, CSS y API intactos; cero extracción y
ninguna reparación funcional. La primera gate tuvo un fallo aislado
de Section Header; su ejecución dirigida pasó 17/17 y la segunda gate
completa salió verde: 590 archivos/7112 pruebas, exit 0.

@ -3,7 +3,7 @@ title: UIX — Sticky, estado de posición pegada
type: notes
audience: human + agent
authority: process — auditoría de componente, no contrato nuevo
status: current
status: reviewed
---
# Sticky
@ -47,3 +47,37 @@ los dos archivos de tests existentes de Sticky pasaron 5/5 después.
`component:audit --only sticky`: PASS (1/1); `docs:check`: 0 errores y
0 avisos. Gate integral: 482/482 archivos, 5511/5511 tests y build/smoke
de `apps/base` (27/27). `web/` intacto.
## Continuación neutral — 2026-09-30
Se releyeron las cuatro guías obligatorias y se contrastó la matriz con
Ant Design Affix, Mantine Affix, Chrome sticky headers y MDN. La baseline
nativa propia dio 14 canarios positivos, una sonda `AssertionError` de
Q34, cero pendientes y 15 nombres únicos. Los positivos promovidos
conservaron exactamente nombres y estados; dos helpers y los tests
viven fuera del wrapper. Morfo, Soma, wrapper, tipos, receta, barrel,
README y API quedaron intactos; cero lógica extraída.
Los canarios cubren el único box y su centinela, flujo normal, fijación
top/bottom, offsets, retorno al origen, scroll-host explícito, viewport,
`disabled`, cambios reactivos, atributos globales, estilo y ancho de
350 px. Q34 se reprodujo de nuevo con sección de 280 px y scroll de
400 px: sección y box ya estaban fuera del host, pero `data-stuck`
seguía presente. La sonda negativa queda sólo en evidencia temporal.
Una segunda sonda temporal montó `<Sticky bind:ref>` en Eidos: la ref
quedó `null` frente al box real. Extiende L-152, ya registrado para
wrappers que reenvían bindables por spread; no se abrió otro ID ni se
modificó el reenvío durante la auditoría neutral.
Se inspeccionaron y archivaron con SHA-256 los 15 PNG originales,
incluidos bottom, viewport, 350 px y Q34. Las capturas no certifican AT,
touch, tema oscuro, contraste, otros motores ni paridad externa.
`component:audit --only sticky`: PASS 1/cero errores/avisos. La primera
gate alcanzó 590 archivos/7112 pruebas y falló sólo un test de Section
Header (1/7112); Section pasó dirigido 17/17 sin cambios. La segunda
gate completa salió con exit 0: 590 archivos/7112 pruebas, docs
971/arts 25 limpios, `check:gate` 89 históricos/cero nuevos en
src/scripts, apps/base check/build y smoke foundation 1/1 + boot
27/27. Sticky queda reviewed/censo 151; fase 6 abierta.

@ -0,0 +1,195 @@
import { expect, it, vi } from 'vitest';
import { page } from 'vitest/browser';
import { visit } from '../../../test/sticky/sticky-audit-view';
const near = (actual: number, expected: number) =>
expect(Math.abs(actual - expected)).toBeLessThan(2);
const waitStuck = (node: HTMLElement, stuck: boolean) =>
vi.waitFor(() => expect(node.hasAttribute('data-stuck')).toBe(stuck), { timeout: 2000 });
it('native default Sticky has one box and an aria-hidden preceding sentinel', () =>
visit(async (view) => {
const root = view.node();
const sentinel = view.sentinel();
expect(root.tagName).toBe('DIV');
expect(root.hasAttribute('data-sticky')).toBe(true);
expect(root.getAttribute('data-edge')).toBe('top');
expect(root.hasAttribute('data-stuck')).toBe(false);
expect(sentinel.getAttribute('aria-hidden')).toBe('true');
expect(sentinel.getAttribute('data-edge')).toBe('top');
expect(sentinel.nextElementSibling).toBe(root);
expect(getComputedStyle(root).position).toBe('sticky');
await view.capture('default');
}));
it('native sentinel keeps the top box in normal flow before pinning', () =>
visit(async (view) => {
near(view.node().getBoundingClientRect().top, view.section().getBoundingClientRect().top);
near(view.sentinel().getBoundingClientRect().top, view.node().getBoundingClientRect().top);
await view.capture('normal-flow');
}));
it('native top edge pins at the offset and marks stuck after scroll', () =>
visit(
async (view) => {
await view.scrollTo(100);
await waitStuck(view.node(), true);
near(
view.node().getBoundingClientRect().top,
view.host().getBoundingClientRect().top + 2 + 12
);
expect(getComputedStyle(view.node()).top).toBe('12px');
await view.capture('top-stuck');
},
{ offset: 12 }
));
it('native top edge clears the marker when scrolled back', () =>
visit(
async (view) => {
await view.scrollTo(100);
await waitStuck(view.node(), true);
await view.scrollTo(0);
await waitStuck(view.node(), false);
near(view.node().getBoundingClientRect().top, view.section().getBoundingClientRect().top);
await view.capture('top-return');
},
{ offset: 12 }
));
it('native larger top offset changes both CSS inset and pin line', () =>
visit(
async (view) => {
await view.scrollTo(100);
await waitStuck(view.node(), true);
near(
view.node().getBoundingClientRect().top,
view.host().getBoundingClientRect().top + 2 + 24
);
expect(view.node().style.getPropertyValue('--_sticky-offset')).toBe('24px');
await view.capture('top-offset-24');
},
{ offset: 24 }
));
it('native bottom edge puts the sentinel after the box', () =>
visit(
async (view) => {
const root = view.node();
const sentinel = view.sentinel();
expect(root.getAttribute('data-edge')).toBe('bottom');
expect(sentinel.getAttribute('data-edge')).toBe('bottom');
expect(root.nextElementSibling).toBe(sentinel);
expect(getComputedStyle(root).bottom).toBe('12px');
await view.capture('bottom-structure');
},
{ edge: 'bottom', offset: 12, innerBeforeHeight: 500 }
));
it('native bottom edge pins above the host bottom with its offset', () =>
visit(
async (view) => {
const root = view.node();
near(
root.getBoundingClientRect().bottom,
view.host().getBoundingClientRect().bottom - 2 - 12
);
await waitStuck(root, true);
await view.capture('bottom-stuck');
},
{ edge: 'bottom', offset: 12, innerBeforeHeight: 500 }
));
it('native disabled observation keeps the marker absent while CSS can pin', () =>
visit(
async (view) => {
await view.scrollTo(100);
near(
view.node().getBoundingClientRect().top,
view.host().getBoundingClientRect().top + 2 + 12
);
expect(view.node().hasAttribute('data-stuck')).toBe(false);
await view.capture('disabled');
},
{ offset: 12, disabled: true }
));
it('native reactive disabled clears an existing stuck marker', () =>
visit(
async (view) => {
await view.scrollTo(100);
await waitStuck(view.node(), true);
view.controls().disabled(true);
await view.ready();
await waitStuck(view.node(), false);
await view.capture('reactive-disabled');
},
{ offset: 12 }
));
it('native reactive offset updates the same root and pin position', () =>
visit(
async (view) => {
await view.scrollTo(100);
const root = view.node();
view.controls().offset(24);
await view.ready();
expect(view.node()).toBe(root);
near(root.getBoundingClientRect().top, view.host().getBoundingClientRect().top + 2 + 24);
expect(root.style.getPropertyValue('--_sticky-offset')).toBe('24px');
await view.capture('reactive-offset');
},
{ offset: 12 }
));
it('native reactive edge moves the sentinel after the same box', () =>
visit(async (view) => {
const root = view.node();
view.controls().edge('bottom');
await view.ready();
expect(view.node()).toBe(root);
expect(root.getAttribute('data-edge')).toBe('bottom');
expect(root.nextElementSibling).toBe(view.sentinel());
await view.capture('reactive-edge');
}));
it('native consumer style and global attributes reach the box', () =>
visit(
async (view) => {
const root = view.node();
expect(root.classList.contains('audit-sticky')).toBe(true);
expect(root.getAttribute('data-probe')).toBe('sticky');
expect(root.getAttribute('title')).toBe('Audit Sticky');
expect(getComputedStyle(root).color).toBe('rgb(20, 40, 60)');
await view.capture('style');
},
{ style: 'color:rgb(20,40,60)' }
));
it('native viewport root pins to the document viewport', () =>
visit(
async (view) => {
window.scrollTo(0, 150);
await view.ready();
expect(window.scrollY).toBeGreaterThan(0);
near(view.node().getBoundingClientRect().top, 12);
await waitStuck(view.node(), true);
await view.capture('viewport-stuck');
},
{ rootMode: 'viewport', offset: 12 }
));
it('native 350px viewport fits the scroll host without horizontal overflow', () =>
visit(
async (view) => {
await page.viewport(350, 900);
await view.ready();
const box = view.host().getBoundingClientRect();
expect(box.left).toBeGreaterThanOrEqual(0);
expect(box.right).toBeLessThanOrEqual(350);
await view.scrollTo(100);
await waitStuck(view.node(), true);
await view.capture('350');
},
{ offset: 12 }
));

@ -0,0 +1,91 @@
<script module lang="ts">
import type { StickyProps } from '$uix/eidos/components/sticky';
export type Config = {
edge?: StickyProps['edge'];
offset?: StickyProps['offset'];
disabled?: StickyProps['disabled'];
sectionHeight?: number;
beforeHeight?: number;
innerBeforeHeight?: number;
rootMode?: 'container' | 'viewport';
style?: string;
};
export type Controls = {
edge: (next: StickyProps['edge']) => void;
offset: (next: StickyProps['offset']) => void;
disabled: (next: StickyProps['disabled']) => void;
};
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { setActiveUix, type ActiveUix } from '$active-uix';
import { Soma } from '$soma/core/soma.svelte';
import Sticky from '$uix/eidos/components/sticky';
let {
uix,
initial = {},
onControls
}: {
uix: ActiveUix;
initial?: Config;
onControls: (controls: Controls) => void;
} = $props();
// svelte-ignore state_referenced_locally
setActiveUix(uix);
Soma.create();
let host = $state<HTMLDivElement | null>(null);
// svelte-ignore state_referenced_locally
let edge = $state(initial.edge ?? 'top');
// svelte-ignore state_referenced_locally
let offset = $state(initial.offset ?? 0);
// svelte-ignore state_referenced_locally
let disabled = $state(initial.disabled ?? false);
onMount(() =>
onControls({
edge: (next) => (edge = next ?? 'top'),
offset: (next) => (offset = next ?? 0),
disabled: (next) => (disabled = next ?? false)
})
);
</script>
<div id="audit-frame" style="padding:24px;font:14px Arial,sans-serif">
<div
id="audit-host"
bind:this={host}
style={`height:200px;width:400px;max-width:100%;box-sizing:border-box;overflow:${initial.rootMode === 'viewport' ? 'visible' : 'auto'};position:relative;border:2px solid rgb(66,90,128);background:rgb(232,240,252)`}
>
<div id="audit-before" style={`height:${initial.beforeHeight ?? 60}px`}></div>
<section
id="audit-section"
style={`height:${initial.sectionHeight ?? 600}px;background:rgb(213,227,244)`}
>
<div id="audit-inner-before" style={`height:${initial.innerBeforeHeight ?? 0}px`}></div>
<Sticky
id="audit-sticky"
class="audit-sticky"
data-probe="sticky"
title="Audit Sticky"
{edge}
{offset}
root={initial.rootMode === 'viewport' ? null : host}
{disabled}
style={initial.style}
>
Sticky header
</Sticky>
</section>
<div id="audit-after" style="height:700px;background:rgb(244,248,253)">After section</div>
</div>
</div>
<style>
:global(.audit-sticky) {
height: 40px;
background: rgb(107, 156, 206);
color: white;
padding: 8px;
box-sizing: border-box;
}
</style>

@ -0,0 +1,65 @@
import { mount, unmount, flushSync } from 'svelte';
import { page } from 'vitest/browser';
import { createActiveUix } from '$active-uix';
import '$uix/eidos/index.css';
import Harness, { type Config, type Controls } from './sticky-audit-harness.svelte';
export function renderSticky(initial: Config = {}) {
const uix = createActiveUix({
langs: { schema: {}, defaultLocale: 'en' },
prefs: { storage: false },
projectPrefs: false
});
const target = document.createElement('div');
document.body.append(target);
let controls!: Controls;
const component = mount(Harness, {
target,
props: { uix, initial, onControls: (value) => (controls = value) }
});
flushSync();
const node = () => target.querySelector<HTMLElement>('#audit-sticky')!;
const sentinel = () => target.querySelector<HTMLElement>('[data-sticky-sentinel]')!;
const host = () => target.querySelector<HTMLElement>('#audit-host')!;
const section = () => target.querySelector<HTMLElement>('#audit-section')!;
const ready = async () =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
);
return {
target,
node,
sentinel,
host,
section,
controls: () => controls,
ready,
async scrollTo(y: number) {
host().scrollTop = y;
await ready();
},
async capture(name: string) {
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1') {
await ready();
await page.screenshot({ path: 'tmp/sticky-' + name + '.png' });
}
},
async dispose() {
await unmount(component);
uix.dispose();
target.remove();
window.scrollTo(0, 0);
}
};
}
export type View = ReturnType<typeof renderSticky>;
export async function visit(fn: (view: View) => Promise<void>, initial: Config = {}) {
await page.viewport(1100, 900);
const view = renderSticky(initial);
try {
await view.ready();
await fn(view);
} finally {
await view.dispose();
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.