@jossuealcala/madre 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,101 @@ Todas las versiones publicadas de `@jossuealcala/madre`. Fechas en ISO.
4
4
 
5
5
  Una versión se cierra cuando está en npm: hasta entonces su sección se llama **Sin publicar** y puede crecer. Cada versión publicada tiene exactamente una etiqueta `vX.Y.Z`, una release en GitHub y una sección aquí; el parche puede llegar a dos dígitos (`0.2.10`) antes de subir el menor. Ver `docs/ROADMAP.md` para el criterio de qué sube cada número.
6
6
 
7
+ ## 0.6.1 · Sin publicar
8
+
9
+ ## 0.6.0 · 2026-10-01
10
+
11
+ ### Ash conserva una sola decisión de principio a fin
12
+
13
+ - El historial ya no puede encender ni apagar Ash al abrir la sala o cambiar de conversación: `/api/state` manda y solo un `extension.toggled` vivo cambia el compositor. La vista previa usa además la selección del mensaje, no solo el interruptor global.
14
+ - Cada turno conserva la decisión tomada al pulsar Enviar antes del primer `await`: `requested` y `active` quedan separados, `turn.cost.ash` confirma si el bloque llegó al prompt y un fallo posterior conserva el coste y limpia su forma pendiente. Los pasos y el cierre de un plan mantienen el mismo Ash y el mismo modo.
15
+ - La prosa compacta se adapta a longitud explícita, código y estructura pedida. Una comprobación local marca respuestas vacías, límites excedidos, bloques sin cerrar, planes con texto posterior y deriva de literales pedidos como exactos. No cambia modelo, contexto, herramientas ni repite automáticamente un turno con permisos.
16
+ - La ficha pública no promete un porcentaje fijo: muestra entrada, salida y caché por turno, y dice que el ahorro depende del agente, la tarea y la caché. El corpus reproducible de `scripts/bench-ash.mjs` solo verifica el contrato; no se presenta como medición de ahorro.
17
+
18
+ ### La economía deja de ser una promesa y pasa a ser una lectura
19
+
20
+ - El panel ECONOMÍA, a pantalla completa desde la ficha de Ash: lo que nunca se cobró, lo que Ash le hace a una respuesta, a dónde va cada prompt y una tabla por agente. Abre con seis cifras del total —turnos, entrada, salida, no cobrados, caché y caracteres por token— porque seis números de seis naturalezas distintas no tienen forma que dibujar.
21
+ - Cada mensaje dice en su pie cuántos tokens **no** se cobraron: lo que el CLI leyó de su propia caché (medido) más el briefing que la sala nunca envió (estimado). Las dos mitades nunca se suman sin decir cuál es cuál.
22
+ - Ash muestra primero lo que **cuesta** —306 caracteres por turno, medidos del bloque— y después lo que podría ahorrar. Y cuando hay cinco turnos de cada lado, la lectura dice si la diferencia supera la variación propia de ese agente o si todavía no hay nada que leer. Una lectura que solo pudiera darle la razón al interruptor sería publicidad.
23
+ - Los turnos que la sala contestó sola, con `@madre` y sin factura, se cuentan aparte. No se les pone precio: nadie midió lo que habrían costado en otro lado.
24
+
25
+ ### Lo que la sala sabe, puesto donde se decide
26
+
27
+ - Arriba del hilo, las decisiones vigentes del proyecto. Una decisión refutada no aparece: el archivo la conserva, pero una franja que dice «decidido» no puede cargar algo que la sala ya estableció como falso.
28
+ - Bajo el compositor, con el campo vacío, las preguntas que el archivista registró como abiertas y nadie contestó, las más arrastradas primero. Se van en cuanto escribes.
29
+ - Una pregunta sobre lo que la sala ya dijo ofrece `@madre` antes de gastar: contesta desde el mismo archivo, en esta computadora y sin tokens. Ofrece; tú eliges.
30
+ - Nada de esto entra en un prompt. Son lecturas del archivo que se te muestran a ti.
31
+
32
+ ### Choices · un módulo para devolverte una decisión
33
+
34
+ - Un agente que tiene que elegir entre tres o más opciones reales puede devolvértelas como botones, con un canal propio e inerte: `pulse-ask`. Pulsar uno escribe la opción en el compositor y para ahí; nada corre ni se gasta hasta que tú mandas.
35
+ - No toca el canal de delegación. Ese parser arranca turnos con permisos reales, y enseñarle una segunda gramática sería poner un parser que puede gastar dinero en el camino de uno que no.
36
+ - Tiene interruptor porque no es gratis: la instrucción cuesta unos 135 tokens por turno mientras está encendido, y aparece como bloque propio en la economía. Una capacidad cuyo costo no se puede ver es una capacidad contra la que nadie puede decidir.
37
+
38
+ ### La escalera, al lado del agente que pide subir
39
+
40
+ - Cuando una respuesta dice que el turno necesita más permiso del que tuvo, la sala lo ofrece ahí mismo: `#2` arma el compositor y `#3` o `#4` abren la misma anulación de siempre, donde la designación del proyecto se escribe a mano. Ofrece el camino; no concede nada ni acorta la ceremonia.
41
+ - Hasta ahora la sala solo sabía pedir elevación en un caso, un paso de plan que quiere `#2`. Un agente en turno directo que necesitaba `#3` únicamente podía decirlo en una frase, y leerla, salir del mensaje y encontrar el selector era trabajo del humano.
42
+ - El detector es estrecho a propósito: un `#3` suelto no basta, hace falta una palabra de necesidad a su lado, y el modo tiene que estar por encima del que el turno tuvo. Un agente que explica la escalera menciona todos los peldaños y eso no es una petición. No cuesta nada en ningún prompt.
43
+
44
+ ### RIPLEY abre lo que ya está escuchando en esta computadora
45
+
46
+ - Un navegador anclado al lado del chat, con pestañas, que abre los servidores que contestan HTTP aquí: los lee de `lsof`/`netstat` y solo acepta direcciones de loopback. La sala no ejecuta nada para encontrarlos.
47
+ - Sin barra de direcciones. Un sitio que manda `X-Frame-Options` o `frame-ancestors` no se deja enmarcar, y ninguna página puede saltárselo: es la defensa estándar contra el clickjacking y RIPLEY ni puede ni debe anularla. Cuando uno se niega, el panel lo dice y ofrece abrirlo en pestaña o copiar la dirección; no abre ventanas por su cuenta.
48
+ - La comprobación de marcado, el permiso y el ajuste siguen vivos debajo, porque el obstáculo es el medio y no el diseño: una MADRE de escritorio con su propio motor sí puede, y ese día la barra vuelve.
49
+ - El visor de archivos volvió a ser solo eso. Su botón de servidores desapareció: esa lista vive en el navegador.
50
+
51
+ ### El finder opera sobre los archivos, con las mismas vallas
52
+
53
+ - Clic derecho sobre cualquier fila: copiar, mover, renombrar, archivo nuevo, carpeta nueva. Lo que se toma se ve en una tira bajo el buscador; el nombre se escribe ahí mismo, en el panel, sin sacar al humano de donde estaba mirando.
54
+ - Esto no es un agente bajo un modo: es el humano en su proyecto. Pero las vallas siguen. El proyecto es el mundo —los dos extremos se resuelven dentro o la operación no ocurre, comprobado después de seguir enlaces simbólicos— y las zonas que la sala protege de los agentes se protegen también de un resbalón: `.git`, `.pulse`, `.madre`, un `.env` o los ajustes locales piden la designación del proyecto, la misma palabra que pide CONTROL.
55
+ - Cuentan los dos extremos. Mover algo inofensivo **encima** de una ruta protegida es exactamente como se pierde una ruta protegida.
56
+ - Y un buscador por nombre, con el mismo puntaje que usa el menú `!` del compositor.
57
+
58
+ ### La barra, el pie y la firma
59
+
60
+ - La barra superior se compacta por consultas de contenedor, no de ventana: con el navegador abierto la columna del chat mide mucho menos que la pantalla, y una media query nunca se enteraba. Los botones pierden la etiqueta y conservan el icono en el orden en que dejan de caber.
61
+ - Las preguntas abiertas van en un riel horizontal con la etiqueta centrada encima. Antes envolvían a un segundo renglón y empujaban el compositor hacia abajo.
62
+ - El aviso de modo pasó de cuatro renglones a dos, y en la esquina inferior derecha quedó la versión y el copyright. La versión viene de `/api/state`, no escrita a mano donde se olvide en el siguiente release.
63
+
64
+ ### El gato ya no es un dibujo: es una pequeña escena
65
+
66
+ - Escribe `madre` sola en la caja, sin arroba ni nada más, y envía. Una nave hiperrealista entra en la sala con su pasajero verde dentro; no responde un agente.
67
+ - Cuatro fotogramas registrados mantienen el casco, el domo y el haz en su sitio mientras el michi parpadea, mira el tablero y juega con los controles. El resultado se siente animado sin los saltos de una colección de imágenes distintas.
68
+ - Las luces del casco persiguen, el emisor respira y el reflejo del domo cambia con la escena. La nave conserva completos sus bordes, antenas y motor durante toda la aparición.
69
+ - Sigue siendo un secreto local: no se enruta, no entra al ledger, no cuesta tokens y ningún agente se entera. Al terminar, el compositor vuelve exactamente como estaba.
70
+
71
+ ### Arreglos
72
+
73
+ - Los chips de memoria eran ilegibles en tema claro: el tipo se pintaba con colores elegidos para el mapa oscuro de NOSTROMO, y «HECHO» en un azul casi blanco desaparecía sobre el fondo. Ahora el tipo es un punto con anillo, el texto lleva los colores del texto y la fila perdió la opacidad que la apagaba.
74
+ - La paleta de `/` dejaba sin descripción a cualquier comando con sintaxis larga: la columna del nombre crecía sin límite y la explicación se truncaba hasta desaparecer. La sintaxis bajó a su propio renglón.
75
+ - Un renderizador que declina ya no mete un nulo en la columna de mensajes.
76
+ - El primer replay pintaba chips de memoria antes de que se evaluaran las constantes que necesitan: la página podía morir en una zona muerta temporal antes de dibujar nada. Los colores, las palabras de cada tipo y el estimador de caracteres por token viven ahora con los demás formateadores, arriba.
77
+ - PLAYWRIGHT abortaba hasta esta misma sala con `ERR_BLOCKED_BY_CLIENT`. Pasaba `--blocked-origins '*'` además de la lista blanca, y Playwright casa la ruta añadida más recientemente primero: el bloqueo general ganaba sobre la lista. La lista blanca ya era una puerta cerrada.
78
+ - El `+N` de las memorias usadas podía quedarse solo en un renglón al envolverse la fila; ahora viaja junto al último chip.
79
+
80
+ ## 0.5.2 · 2026-09-28
81
+
82
+ Tres reportes de la 0.5.1, y la razón por la que ninguno lo atrapó una prueba. Arreglos: nada cambia modos, módulos, agentes ni a dónde sale nada.
83
+
84
+ ### Cerrar una versión abre la siguiente
85
+
86
+ - Dos veces seguidas una versión salió y los arreglos que llegaron minutos después quedaron escritos **dentro de una sección ya publicada**. Eso es reescribir historia a mano, y hay que moverla de sitio antes de poder cerrar nada — porque un número en npm tiene que significar un código y solo uno: la sala decide si hay algo nuevo comparando versiones, y reescribir una dejaría sin señal, para siempre, a quien ya la tuviera.
87
+ - Ahora `scripts/release.mjs` **abre la siguiente sección en el mismo commit que cierra esta**. Lo que venga después tiene dónde escribirse y nadie tiene que acordarse. Abre un parche por defecto; si lo que sigue resulta ser un menor, cambiar el encabezado es todo el trabajo.
88
+ - Y ya no deja cerrar una versión cuya sección esté vacía. Una nota de versión es cómo alguien decide si la toma; una vacía es peor que no publicar.
89
+
90
+ ### Los tres reportes de 0.5.1
91
+
92
+ - **Ningún comando de módulo funcionaba al abrir la sala** (#8). `/git`, `/ahp`, `/pendientes`: todos contestaban «comando desconocido» con el módulo encendido, porque la única llamada que le pide al servidor la lista se había caído **dentro** del bloque que cierra una instalación en vivo. Hasta que ocurriera una instalación, el compositor no conocía ni un comando de módulo. Ahora se pide al cargar, y también cuando un módulo entra, sale o cambia de interruptor — que es cuando cambia lo que el compositor acepta.
93
+ - **Las franjas de CREATE del historial no se dibujaban** (#9). `renderLease` leía `CAP_LABELS`, declarada cuatrocientas líneas más abajo; el historial se repite antes de llegar ahí, el `try` de cada renderizador se tragaba el error, y la franja simplemente no aparecía. En vivo salía bien, que es lo que lo hizo invisible.
94
+ - **«@CODEX PUEDE CREA»** (#10). La frase ya se traducía entera, pero `{what}` traía las etiquetas de la ficha, que son imperativos para un botón suelto. Dentro de una oración hace falta el infinitivo, así que ahora hay dos juegos: el de la ficha y el de la frase. Con ello, «instalado por **ti**» en vez de «por you» —el servidor manda `you` y el `??` nunca alcanzaba la traducción—, `@madre en la sala` en la ficha de OLLAMA, y la instrucción de `/module` dice ahora qué poner en `vendor`: sin decirlo, Codex escribió `'you'` y la ficha lo mostró como autor.
95
+
96
+ ### Por qué ninguno de los tres lo atrapó una prueba
97
+
98
+ - La prueba de humo reproduce un transcript real y falla si algo revienta al dibujarlo. Pero ese transcript tenía **ocho** de los **sesenta y un** tipos de evento que la sala sabe dibujar: los otros cincuenta y tres no los ejecutaba nadie. Por ahí pasaron tres constantes declaradas al fondo del archivo y leídas durante el arranque — y como cada renderizador está envuelto en un `try`, el error se tragaba, la franja no aparecía, y la suite seguía verde.
99
+ - Ahora entra un evento sintético **de cada tipo** detrás del transcript real. Un campo que a mis payloads les falte es culpa de la prueba y solo se exige sobre el transcript de verdad; lo que no se perdona nunca, en ningún tipo de evento, es que un renderizador lea algo que todavía no existe.
100
+ - Encontró una quinta en su primer intento: `modules`, que cuatro renderizadores de instalación leían durante el historial. Cada instalación y cada baja de módulo jamás aparecieron al recargar.
101
+
7
102
  ## 0.5.1 · 2026-09-28
8
103
 
9
104
  Cuatro cosas que salieron del QA de 0.5.0 el mismo día que se publicó. Ninguna cambia modos, módulos, agentes ni a dónde sale nada: son arreglos.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jossuealcala/madre",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "MADRE · one local room where the AI coding agents already on your machine (Codex, Claude Code, Gemini CLI, OpenCode) work on a project together, with a shared memory every one of them recalls and a local @madre that speaks for it. Read-only by default, per-message permission modes up to a checkpointed CONTROL. Nothing leaves your machine on its own.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -0,0 +1,384 @@
1
+ // ABDUCTION — the one joke in the room, and the only thing here that is not work.
2
+ //
3
+ // Type MADRE into the box, alone, and send it. Not @madre: that is the local agent and it has a
4
+ // turn to answer. Just the word, nothing else in the field, nothing attached. The room takes it
5
+ // as being called by name, and what answers is a flying saucer with a cat in it.
6
+ //
7
+ // It never reaches a provider, never enters the ledger, never costs a token. The field comes back
8
+ // exactly as it was. The one image ships with MADRE; no font or network request is involved.
9
+
10
+ // Called by name: the word on its own, in any casing, with nothing around it but space. An @ in
11
+ // front is an agent being addressed and belongs to the room, not here.
12
+ export function calledByName(text, { attachments = 0 } = {}) {
13
+ if (attachments > 0) return false;
14
+ return /^\s*madre\s*$/i.test(String(text ?? ''));
15
+ }
16
+
17
+ const PHASES = { arrive: 700, beam: 1050, lift: 2000, swallow: 2400, leave: 3000, done: 3400 };
18
+ const SHIP_FRAMES = Object.freeze({
19
+ idle: '/assets/abduction-cat.png',
20
+ blink: '/assets/abduction-cat-blink.png',
21
+ controlLeft: '/assets/abduction-cat-control-left.png',
22
+ controlRight: '/assets/abduction-cat-control-right.png',
23
+ });
24
+ const ease = (t) => (t < 0 ? 0 : t > 1 ? 1 : t * t * (3 - 2 * t));
25
+ const span = (now, from, to) => ease((now - from) / (to - from));
26
+ const bounce = (t) => 1 + 0.22 * Math.sin(Math.PI * 2 * t) * (1 - t);
27
+
28
+ // The saucer, and the passenger. Local space is 0..900 across, 0..520 down; the belly the beam
29
+ // comes out of is (450, 452), which is the one number the page positions everything else from.
30
+ function shipSVG(doc) {
31
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
32
+ svg.setAttribute('viewBox', '0 0 900 520');
33
+ svg.setAttribute('class', 'ab-svg');
34
+ svg.setAttribute('aria-hidden', 'true');
35
+ svg.innerHTML = `
36
+ <defs>
37
+ <linearGradient id="ab-hull" x1="0" y1="0" x2="0" y2="1">
38
+ <stop offset="0" stop-color="#cfd8e2"/>
39
+ <stop offset=".3" stop-color="#8c9aab"/>
40
+ <stop offset=".62" stop-color="#4a5666"/>
41
+ <stop offset="1" stop-color="#232c38"/>
42
+ </linearGradient>
43
+ <linearGradient id="ab-rim" x1="0" y1="0" x2="0" y2="1">
44
+ <stop offset="0" stop-color="#f2f6fa"/>
45
+ <stop offset=".5" stop-color="#9fb0c2"/>
46
+ <stop offset="1" stop-color="#596675"/>
47
+ </linearGradient>
48
+ <radialGradient id="ab-glass" cx=".36" cy=".26" r=".8">
49
+ <stop offset="0" stop-color="#ffffff" stop-opacity=".55"/>
50
+ <stop offset=".45" stop-color="#bfe8ff" stop-opacity=".18"/>
51
+ <stop offset="1" stop-color="#8ed0f0" stop-opacity=".1"/>
52
+ </radialGradient>
53
+ <linearGradient id="ab-fur" x1=".3" y1="0" x2=".6" y2="1">
54
+ <stop offset="0" stop-color="#cdf266"/>
55
+ <stop offset=".35" stop-color="#8ad434"/>
56
+ <stop offset=".78" stop-color="#4f9c1c"/>
57
+ <stop offset="1" stop-color="#2f6d12"/>
58
+ </linearGradient>
59
+ <linearGradient id="ab-muzzle" x1="0" y1="0" x2="0" y2="1">
60
+ <stop offset="0" stop-color="#e2fb93"/>
61
+ <stop offset="1" stop-color="#9ad93e"/>
62
+ </linearGradient>
63
+ <radialGradient id="ab-bulb">
64
+ <stop offset="0" stop-color="#ffffff"/>
65
+ <stop offset=".4" stop-color="#dcff7a"/>
66
+ <stop offset="1" stop-color="#7ac41f"/>
67
+ </radialGradient>
68
+ <linearGradient id="ab-beam" x1="0" y1="0" x2="0" y2="1">
69
+ <stop offset="0" stop-color="#d9ff8a" stop-opacity=".85"/>
70
+ <stop offset=".45" stop-color="#9ee23f" stop-opacity=".34"/>
71
+ <stop offset="1" stop-color="#7ac41f" stop-opacity=".06"/>
72
+ </linearGradient>
73
+ <!-- fur: the silhouette's edge is pushed about by noise, which is cheaper and softer than
74
+ drawing several hundred hairs, and it never looks repeated -->
75
+ <filter id="ab-furry" x="-15%" y="-15%" width="130%" height="130%">
76
+ <feTurbulence type="fractalNoise" baseFrequency="0.09" numOctaves="3" seed="7" result="n"/>
77
+ <feDisplacementMap in="SourceGraphic" in2="n" scale="9" xChannelSelector="R" yChannelSelector="G"/>
78
+ </filter>
79
+ <filter id="ab-soft" x="-40%" y="-40%" width="180%" height="180%">
80
+ <feGaussianBlur stdDeviation="14"/>
81
+ </filter>
82
+ <filter id="ab-lamp-glow" x="-350%" y="-350%" width="800%" height="800%">
83
+ <feGaussianBlur stdDeviation="6" result="blur"/>
84
+ <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
85
+ </filter>
86
+ <filter id="ab-mask-soft" x="-8%" y="-10%" width="116%" height="120%">
87
+ <feGaussianBlur stdDeviation="10"/>
88
+ </filter>
89
+ <mask id="ab-silhouette" maskUnits="userSpaceOnUse" x="0" y="0" width="900" height="520">
90
+ <rect width="900" height="520" fill="#000"/>
91
+ <g fill="#fff" filter="url(#ab-mask-soft)">
92
+ <!-- Deliberately wider than the photographed silhouette: the fade happens in empty
93
+ backdrop, never through the metal, glass, antennae or emitter. -->
94
+ <ellipse cx="450" cy="345" rx="438" ry="174"/>
95
+ <ellipse cx="450" cy="178" rx="252" ry="218"/>
96
+ <ellipse cx="450" cy="462" rx="112" ry="58"/>
97
+ </g>
98
+ </mask>
99
+ </defs>
100
+
101
+ <!-- Four registered photographs make one tiny performance: idle, blink and two controls.
102
+ The vector remains underneath as a local fallback if the base frame cannot be decoded. -->
103
+ <g class="ab-photos" mask="url(#ab-silhouette)">
104
+ <image class="ab-photo" data-frame="idle" href="${SHIP_FRAMES.idle}" x="60" y="0" width="780" height="520" preserveAspectRatio="xMidYMid meet"/>
105
+ <image class="ab-photo" data-frame="blink" href="${SHIP_FRAMES.blink}" x="60" y="0" width="780" height="520" preserveAspectRatio="xMidYMid meet"/>
106
+ <image class="ab-photo" data-frame="controlLeft" href="${SHIP_FRAMES.controlLeft}" x="60" y="0" width="780" height="520" preserveAspectRatio="xMidYMid meet"/>
107
+ <image class="ab-photo" data-frame="controlRight" href="${SHIP_FRAMES.controlRight}" x="60" y="0" width="780" height="520" preserveAspectRatio="xMidYMid meet"/>
108
+ </g>
109
+ <!-- These highlights stay vector-sharp so the hull feels powered rather than baked into a
110
+ still image. Their positions follow the practical lamps in every registered frame. -->
111
+ <g class="ab-live-lamps"></g>
112
+ <ellipse class="ab-live-emitter" cx="450" cy="452" rx="45" ry="9" fill="#ddff9a" opacity=".25" filter="url(#ab-lamp-glow)"/>
113
+ <path class="ab-glass-sheen" d="M300 265 C318 150 384 91 455 83" fill="none" stroke="#efffe2" stroke-width="8" stroke-linecap="round" opacity=".08"/>
114
+ <g class="ab-ship ab-vector">
115
+ <!-- the glow the hull sits in -->
116
+ <ellipse cx="450" cy="372" rx="300" ry="54" fill="#9ee23f" opacity=".25" filter="url(#ab-soft)"/>
117
+
118
+ <!-- the glass dome, and the passenger inside it -->
119
+ <g class="ab-cabin">
120
+ <path d="M250 320 C 250 176 340 86 450 86 C 560 86 650 176 650 320 Z" fill="#0b1118" opacity=".55"/>
121
+ <g class="ab-cat">
122
+ <!-- antennae -->
123
+ <g class="ab-antennae">
124
+ <path d="M392 196 C 376 160 366 134 364 110" fill="none" stroke="#7ac41f" stroke-width="11" stroke-linecap="round"/>
125
+ <path d="M508 196 C 524 160 534 134 536 110" fill="none" stroke="#7ac41f" stroke-width="11" stroke-linecap="round"/>
126
+ <circle class="ab-bulb" cx="364" cy="102" r="14" fill="url(#ab-bulb)"/>
127
+ <circle class="ab-bulb" cx="536" cy="102" r="14" fill="url(#ab-bulb)"/>
128
+ </g>
129
+ <!-- head and body, one furry silhouette -->
130
+ <g filter="url(#ab-furry)">
131
+ <path d="M450 302 C 370 302 338 272 338 224 C 338 162 384 126 450 126 C 516 126 562 162 562 224 C 562 272 530 302 450 302 Z" fill="url(#ab-fur)"/>
132
+ <path d="M398 288 C 372 300 356 318 352 340 L548 340 C 544 318 528 300 502 288 Z" fill="url(#ab-fur)"/>
133
+ </g>
134
+ <!-- muzzle -->
135
+ <ellipse cx="450" cy="252" rx="46" ry="26" fill="url(#ab-muzzle)" opacity=".5"/>
136
+ <!-- the eyes: big, black, almond, and the only part that has to be exactly right -->
137
+ <g class="ab-eyes">
138
+ <ellipse class="ab-eye" cx="410" cy="224" rx="33" ry="24" fill="#06120a" transform="rotate(-17 410 224)"/>
139
+ <ellipse class="ab-eye" cx="490" cy="224" rx="33" ry="24" fill="#06120a" transform="rotate(17 490 224)"/>
140
+ <circle cx="399" cy="214" r="7" fill="#ffffff" opacity=".92"/>
141
+ <circle cx="479" cy="214" r="7" fill="#ffffff" opacity=".92"/>
142
+ <circle cx="421" cy="232" r="3.4" fill="#ffffff" opacity=".45"/>
143
+ <circle cx="501" cy="232" r="3.4" fill="#ffffff" opacity=".45"/>
144
+ </g>
145
+ <!-- the lids, dropped only to blink -->
146
+ <g class="ab-lids">
147
+ <ellipse class="ab-lid" cx="410" cy="224" rx="35" ry="26" fill="url(#ab-fur)" transform="rotate(-17 410 224)"/>
148
+ <ellipse class="ab-lid" cx="490" cy="224" rx="35" ry="26" fill="url(#ab-fur)" transform="rotate(17 490 224)"/>
149
+ </g>
150
+ <path d="M443 248 L457 248 L450 256 Z" fill="#2f6d12"/>
151
+ <path d="M450 256 C 450 264 442 266 436 262" fill="none" stroke="#2f6d12" stroke-width="3" stroke-linecap="round"/>
152
+ <path d="M450 256 C 450 264 458 266 464 262" fill="none" stroke="#2f6d12" stroke-width="3" stroke-linecap="round"/>
153
+ <g stroke="#e8ffb4" stroke-width="2" stroke-linecap="round" opacity=".6">
154
+ <path d="M408 254 L356 246"/><path d="M408 260 L358 262"/>
155
+ <path d="M492 254 L544 246"/><path d="M492 260 L542 262"/>
156
+ </g>
157
+ <!-- paws on the glass -->
158
+ <ellipse cx="398" cy="336" rx="22" ry="13" fill="url(#ab-fur)"/>
159
+ <ellipse cx="502" cy="336" rx="22" ry="13" fill="url(#ab-fur)"/>
160
+ </g>
161
+ <path d="M250 320 C 250 176 340 86 450 86 C 560 86 650 176 650 320 Z" fill="url(#ab-glass)"/>
162
+ <path d="M300 300 C 302 196 352 120 430 104 C 372 136 334 210 330 300 Z" fill="#ffffff" opacity=".3"/>
163
+ <path d="M250 320 C 250 176 340 86 450 86 C 560 86 650 176 650 320" fill="none" stroke="#dfeaf4" stroke-opacity=".7" stroke-width="4"/>
164
+ </g>
165
+
166
+ <!-- the hull -->
167
+ <ellipse cx="450" cy="322" rx="330" ry="34" fill="url(#ab-rim)"/>
168
+ <path d="M120 322 C 120 362 258 392 450 392 C 642 392 780 362 780 322 Z" fill="url(#ab-hull)"/>
169
+ <path d="M140 330 C 180 352 300 368 450 368 C 600 368 720 352 760 330" fill="none" stroke="#0e141c" stroke-opacity=".35" stroke-width="6"/>
170
+ <g class="ab-lamps"></g>
171
+ <!-- the emitter the beam leaves from -->
172
+ <ellipse cx="450" cy="388" rx="58" ry="14" fill="#0a0f15"/>
173
+ <ellipse class="ab-emitter" cx="450" cy="386" rx="46" ry="10" fill="#c9ff79"/>
174
+ </g>`;
175
+ for (const photo of svg.querySelectorAll('.ab-photo')) {
176
+ photo.addEventListener('error', () => {
177
+ if (photo.dataset.frame === 'idle') svg.classList.add('missing-photo');
178
+ else photo.remove();
179
+ }, { once: true });
180
+ }
181
+ return svg;
182
+ }
183
+
184
+ // A light laid over each practical bulb makes the photographed ship feel powered. They chase in
185
+ // order, but never go fully dark: this is a living console, not a Christmas garland.
186
+ function growLamps(svg) {
187
+ const host = svg.querySelector('.ab-live-lamps');
188
+ if (!host) return [];
189
+ const doc = svg.ownerDocument;
190
+ const lamps = [];
191
+ const points = [[110, 331], [203, 325], [319, 326], [450, 330], [590, 326], [708, 325], [797, 331]];
192
+ for (const [x, y] of points) {
193
+ const lamp = doc.createElementNS('http://www.w3.org/2000/svg', 'ellipse');
194
+ lamp.setAttribute('cx', String(x));
195
+ lamp.setAttribute('cy', String(y));
196
+ lamp.setAttribute('rx', '10');
197
+ lamp.setAttribute('ry', '7');
198
+ lamp.setAttribute('fill', '#e6ffad');
199
+ host.append(lamp);
200
+ lamps.push(lamp);
201
+ }
202
+ return lamps;
203
+ }
204
+
205
+ function styleOnce(doc) {
206
+ if (doc.querySelector('#ab-style')) return;
207
+ const style = doc.createElement('style');
208
+ style.id = 'ab-style';
209
+ style.textContent = `
210
+ .ab-stage { position: fixed; inset: 0; z-index: 60; pointer-events: none; opacity: 0; overflow: hidden; }
211
+ .ab-sky { position: absolute; inset: 0; background: radial-gradient(75% 55% at 50% 18%, rgba(171,255,91,.18), rgba(4,12,6,.08) 50%, rgba(0,0,0,.18)); backdrop-filter: blur(1px) saturate(.82); opacity: 0; }
212
+ .ab-wrap { position: absolute; will-change: transform; }
213
+ .ab-svg { display: block; width: 100%; height: 100%; overflow: visible; filter: drop-shadow(0 28px 46px rgba(0,0,0,.42)) drop-shadow(0 0 18px rgba(158,226,63,.12)); }
214
+ .ab-vector { display: none; }
215
+ .ab-svg.missing-photo .ab-vector { display: inline; }
216
+ .ab-svg.missing-photo .ab-photos, .ab-svg.missing-photo .ab-live-lamps, .ab-svg.missing-photo .ab-live-emitter, .ab-svg.missing-photo .ab-glass-sheen { display: none; }
217
+ .ab-photo { opacity: 0; transform-origin: 50% 60%; }
218
+ .ab-photo[data-frame="idle"] { opacity: 1; }
219
+ .ab-live-lamps { filter: url(#ab-lamp-glow); mix-blend-mode: screen; }
220
+ .ab-glass-sheen { mix-blend-mode: screen; }
221
+ .ab-beam { position: absolute; transform-origin: 50% 0%; will-change: transform, opacity; }
222
+ .ab-mote { position: absolute; width: 7px; height: 7px; border-radius: 50%; background: #b7ec5a; box-shadow: 0 0 10px #9ee23f; will-change: transform, opacity; }
223
+ .ab-taken { will-change: transform, filter, opacity; }
224
+ @media (prefers-reduced-motion: reduce) { .ab-svg { filter: none; } }`;
225
+ doc.head.append(style);
226
+ }
227
+
228
+ export async function abduct({ composer, shell = document.querySelector('.shell'), doc = document, reduced = false } = {}) {
229
+ if (!composer) return;
230
+ // The asset lives on disk with the room. Warm it before the first frame so the ship never
231
+ // arrives as an empty glow on a cold cache; failure falls through to the vector below it.
232
+ await Promise.all(Object.values(SHIP_FRAMES).map((source) => new Promise((ready) => {
233
+ const preload = doc.createElement('img');
234
+ preload.src = source;
235
+ if (preload.complete) { ready(); return; }
236
+ preload.addEventListener('load', ready, { once: true });
237
+ preload.addEventListener('error', ready, { once: true });
238
+ })));
239
+ styleOnce(doc);
240
+ const box = composer.getBoundingClientRect();
241
+ const stage = doc.createElement('div');
242
+ stage.className = 'ab-stage';
243
+ const sky = doc.createElement('div');
244
+ sky.className = 'ab-sky';
245
+
246
+ const shipW = Math.min(760, Math.max(420, box.width * 0.62));
247
+ const scale = shipW / 900;
248
+ const shipH = 520 * scale;
249
+ const wrap = doc.createElement('div');
250
+ wrap.className = 'ab-wrap';
251
+ wrap.style.width = `${shipW}px`;
252
+ wrap.style.height = `${shipH}px`;
253
+ // Centred over the field, hovering a ship's height above it: the beam has to have somewhere to
254
+ // fall through, and the cat has to be able to see what it is taking.
255
+ const centreX = box.left + box.width / 2;
256
+ const bellyY = Math.max(96, box.top - shipH * 0.52);
257
+ wrap.style.left = `${centreX - shipW / 2}px`;
258
+ wrap.style.top = `${bellyY - 452 * scale}px`;
259
+ const svg = shipSVG(doc);
260
+ wrap.append(svg);
261
+
262
+ // The beam: a cone, widening from the emitter down to the field.
263
+ const beam = doc.createElement('div');
264
+ beam.className = 'ab-beam';
265
+ const beamH = Math.max(40, box.top + box.height / 2 - bellyY);
266
+ beam.style.left = `${centreX - box.width * 0.34}px`;
267
+ beam.style.top = `${bellyY}px`;
268
+ beam.style.width = `${box.width * 0.68}px`;
269
+ beam.style.height = `${beamH}px`;
270
+ beam.style.clipPath = 'polygon(42% 0%, 58% 0%, 100% 100%, 0% 100%)';
271
+ beam.style.background = 'linear-gradient(to bottom, rgba(217,255,138,.9), rgba(158,226,63,.34) 45%, rgba(122,196,31,.06))';
272
+ beam.style.opacity = '0';
273
+
274
+ stage.append(sky, beam, wrap);
275
+ doc.body.append(stage);
276
+ const lamps = growLamps(svg);
277
+ const lids = [...svg.querySelectorAll('.ab-lid')];
278
+ const emitter = svg.querySelector('.ab-live-emitter') ?? svg.querySelector('.ab-emitter');
279
+ const photos = [...svg.querySelectorAll('.ab-photo')];
280
+ const photoByFrame = new Map(photos.map((photo) => [photo.dataset.frame, photo]));
281
+ const sheen = svg.querySelector('.ab-glass-sheen');
282
+ composer.classList.add('ab-taken');
283
+
284
+ // Motes drifting up the beam, which is what sells a beam as a beam.
285
+ const motes = [];
286
+ const drift = () => {
287
+ const mote = doc.createElement('div');
288
+ mote.className = 'ab-mote';
289
+ const spread = box.width * 0.3;
290
+ mote.style.left = `${centreX + (Math.random() - 0.5) * spread}px`;
291
+ mote.style.top = `${box.top + box.height / 2}px`;
292
+ stage.append(mote);
293
+ mote.animate([
294
+ { transform: 'translate(0,0) scale(1)', opacity: .9 },
295
+ { transform: `translate(${(Math.random() - .5) * 40}px, ${-beamH}px) scale(.3)`, opacity: 0 },
296
+ ], { duration: 900 + Math.random() * 500, easing: 'cubic-bezier(.3,.1,.5,1)', fill: 'forwards' });
297
+ motes.push(mote);
298
+ };
299
+
300
+ const total = reduced ? 1500 : PHASES.done;
301
+ const started = performance.now();
302
+ let lastMote = 0;
303
+ let blinked = false;
304
+ let activeFrame = 'idle';
305
+
306
+ await new Promise((resolve) => {
307
+ const frame = (stamp) => {
308
+ const now = reduced ? ((stamp - started) / total) * PHASES.done : stamp - started;
309
+ stage.style.opacity = String(Math.min(1, span(now, 0, 160) + 0.001));
310
+ sky.style.opacity = String(0.9 * span(now, 80, PHASES.beam) * (1 - span(now, PHASES.leave, PHASES.done)));
311
+
312
+ // The ship drops in, holds while it works, then tips and goes.
313
+ const arrive = span(now, 0, PHASES.arrive);
314
+ const gone = span(now, PHASES.leave, PHASES.done);
315
+ const hover = Math.sin(now / 420) * 6 * (1 - gone);
316
+ wrap.style.transform = `translate(${260 * gone}px, ${-shipH * 1.6 * (1 - arrive) + hover - shipH * 2.2 * gone}px) rotate(${-2 + 4 * Math.sin(now / 700) - 16 * gone}deg)`;
317
+
318
+ // Rim lamps chase around the ring; the emitter breathes while the beam is on.
319
+ for (const [i, lamp] of lamps.entries()) {
320
+ const phase = (now / 720 - i / lamps.length) % 1;
321
+ const pulse = 0.5 + 0.5 * Math.sin(phase * Math.PI * 2);
322
+ lamp.setAttribute('opacity', String(0.18 + 0.82 * pulse));
323
+ lamp.setAttribute('rx', String(9 + 2.5 * pulse));
324
+ lamp.setAttribute('ry', String(6 + 1.5 * pulse));
325
+ }
326
+ const live = span(now, PHASES.arrive, PHASES.beam) * (1 - span(now, PHASES.swallow, PHASES.leave));
327
+ if (emitter) emitter.setAttribute('opacity', String(0.22 + 0.7 * live * (0.78 + 0.22 * Math.sin(now / 95))));
328
+ if (sheen) sheen.setAttribute('opacity', String(0.05 + 0.08 * (0.5 + 0.5 * Math.sin(now / 480))));
329
+
330
+ // The beam opens, then shuts once the field is aboard.
331
+ beam.style.opacity = String(live * (0.75 + 0.25 * Math.sin(now / 90)));
332
+ beam.style.transform = `scaleX(${0.2 + 0.8 * live}) scaleY(${0.3 + 0.7 * live})`;
333
+
334
+ // The photographic performance: a quick blink, then two small cockpit actions. Hard cuts
335
+ // keep the registered hull crisp; only the living passenger changes between frames.
336
+ let wantedFrame = 'idle';
337
+ if (now >= 1180 && now < 1300) wantedFrame = 'blink';
338
+ else if (now >= 1460 && now < 1540) wantedFrame = 'blink';
339
+ else if (now >= 1680 && now < 1980) wantedFrame = 'controlLeft';
340
+ else if (now >= 1980 && now < 2280) wantedFrame = 'controlRight';
341
+ else if (now >= 2280 && now < 2400) wantedFrame = 'controlLeft';
342
+ if (!photoByFrame.has(wantedFrame)) wantedFrame = 'idle';
343
+ if (wantedFrame !== activeFrame) {
344
+ for (const photo of photos) photo.style.opacity = photo.dataset.frame === wantedFrame ? '1' : '0';
345
+ activeFrame = wantedFrame;
346
+ }
347
+
348
+ // The vector fallback still gets its own blink.
349
+ const blink = Math.max(0, Math.sin((now - PHASES.beam - 150) / 90));
350
+ const lidOn = now > PHASES.beam + 150 && now < PHASES.beam + 430;
351
+ for (const lid of lids) lid.setAttribute('opacity', String(lidOn ? blink : 0));
352
+ if (!blinked && lidOn) blinked = true;
353
+ // It leans toward what it is taking.
354
+ for (const photo of photos) photo.setAttribute('transform', `translate(0 ${3 * live}) scale(${1 + 0.004 * live})`);
355
+
356
+ // The field goes up. It wobbles on the way, because nothing rides a tractor beam steadily.
357
+ const rise = span(now, PHASES.beam, PHASES.lift);
358
+ const eaten = span(now, PHASES.lift, PHASES.swallow);
359
+ const backHome = span(now, PHASES.leave, PHASES.done);
360
+ const climbed = (box.top + box.height / 2 - bellyY) * rise;
361
+ const shrink = (1 - 0.72 * rise) * (1 - eaten) * bounce(rise);
362
+ if (backHome > 0) {
363
+ composer.style.transform = '';
364
+ composer.style.opacity = '';
365
+ composer.style.filter = '';
366
+ } else {
367
+ composer.style.transform = `translateY(${-climbed}px) scale(${Math.max(0.001, shrink)}) rotate(${Math.sin(now / 130) * 3 * rise}deg)`;
368
+ composer.style.opacity = String(1 - eaten);
369
+ composer.style.filter = `drop-shadow(0 0 ${18 * rise}px rgba(158,226,63,.75)) brightness(${1 + 0.35 * rise})`;
370
+ }
371
+
372
+ if (!reduced && live > 0.4 && now - lastMote > 70 && eaten < 1) { lastMote = now; drift(); }
373
+ if (now < total) { requestAnimationFrame(frame); return; }
374
+ resolve();
375
+ };
376
+ requestAnimationFrame(frame);
377
+ });
378
+
379
+ // Nothing of this survives: the field is exactly as it was before the word was typed.
380
+ for (const mote of motes) mote.remove();
381
+ stage.remove();
382
+ composer.classList.remove('ab-taken');
383
+ for (const property of ['transform', 'opacity', 'filter']) composer.style[property] = '';
384
+ }