feat(scene): `portal` — la presencia leída como esfera a contraluz (variante)

Encargo del usuario a partir de un componente de referencia de pago cuya
fuente NO está disponible (la pestaña «Code» es de clientes). Así que
implementación propia — que es la regla de la casa de todos modos: solo el
vocabulario de parámetros se dejó cercano para poder compararlos.

QUÉ ES
Una esfera a contraluz: filo encendido, interior abierto, y capas de estelas
que la envuelven y giran — cada una sobre su eje inclinado, con su ritmo, con
motion blur a lo largo del barrido. El fragmento se mapea sobre la esfera
unidad y las estelas viven en ese espacio 3D, que es lo que las hace curvarse
con la superficie en vez de leerse como rayas planas.

ES UNA VARIANTE, LA ELIGE EL DESARROLLADOR
No sustituye al `orb`: conviven. `orb` está afinado para 32-48px (silueta
simple, movimiento perceptible a ese tamaño); `portal` tiene mucho más detalle
y luce en superficies grandes — a tamaño de componente pierde justo la
irregularidad que lo define. La demo gana un switch **studio** (220px) para
poder juzgar eso, porque un shader no se juzga a 40px y forzarlo desde la
consola no deja nada para la sesión siguiente.

EL INTERIOR ES TRANSPARENTE
No se pinta nada dentro: lo que se ve a través es la página. Eso es lo que
hace la variante agnóstica al tema SIN una sola rama claro/oscuro — un
interior relleno tendría que elegir bando, y elegirlo fue lo que en el primer
intento la convirtió en una bola de cristal. Los tres colores salen de los
mismos slots de rol que el orb (`solid` en el filo, `solid-hover` en la cara
cercana, `element` en la que se aleja): retintas el tema y cambia entera.

TRES LECCIONES DEL FORMATO, no de este efecto — anotadas en el README porque
las va a repetir quien toque el siguiente shader:
- Las estelas no pueden salir de senos. Un `sin()` da anillos de latitud
  PERFECTAMENTE regulares que se leen como jaula de alambre por mucho que se
  afinen; la irregularidad (longitudes distintas, trazos sueltos) exige ruido
  anisótropo — aquí 1 : 9 en longitud/latitud, lento a favor del flujo y
  rápido a través.
- `sin(q.y * freq)` sobre la esfera unidad da `freq/π` ciclos: ~9 son tres
  trazos finos por capa, 22 es malla, 2 es un borrón. El número sale de la
  cuenta, no del tanteo — tanteando me pasé de largo en ambos sentidos.
- El motion blur PROMEDIA: 5 muestras × 0.16 rad son 46° de barrido, que borra
  las bandas en vez de estirarlas.

Verificado en Chrome real, claro y oscuro, en `acting` y `escalated`, a 48px y
en studio. Tests 370 (scene 9 + eidos 361) · eidos-lint aura 0 inválidos ·
docs:check sin errores nuevos · 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 aad0155d55
commit 19e8dbfb63

