uix(aura): el anillo de custodia sale del `<script module>` y su afirmación queda fijada

Fase 0 de la corrección: higiene, sin decisiones pendientes de firma.

EL MOTOR PURO
La tabla de formas del anillo vivía dentro de un `<script module>` sin
exportar — o sea, imposible de testear. Sale a `shapes.ts` siguiendo el
precedente de la casa (OnionMenu ya tiene sus engines puros `geometry.ts` /
`color.ts` con test al lado). No es abstracción nueva: es mover código que ya
estaba separado a un sitio donde un test pueda alcanzarlo.

EL TEST QUE FALTABA
`RING_SHAPES` es LA afirmación del componente — «ningún estado se distingue
solo por el color» — y no la sostenía nada: los 353 de eidos son de contrato
y no la tocan. Ahora hay 8 casos, y el central compara SILUETAS (raíl · dash ·
loop · halo, con el color deliberadamente ausente) y exige que ningún par de
estados comparta la suya. Los demás fijan las decisiones de diseño que se
tomaron con motivo: el raíl permanece en reposo (la presencia recede, nunca
desaparece), `reviewing` está QUIETO mientras `acting` gira (es lo que los
separa cuando el tinte solo los aleja ~22° de hue), `escalated` es el único
con anillo cerrado y halo, los loops son solo los registrados, y el halo
arranca en el borde del orbe para que el `ping` (2×) aterrice en el anillo.

Verificado en negativo, como el guard: igualando `reviewing` a `acting` el
test falla con el mensaje exacto — «acting is indistinguishable from
reviewing without colour».

GAPS DESFASADOS
El README de soma listaba dos gaps YA HECHOS (el wrapper de eidos y la
comparativa, de fases cerradas) y los dos READMEs seguían diciendo que la
elicitación «se materializa con la superficie de conversación» — que es
justo lo que la firma de ayer (§5b) descartó. Corregido, y de paso queda
escrito POR QUÉ nunca se materializó, que no era falta de superficie: la
escalada lleva una razón tipada pero NO la pregunta, y
`resolveEscalation(runId, 'continue'|'cancel')` no tiene por dónde llevar una
RESPUESTA. Hasta que existan esas dos costuras, `escalated` se puede mostrar
pero no responder — con el punto 4 del contrato a11y firmado (elicitaciones
descartables/posponibles) incumplido.

Verificado: eidos 360/361 · el único fallo es `backdrop`, componente SIN
TRACKEAR de otra sesión en curso (dos `#000` crudos en su recipe), ajeno ·
eidos-lint aura 0 inválidos / 0 class-hooks · check en los 73 preexistentes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 2 months ago
parent 03d8b31711
commit 620aef5c48

@ -199,9 +199,9 @@ pulses land circular. Full table in the soma README
## Gaps
| Gap | Disposition |
| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Presets de escena por estado (carácter de aurora distinto más allá de speed/amplitude/hue) | **diferir** — la modulación actual cubre el ciclo; se especializa cuando el diseño lo pida |
| Superficie de elicitación en `escalated` (pregunta tipada vía `Form.AutoFields`) | **diferir** a PLAN-agent F4 — la categoría del protocolo existe (reservada); se materializa con la superficie de conversación |
| Variante compacta/inline (solo orb, sin status/cancel) para toolbars | **diferir** — hasta que un host real lo pida (la barra de admisión) |
| Prop `color` decorativo | **descartar** — el tinte es evaluativo (estado del run); un `color` invitaría al antipatrón «marco rojo». El tema re-tinta los slots de rol |
| Gap | Disposition |
| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Presets de escena por estado (carácter de aurora distinto más allá de speed/amplitude/hue) | **diferir** — la modulación actual cubre el ciclo; se especializa cuando el diseño lo pida |
| Superficie de elicitación en `escalated` (pregunta tipada vía `Form.AutoFields`) | **construible ya** (desbloqueada 2026-07-23: no depende de superficie conversacional — PLAN-agent §5b). Falta la costura en el motor: la escalada no lleva la pregunta y `resolveEscalation` no lleva la respuesta |
| Variante compacta/inline (solo orb, sin status/cancel) para toolbars | **diferir** — hasta que un host real lo pida (la barra de admisión) |
| Prop `color` decorativo | **descartar** — el tinte es evaluativo (estado del run); un `color` invitaría al antipatrón «marco rojo». El tema re-tinta los slots de rol |