@ -0,0 +1,241 @@
/**
* Portal — a backlit sphere with streaks of light sweeping around it.
*
* The look: a bright rim (the sphere read contre-jour), a dark interior, and
* layers of thin curved streaks that wrap the surface and rotate — each layer
* on its own tilted axis, at its own rate, motion-blurred along the sweep.
*
* It is a SPHERE, not a disc of rings: the fragment is mapped onto the unit
* sphere and the streaks live in that 3D space, which is what makes them bend
* with the curvature instead of reading as flat stripes. And the strokes come
* from ANISOTROPIC noise in the sphere's longitude/latitude — slow along the
* flow, fast across it — not from bands: that ratio is what gives them
* irregular lengths and stray short dashes. Sine bands were the first attempt
* and cannot get there; perfectly regular rings read as a wire cage however
* finely they are tuned.
*
* Written for the agent presence surface, so two things are non-negotiable:
* the interior stays open (the effect owns its alpha — what shows through is
* the page, never a painted background), and the three colours arrive as
* concrete hex, resolved by the consumer from its semantic role tokens. A
* theme re-tints the portal without touching this file.
*
* Not a port: the reference for this look is a paid component whose source is
* not available, and the framework implements its own anyway.
*/
import type { SceneApi, WebglSceneEffect } from '../types';
import { hexToRgb01 } from './color';
/** Streak layers the shader unrolls; `layers` selects how many are lit. */
const MAX_LAYERS = 7;
export interface PortalParams {
/** The rim — the lit edge of the sphere (hex). */
colorPrimary: string;
/** The streaks sweeping across the surface (hex). */
colorSecondary: string;
/** The interior the streaks travel over (hex). */
colorDeep: string;
/** Sweep rate. `0` freezes the sphere without emptying it. */
speed: number;
/** How many streak layers are lit (1–7). Each has its own axis and rate. */
layers: number;
/** Streaks per layer — higher packs more, thinner. */
density: number;
/** Width of the lit rim, `0` → `1`. The interior stays open at any value. */
thickness: number;
/** Length of the motion-blur smear along the sweep. */
blur: number;
/** Overall gain. */
brightness: number;
}
interface PortalState {
colorPrimary: WebGLUniformLocation | null;
colorSecondary: WebGLUniformLocation | null;
colorDeep: WebGLUniformLocation | null;
phase: WebGLUniformLocation | null;
layers: WebGLUniformLocation | null;
density: WebGLUniformLocation | null;
thickness: WebGLUniformLocation | null;
blur: WebGLUniformLocation | null;
brightness: WebGLUniformLocation | null;
cp: Float32Array;
cpKey: string;
cs: Float32Array;
csKey: string;
cd: Float32Array;
cdKey: string;
/** Phase integrated from `speed`, so a rate change never jumps the sweep. */
phaseValue: number;
}
const FRAG = `precision highp float;
uniform float uTime;
uniform vec3 uResolution;
uniform float uPhase;
uniform vec3 colorPrimary;
uniform vec3 colorSecondary;
uniform vec3 colorDeep;
uniform float layers;
uniform float density;
uniform float thickness;
uniform float blur;
uniform float brightness;
vec3 hash33(vec3 p3) { p3 = fract(p3 * vec3(0.1031, 0.11369, 0.13787)); p3 += dot(p3, p3.yxz + 19.19); return -1.0 + 2.0 * fract(vec3(p3.x + p3.y, p3.x + p3.z, p3.y + p3.z) * p3.zyx); }
float snoise3(vec3 p) { const float K1 = 0.333333333; const float K2 = 0.166666667; vec3 i = floor(p + (p.x + p.y + p.z) * K1); vec3 d0 = p - (i - (i.x + i.y + i.z) * K2); vec3 e = step(vec3(0.0), d0 - d0.yzx); vec3 i1 = e * (1.0 - e.zxy); vec3 i2 = 1.0 - e.zxy * (1.0 - e); vec3 d1 = d0 - (i1 - K2); vec3 d2 = d0 - (i2 - K1); vec3 d3 = d0 - 0.5; vec4 h = max(0.6 - vec4(dot(d0, d0), dot(d1, d1), dot(d2, d2), dot(d3, d3)), 0.0); vec4 n = h * h * h * h * vec4(dot(d0, hash33(i)), dot(d1, hash33(i + i1)), dot(d2, hash33(i + i2)), dot(d3, hash33(i + 1.0))); return dot(vec4(31.316), n); }
vec3 rotY(vec3 v, float a) {
float s = sin(a), c = cos(a);
return vec3(c * v.x + s * v.z, v.y, -s * v.x + c * v.z);
}
vec3 rotX(vec3 v, float a) {
float s = sin(a), c = cos(a);
return vec3(v.x, c * v.y - s * v.z, s * v.y + c * v.z);
}
// One layer of streaks. The shape comes from ANISOTROPIC noise sampled in the
// sphere's own longitude/latitude: slow along the flow, fast across it. That
// ratio IS the streak — long, thin, and irregular, with the varying lengths
// and stray short dashes that a sine band can never produce (a sine gives
// perfectly regular latitude rings, which read as a wire cage).
float layerStreaks(vec3 p, float i) {
float tilt = 0.30 + i * 0.44;
float rate = 0.55 + i * 0.21;
float acc = 0.0;
for (int k = 0; k < 4; k++) {
// A few taps along the sweep, averaged: that average is the motion blur,
// and it is what smears each stroke into a trail.
float t = uPhase * rate + float(k) * blur * 0.03;
vec3 q = rotY(rotX(p, tilt), t);
float lon = atan(q.z, q.x);
float lat = q.y;
// 1 : 9 anisotropy — the streak runs with the flow and is cut across it.
float n = snoise3(vec3(lon * 1.1, lat * 9.0 * density, i * 11.0)) * 0.5 + 0.5;
// High power keeps only the crests: everything between two strokes falls
// to zero, which is what leaves the sphere open.
acc += pow(n, 13.0);
}
return acc * 0.25;
}
void main() {
vec2 center = uResolution.xy * 0.5;
float size = min(uResolution.x, uResolution.y);
vec2 uv = (gl_FragCoord.xy - center) / size * 2.0;
float r = length(uv);
// Outside the sphere there is nothing: the disc edge IS the silhouette.
float disc = 1.0 - smoothstep(0.97, 1.0, r);
if (disc <= 0.0) {
gl_FragColor = vec4(0.0);
return;
}
// Map the fragment onto the unit sphere; the streaks live in THIS space, so
// they bend with the curvature instead of reading as flat stripes.
float z = sqrt(max(1.0 - r * r, 0.0));
vec3 p = vec3(uv, z);
float streaks = 0.0;
for (int i = 0; i < 7; i++) {
float fi = float(i);
// Fixed bounds (GLSL ES 1.0); the mask lights only the layers asked for,
// so the count stays animatable without recompiling the program.
streaks += layerStreaks(p, fi) * step(fi, layers - 0.5);
}
streaks = clamp(streaks / max(layers, 1.0) * 9.0, 0.0, 1.0);
// The limb crowds: near the silhouette the surface turns away, so strokes
// pile up there exactly as they do on a real sphere.
streaks *= mix(1.0, 1.5, 1.0 - z);
// The rim: a thin lit edge, not a halo — it is the sphere read against the
// light, and it must not drown the strokes.
float rimStart = mix(0.985, 0.90, clamp(thickness, 0.0, 1.0));
float rim = pow(smoothstep(rimStart, 0.998, r), 1.4);
// NOTHING is painted into the interior: it is TRANSPARENT, so what shows
// through the sphere is the page. That is what makes the effect
// theme-agnostic without a single light/dark branch.
// All three colours are the state's ROLE slots: the far side recedes into
// the recessive step, the near side carries the solid, the rim is the edge.
vec3 col = mix(colorDeep, colorSecondary, z);
col = mix(col, colorPrimary, rim);
// Only light contributes opacity. No stroke and no rim, nothing at all.
float alpha = clamp(streaks + rim, 0.0, 1.0) * brightness * disc;
gl_FragColor = vec4(col * alpha, alpha);
}`;
export const portalEffect: WebglSceneEffect<PortalParams, PortalState> = {
name: 'portal',
driver: 'webgl',
// A frozen sweep is still a lit sphere: presence survives the freeze.
reduce: 'static-frame',
defaults: {
colorPrimary: '#c084fc',
colorSecondary: '#e879f9',
colorDeep: '#0a0a0f',
speed: 1,
layers: 5,
density: 1,
thickness: 0.5,
blur: 1,
brightness: 1
},
fragmentShader: () => FRAG,
setup(gl, program) {
return {
colorPrimary: gl.getUniformLocation(program, 'colorPrimary'),
colorSecondary: gl.getUniformLocation(program, 'colorSecondary'),
colorDeep: gl.getUniformLocation(program, 'colorDeep'),
phase: gl.getUniformLocation(program, 'uPhase'),
layers: gl.getUniformLocation(program, 'layers'),
density: gl.getUniformLocation(program, 'density'),
thickness: gl.getUniformLocation(program, 'thickness'),
blur: gl.getUniformLocation(program, 'blur'),
brightness: gl.getUniformLocation(program, 'brightness'),
cp: new Float32Array(3),
cpKey: '',
cs: new Float32Array(3),
csKey: '',
cd: new Float32Array(3),
cdKey: '',
phaseValue: 0
};
},
update(gl, state, api: SceneApi<PortalParams>) {
const p = api.params;
if (p.colorPrimary !== state.cpKey) {
state.cpKey = p.colorPrimary;
state.cp.set(hexToRgb01(p.colorPrimary));
}
if (p.colorSecondary !== state.csKey) {
state.csKey = p.colorSecondary;
state.cs.set(hexToRgb01(p.colorSecondary));
}
if (p.colorDeep !== state.cdKey) {
state.cdKey = p.colorDeep;
state.cd.set(hexToRgb01(p.colorDeep));
}
// Integrate the rate: a custody transition changes `speed`, and the
// sweep must not jump when it does.
state.phaseValue += api.dt * p.speed;
if (state.phase) gl.uniform1f(state.phase, state.phaseValue);
if (state.layers) gl.uniform1f(state.layers, p.layers);
if (state.density) gl.uniform1f(state.density, p.density);
if (state.thickness) gl.uniform1f(state.thickness, p.thickness);
if (state.blur) gl.uniform1f(state.blur, p.blur);
if (state.brightness) gl.uniform1f(state.brightness, p.brightness);
if (state.colorPrimary) {
gl.uniform3f(state.colorPrimary, state.cp[0], state.cp[1], state.cp[2]);
}
if (state.colorSecondary) {
gl.uniform3f(state.colorSecondary, state.cs[0], state.cs[1], state.cs[2]);
}
if (state.colorDeep) gl.uniform3f(state.colorDeep, state.cd[0], state.cd[1], state.cd[2]);
}
};

@ -86,6 +86,43 @@ la presencia (`static-frame`, nunca `hide`), y una salida global por teclado.
distinguiendo el estado. El atributo va en los hijos del SVG, jamás en la
parte — `motionAttrs()` estampa además `data-state`, que en ese subárbol es
del morfo.
- **Variante `portal` (2026-07-28, encargo del usuario)**: el mismo ciclo
leído como una ESFERA a contraluz — estelas de luz que la envuelven y giran,
cada capa sobre su eje inclinado y con su ritmo, con motion blur a lo largo
del barrido. **La elige el desarrollador**: no sustituye al `orb`, convive
con él. `orb` está afinado para 32–48px (silueta simple, movimiento
perceptible a ese tamaño); `portal` tiene mucho más detalle y luce en
superficies grandes — a tamaño de componente pierde la irregularidad que lo
define. La demo trae un switch **studio** (220px) precisamente para poder
juzgar eso.
Implementación propia (`$scene/effects/portal`), no un port: la referencia
del look es un componente de pago cuya fuente no está disponible, y la regla
de la casa son implementaciones propias de todos modos.
Los tres colores salen de los MISMOS slots de rol que el orb —
`solid` en el filo, `solid-hover` en la cara cercana, `element` en la que se
aleja — así que un tema los retinta a la vez. **El interior es TRANSPARENTE**:
no se pinta nada dentro, así que lo que se ve a través es la página. Eso es
lo que hace la variante agnóstica al tema sin una sola rama claro/oscuro — un
interior relleno tendría que elegir bando, y elegirlo fue lo que en el primer
intento la convirtió en una bola de cristal.
Modulación §F6 propia: `speed` = ritmo del barrido (pitch), `layers` =
cuántas capas vuelan, `density` = cuán apretadas, `brightness` = gain;
`escalated` gana capas Y corre caliente — forma y energía juntas, nunca color
solo (AG-44).
Tres lecciones que costaron render, anotadas porque son del FORMATO y no de
este efecto: (1) las estelas no pueden salir de senos — un `sin()` da anillos
de latitud perfectamente regulares que se leen como jaula de alambre por
mucho que se afinen; la irregularidad exige ruido anisótropo (aquí 1 : 9 en
longitud/latitud). (2) `sin(q.y * freq)` sobre la esfera unidad da `freq/π`
ciclos: ~9 son tres trazos finos por capa, 22 es malla y 2 es un borrón — el
número sale de la cuenta, no del tanteo. (3) el motion blur promedia: 5
muestras × 0.16 rad son 46° de barrido, que borra las bandas en vez de
estirarlas.
- **Variante `orb` por defecto (2026-07-22, decisión del usuario)**: el
render vivo del indicator es el efecto `orb` de `$scene` — presencia
esférica con anillo respirando (ruido) + luz orbitando; movimiento de