@ -1,55 +1,3 @@
<script module lang="ts">
import type { AuraState } from '$soma/components/aura';
/**
* Ring geometry — the component's own coordinate system, not design
* tokens (same split as the OnionMenu: geometry lives in TS, chrome in
* the recipe). One circle, one radius; every state is a dash pattern
* over the SAME stroke, so the cycle reads as SHAPE and the role tint
* stays redundant — no state is ever distinguishable by colour alone.
*/
const R = 44;
const C = 2 * Math.PI * R;
/** Fractions of the circumference, named so the intent survives edits. */
function dash(on: number, off: number): string {
return `${C * on} ${C * off}`;
}
interface RingShape {
/** The faint dotted rail the arc runs on (absent once the ring closes). */
track: boolean;
/** Dash pattern of the state arc; `null` hides it (idle). */
arc: string | null;
/** Registered content loop for the arc — un-gated, reduced-motion safe. */
loop: 'spin' | 'pulse' | undefined;
/** The outward pulse that makes `escalated` claim attention. */
halo: boolean;
}
const SHAPES: Record<AuraState, RingShape> = {
// At rest: the rail alone. Presence recedes, never disappears.
idle: { track: true, arc: null, loop: undefined, halo: false },
// An offer breathes — noticeable without interrupting.
offered: { track: true, arc: dash(0.25, 0.75), loop: 'pulse', halo: false },
// A plan is discrete: four equal segments = steps, not progress. It
// breathes — the plan is proposed and alive, not parked; the shape
// (segments vs the single arc of `offered`) is what tells them apart.
planned: { track: true, arc: dash(0.18, 0.07), loop: 'pulse', halo: false },
// Your review is pending: two opposed arcs, held still. Waiting reads
// as stillness — the only stable state with a closed-in shape.
reviewing: { track: true, arc: dash(0.34, 0.16), loop: undefined, halo: false },
// Working: one arc going round. Indeterminate on purpose (the act
// count is text, in Status — the ring never fakes a progress bar).
acting: { track: true, arc: dash(0.28, 0.72), loop: 'spin', halo: false },
// It needs a human: the ring closes and pushes a pulse outward.
escalated: { track: false, arc: dash(1, 0), loop: undefined, halo: true }
};
/** The halo starts at the orb's edge and expands out to the ring (ping = 2×). */
const HALO_R = R / 2;
</script>
<script lang="ts">
/**
* Eidos `<Aura.Ring>` — the custody ring. It WRAPS the Indicator: the ring
@ -67,28 +15,35 @@
*/
import * as Aura from '$soma/components/aura';
import { AuraProvider } from '$soma/components/aura';
import { HALO_RADIUS, RING_RADIUS, RING_SHAPES } from './shapes';
import type { AuraRingProps } from './types';
let { ref = $bindable(null), children, ...rest }: AuraRingProps = $props();
const provider = AuraProvider.get();
const shape = $derived(SHAPES[provider?.state ?? 'idle']);
const shape = $derived(RING_SHAPES[provider?.state ?? 'idle']);
</script>
<Aura.Ring {...rest} bind:ref>
<svg viewBox="0 0 100 100" aria-hidden="true" focusable="false">
{#if shape.track}
<circle data-aura-ring-slot="track" cx="50" cy="50" r={R} stroke-dasharray="1 5" />
<circle data-aura-ring-slot="track" cx="50" cy="50" r={RING_RADIUS} stroke-dasharray="1 5" />
{/if}
{#if shape.halo}
<circle data-aura-ring-slot="halo" cx="50" cy="50" r={HALO_R} data-animation-style="ping" />
<circle
data-aura-ring-slot="halo"
cx="50"
cy="50"
r={HALO_RADIUS}
data-animation-style="ping"
/>
{/if}
{#if shape.arc}
<circle
data-aura-ring-slot="arc"
cx="50"
cy="50"
r={R}
r={RING_RADIUS}
stroke-dasharray={shape.arc}
data-animation-style={shape.loop}
/>

@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { HALO_RADIUS, RING_RADIUS, RING_SHAPES, type RingShape } from './shapes';
import type { AuraState } from '$soma/components/aura';
const STATES: readonly AuraState[] = [
'idle',
'offered',
'planned',
'reviewing',
'acting',
'escalated'
];
/** The shape as the eye receives it — colour deliberately absent. */
function silhouette(shape: RingShape): string {
return [
shape.track ? 'rail' : 'no-rail',
shape.arc ?? 'no-arc',
shape.loop ?? 'still',
shape.halo ? 'halo' : 'no-halo'
].join('|');
}
describe('Aura custody ring — shapes', () => {
it('covers every custody state', () => {
expect(Object.keys(RING_SHAPES).sort()).toEqual([...STATES].sort());
});
// THE claim of the component: the ring encodes the cycle, the role tint
// only confirms it. If two states ever share a silhouette, the surface
// becomes colour-dependent — the accessibility failure this design exists
// to avoid (and the one the original orb-only version had).
it('gives every state a silhouette no other state shares', () => {
const seen = new Map<string, AuraState>();
for (const state of STATES) {
const key = silhouette(RING_SHAPES[state]);
const clash = seen.get(key);
expect(clash, `"${state}" is indistinguishable from "${clash}" without colour`).toBe(
undefined
);
seen.set(key, state);
}
});
// Presence recedes, never disappears (doctrine agent.md §7 — the same rule
// that forbids `hide` under reduced motion).
it('keeps the rail at rest instead of emptying the ring', () => {
expect(RING_SHAPES.idle.arc).toBeNull();
expect(RING_SHAPES.idle.track).toBe(true);
});
// Waiting reads as stillness: it is what separates «your review is
// pending» from «the agent is working» when both are tinted a role apart
// by only ~22° of hue.
it('holds reviewing still while acting moves', () => {
expect(RING_SHAPES.reviewing.loop).toBeUndefined();
expect(RING_SHAPES.acting.loop).toBe('spin');
});
// The one state that claims attention: closed ring + outward pulse, and
// the only one that drops the rail.
it('reserves the closed ring and the halo for escalated', () => {
expect(RING_SHAPES.escalated.halo).toBe(true);
expect(RING_SHAPES.escalated.track).toBe(false);
for (const state of STATES.filter((s) => s !== 'escalated')) {
expect(RING_SHAPES[state].halo, `${state} must not claim attention`).toBe(false);
expect(RING_SHAPES[state].track, `${state} keeps its rail`).toBe(true);
}
});
// Only loops the motion registry actually ships — a typo here would fail
// silently in CSS (an unknown `data-animation-style` matches no rule).
it('uses only registered content loops', () => {
for (const state of STATES) {
const loop = RING_SHAPES[state].loop;
if (loop !== undefined) expect(['spin', 'pulse']).toContain(loop);
}
});
it('draws every arc within one circumference', () => {
const circumference = 2 * Math.PI * RING_RADIUS;
for (const state of STATES) {
const arc = RING_SHAPES[state].arc;
if (arc === null) continue;
const [on, off] = arc.split(' ').map(Number);
expect(on).toBeGreaterThan(0);
expect(on + off).toBeLessThanOrEqual(circumference + 1e-6);
}
});
it('starts the halo at the orb edge so the ping expands into the ring', () => {
// `loop-ping` scales 2×: from the orb edge it lands on the ring itself.
expect(HALO_RADIUS * 2).toBe(RING_RADIUS);
});
});

@ -0,0 +1,55 @@
/**
* Custody-ring shapes — the pure engine behind `<Aura.Ring>` (same split the
* OnionMenu uses: geometry in TS, chrome in the recipe).
*
* One circle, one radius; every state is a dash pattern over the SAME stroke,
* so the cycle reads as SHAPE and the role tint stays redundant — **no state
* is ever distinguishable by colour alone**. That claim is the component's
* reason to exist, so it lives here where a test can pin it (`shapes.test.ts`)
* rather than inside a `<script module>` nothing can import.
*/
import type { AuraState } from '$soma/components/aura';
/** Ring radius in the SVG's own 100×100 coordinate system. */
export const RING_RADIUS = 44;
const CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
/** The halo starts at the orb's edge and expands out to the ring (ping = 2×). */
export const HALO_RADIUS = RING_RADIUS / 2;
/** Fractions of the circumference, named so the intent survives edits. */
function dash(on: number, off: number): string {
return `${CIRCUMFERENCE * on} ${CIRCUMFERENCE * off}`;
}
export interface RingShape {
/** The faint dotted rail the arc runs on (absent once the ring closes). */
readonly track: boolean;
/** Dash pattern of the state arc; `null` hides it (idle). */
readonly arc: string | null;
/** Registered content loop for the arc — un-gated, reduced-motion safe. */
readonly loop: 'spin' | 'pulse' | undefined;
/** The outward pulse that makes `escalated` claim attention. */
readonly halo: boolean;
}
export const RING_SHAPES: Readonly<Record<AuraState, RingShape>> = {
// At rest: the rail alone. Presence recedes, never disappears.
idle: { track: true, arc: null, loop: undefined, halo: false },
// An offer breathes — noticeable without interrupting.
offered: { track: true, arc: dash(0.25, 0.75), loop: 'pulse', halo: false },
// A plan is discrete: four equal segments = steps, not progress. It
// breathes — the plan is proposed and alive, not parked; the shape
// (segments vs the single arc of `offered`) is what tells them apart.
planned: { track: true, arc: dash(0.18, 0.07), loop: 'pulse', halo: false },
// Your review is pending: two opposed arcs, held still. Waiting reads
// as stillness — the only stable state with a closed-in shape.
reviewing: { track: true, arc: dash(0.34, 0.16), loop: undefined, halo: false },
// Working: one arc going round. Indeterminate on purpose (the act
// count is text, in Status — the ring never fakes a progress bar).
acting: { track: true, arc: dash(0.28, 0.72), loop: 'spin', halo: false },
// It needs a human: the ring closes and pushes a pulse outward.
escalated: { track: false, arc: dash(1, 0), loop: undefined, halo: true }
};

@ -118,11 +118,15 @@ DETAIL is app content, in the app's own domain.
## Gaps (with disposition)
- Eidos wrapper + recipe + `$scene` orb + reduced-motion `static-frame` →
route phases 4–5 (planned, PLAN-agent.md F3).
- Comparativa table (≥3 refs) → phase 7 README pass, with the demo.
- Elicitation surface on `escalated` (typed question) → reserved (protocol
category exists; materializes with the conversation surface, F4).
- Elicitation surface on `escalated` (typed question) → **buildable now**
(unblocked 2026-07-23): a typed question is a form in context
(`Form.AutoFields`), NOT a conversation — see PLAN-agent §5b. Two seams are
still missing in the engine and they are why this never shipped: the
escalation carries a typed REASON but not the question, and
`resolveEscalation(runId, 'continue' | 'cancel')` has nowhere to carry an
ANSWER. Until they exist, `escalated` can be surfaced but not answered —
which leaves point 4 of the signed a11y contract (dismissible /
postponable elicitations) unmet.
- A surface state for the run's FAILURE that outlives the hold (an `errored`
echo) → **discarded for now**: it would contradict custody (control already
returned). Revisit only with a real case where the app cannot hold that

Loading…
Cancel
Save

Powered by TurnKey Linux.