@ -34,6 +34,7 @@
import { AuraProvider, type AuraState } from '$soma/components/aura';
import { auroraEffect, type AuroraParams } from '$scene/effects/aurora';
import { orbEffect, type OrbParams } from '$scene/effects/orb';
import { portalEffect, type PortalParams } from '$scene/effects/portal';
import type { SceneHandle } from '$scene';
import type { AuraIndicatorProps } from './types';
@ -107,6 +108,25 @@
};
}
/**
* Portal-variant §F6 table. The portal expresses the cycle on the sweep:
* `speed` is how fast the streaks travel (pitch), `layers` how many are in
* flight, `density` how packed they are, `brightness` the gain. `escalated`
* gains layers AND runs hot — shape and energy together, never colour alone
* (AG-44). `idle` keeps a slow single sweep: presence recedes, never stops.
*/
const PORTAL: Record<
AuraState,
{ speed: number; layers: number; density: number; brightness: number }
> = {
idle: { speed: 0.4, layers: 2, density: 0.9, brightness: 0.7 },
offered: { speed: 1, layers: 3, density: 1, brightness: 0.95 },
planned: { speed: 0.85, layers: 3, density: 1.05, brightness: 0.9 },
reviewing: { speed: 0.6, layers: 2, density: 1, brightness: 1 },
acting: { speed: 2, layers: 4, density: 1.15, brightness: 1.1 },
escalated: { speed: 2.9, layers: 5, density: 1.3, brightness: 1.25 }
};
/**
* Rim width. The orb is a CONTOUR — the core stays hollow; this widens the
* band inward. The effect's default is the seed's hairline rim, which at
@ -114,7 +134,20 @@
*/
const ORB_THICKNESS = 0.9;
function paramsFor(state: AuraState): Partial<AuroraParams> | Partial<OrbParams> {
function paramsFor(
state: AuraState
): Partial<AuroraParams> | Partial<OrbParams> | Partial<PortalParams> {
if (variant === 'portal') {
const energy = PORTAL[state];
const { colorA, colorB, colorDeep } = orbColorsFor(state);
return {
colorPrimary: colorA,
colorSecondary: colorB,
colorDeep,
thickness: ORB_THICKNESS,
...energy
};
}
if (variant === 'orb') {
const energy = ORB[state];
// The body is PAINTED in the role's colours, never hue-rotated: the
@ -160,11 +193,17 @@
auroraEffect,
untrack(() => params) as AuroraParams
) as SceneHandle<AuroraParams>)
: (engine.mount(
node,
orbEffect,
untrack(() => params) as OrbParams
) as SceneHandle<OrbParams>);
: variant === 'portal'
? (engine.mount(
node,
portalEffect,
untrack(() => params) as PortalParams
) as SceneHandle<PortalParams>)
: (engine.mount(
node,
orbEffect,
untrack(() => params) as OrbParams
) as SceneHandle<OrbParams>);
handle = mounted;
return () => {
handle = null;

@ -38,11 +38,13 @@ export type AuraIndicatorProps = IndicatorProps & {
/**
* The living render. `orb` (default) is the living ring presence —
* breathing radius + an orbiting light, motion that stays perceptible
* at small orb sizes (and the pointer distorts it); `aurora` is the
* flowing field, better on larger surfaces.
* at small orb sizes (and the pointer distorts it); `portal` is the same
* presence read as DEPTH — concentric layers receding into the open
* throat; `aurora` is the flowing field, better on larger surfaces.
* All three take their colour from the state's role tokens.
* @default 'orb'
*/
variant?: 'orb' | 'aurora';
variant?: 'orb' | 'portal' | 'aurora';
};
/**

@ -104,8 +104,17 @@
// ── Live controls ────────────────────────────────────────────────────
let size = $state<AuraSize>('md');
let ringOn = $state(true);
// Studio: a shader cannot be judged at 40px. Off by default — the real
// sizes are what ships.
let studio = $state(false);
const studioStyle = $derived(
'display: grid; gap: var(--uix-space-4); justify-items: center;' +
(studio
? ' --aura-orb-size-sm: 220px; --aura-orb-size-md: 220px; --aura-orb-size-lg: 220px;'
: '')
);
let effectOn = $state(true);
let orbVariant = $state<'orb' | 'aurora'>('orb');
let orbVariant = $state<'orb' | 'portal' | 'aurora'>('orb');
let autonomy = $state<AgentAutonomy>('auto');
let initiative = $state<AgentInitiative>('user');
@ -215,7 +224,10 @@
{dir}
style={`--scaling: ${scaling}; --border-width: ${borderWidth}px;`}
>
<div style="display: grid; gap: var(--uix-space-4); justify-items: center;">
<!-- Studio overrides the three orb-size tokens: a shader cannot be
judged at 40px, and forcing it from the console leaves nothing
behind for the next session. -->
<div style={studioStyle}>
<Aura {agent} {size}>
{#if ringOn}
<Aura.Ring>
@ -391,15 +403,23 @@
<label data-uix-control>
<span data-uix-control-label>orb variant ($scene)</span>
<span data-uix-chips role="radiogroup">
{#each ['orb', 'aurora'] as v}
{#each ['orb', 'portal', 'aurora'] as v}
<button
data-uix-chip
data-active={orbVariant === v}
onclick={() => (orbVariant = v as 'orb' | 'aurora')}>{v}</button
onclick={() => (orbVariant = v as 'orb' | 'portal' | 'aurora')}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>studio (judge the shader)</span>
<span data-uix-switch
><input type="checkbox" bind:checked={studio} /><span data-uix-switch-label
>{studio ? '220px' : 'real size'}</span
></span
>
</label>
<label data-uix-control>
<span data-uix-control-label>orb effect ($scene)</span>
<span data-uix-switch

Loading…
Cancel
Save

Powered by TurnKey Linux.