Skip to content
Brok UI

Loading…

No results

Shaders

Open source

forgeui.in-style WebGL shader backdrops — full-bleed animated gradient and fluid fields rendered on a canvas.

Version
v1.0.1
Stability
stable
License
MIT
Related
Backdrop
Glow

Shader — forgeui.in-style WebGL shader backdrop with seven presets (auralis, chromatic, cloudscape, cosmic-rift, luma-drift, velaris, velora). Colour-capable presets derive their default palette from theme tokens at runtime. Fills its positioned parent; reduced motion renders a single static frame.

Preview

previews.components.shader.default.blade.php Blade
<div class="relative h-64 w-full max-w-full overflow-hidden rounded-lg border border-border bg-card" style="width: 34rem">
    <x-ui.shader preset="auralis" />
</div>

Installation

terminal
php artisan ui:add shader

Note

This component ships an Alpine behavior module at resources/js/ui/shader.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './shader.js';

Registry contract

php artisan ui:add shader writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/shader.blade.php
  • js resources/js/ui/shader-auralis.js
  • js resources/js/ui/shader-chromatic.js
  • js resources/js/ui/shader-cloudscape.js
  • js resources/js/ui/shader-cosmic-rift.js
  • js resources/js/ui/shader-luma-drift.js
  • js resources/js/ui/shader-velaris.js
  • js resources/js/ui/shader-velora.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

shader.md
# Brok UI: Shader (`shader`)

forgeui.in-style WebGL shader backdrop with seven presets (auralis, chromatic, cloudscape, cosmic-rift, luma-drift, velaris, velora). Colour-capable presets derive their default palette from theme tokens at runtime. Fills its positioned parent; reduced motion renders a single static frame.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add shader
```

## Usage

```blade
<div class="relative h-64 w-full max-w-full overflow-hidden rounded-lg border border-border bg-card" style="width: 34rem">
    <x-ui.shader preset="auralis" />
</div>
```

## Props

- `preset` (auralis|chromatic|cloudscape|cosmic-rift|luma-drift|velaris|velora, default `auralis`) — Selects the WebGL effect and its behaviour module. An unknown value throws.
- `speed` (float, default `null`) — Animation speed multiplier (higher = faster). ~0 is nearly still.
- `grain` (float, default `null`) — Film-grain intensity (0..1). Supported by auralis, chromatic, velaris and velora.
- `colors` (string[], default `null`) — auralis: up to three hex glow stops (only the first two are rendered).
- `bg` (string, default `null`) — velaris: base backdrop hex.
- `flowStrength` (float, default `null`) — chromatic: distortion strength of the fluid flow. velora: streak/warp strength.
- `contrast` (float, default `null`) — chromatic: specular contrast of the metallic sheen.
- `colorBottom` (string, default `null`) — cloudscape, velora: the lower gradient stop.
- `colorMid` (string, default `null`) — cloudscape, velora: the middle gradient stop.
- `colorTop` (string, default `null`) — cloudscape, velora: the upper gradient stop.
- `colorBackground` (string, default `null`) — cosmic-rift: the void background tint.
- `colorStars` (string, default `null`) — cosmic-rift: the star tint.
- `density` (float, default `null`) — cosmic-rift: star-field density multiplier.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a decorative, full-bleed animated WebGL background behind a hero or other relative section.
- Switching between a set of dramatic shader presets (glow, metallic sheen, sky bands, starfield, aurora, lava-lamp glow, warp tunnel) without shipping seven separate components.

## Avoid when

- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- The background needs to carry real content or be perceivable to assistive tech; this layer is always aria-hidden.
- A lighter, CPU-only decorative layer is enough; compose backdrop's grid/glow/dots variants or dot-pattern/grid-pattern instead.

## Anti-patterns

- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus

## Rules

- Use the `<brok:shader>` tag (or `<x-ui.shader>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/shader
- Registry JSON (files, props, contract): https://brokui.dev/r/open/shader.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
preset auralis | chromatic | cloudscape | cosmic-rift | luma-drift | velaris | velora auralis Selects the WebGL effect and its behaviour module. An unknown value throws.
speed float null Animation speed multiplier (higher = faster). ~0 is nearly still.
grain float null Film-grain intensity (0..1). Supported by auralis, chromatic, velaris and velora.
colors string[] null auralis: up to three hex glow stops (only the first two are rendered).
bg string null velaris: base backdrop hex.
flowStrength float null chromatic: distortion strength of the fluid flow. velora: streak/warp strength.
contrast float null chromatic: specular contrast of the metallic sheen.
colorBottom string null cloudscape, velora: the lower gradient stop.
colorMid string null cloudscape, velora: the middle gradient stop.
colorTop string null cloudscape, velora: the upper gradient stop.
colorBackground string null cosmic-rift: the void background tint.
colorStars string null cosmic-rift: the star tint.
density float null cosmic-rift: star-field density multiplier.

Slots

  • default — Primary Blade slot rendered by the component.

Data slots

Stable hooks for CSS overrides and browser tests.

{{ $slotName }}

Behavior

  • Always aria-hidden and pointer-events-none; purely decorative.
  • Reduced motion renders exactly one static frame per preset and never starts the requestAnimationFrame loop.
  • Colour-capable presets (auralis, cloudscape, cosmic-rift, velaris, velora) read their default stops from live CSS custom properties (getComputedStyle) so the layer follows the active theme; a hex prop still overrides any stop.
  • Pauses its render loop via IntersectionObserver while scrolled out of view, and rebuilds/loses the WebGL context cleanly on tab suspend/restore.
  • destroy() cancels the animation frame, disconnects observers and frees the GL program so unmounting mid-animation cannot leak.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, reducedMotion.

Guidance

Expressive and showcase effect

Support brand expression or a showcase moment.

Use when

  • Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
  • Adding a decorative, full-bleed animated WebGL background behind a hero or other relative section.
  • Switching between a set of dramatic shader presets (glow, metallic sheen, sky bands, starfield, aurora, lava-lamp glow, warp tunnel) without shipping seven separate components.

Avoid when

  • Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
  • The background needs to carry real content or be perceivable to assistive tech; this layer is always aria-hidden.
  • A lighter, CPU-only decorative layer is enough; compose backdrop's grid/glow/dots variants or dot-pattern/grid-pattern instead.

Use instead

  • Static content-first presentation

Anti-patterns

  • Heavy effects in forms or task-dense workflows
  • Motion that interferes with reading or focus
Anatomy
root (aria-hidden, absolutely positioned) canvas (WebGL surface, one preset's behaviour module attached via x-data)
Theming hooks
shader

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The root is aria-hidden; never place content that needs to be perceivable inside it.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<div wire:ignore>
    <div class="relative h-64 w-full max-w-full overflow-hidden rounded-lg border border-border bg-card" style="width: 34rem">
        <x-ui.shader preset="auralis" />
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/shader.blade.php Blade
@props([
    // Which WebGL effect to render. Each preset ships its own behaviour
    // module (`shader-<preset>.js`); ui:add copies every module, so a
    // consumer can switch presets without re-installing.
    'preset' => 'auralis',
    // Animation speed (higher = faster). ~0 is nearly still. Meaning and
    // default vary per preset — see each module's doc comment.
    'speed' => null,
    // Film-grain intensity (0..1). Supported by auralis, chromatic, velaris
    // and velora.
    'grain' => null,
    // auralis: up to three hex glow stops (colors[0]/[1] are the ones the
    // shader actually reads). Defaults to theme-derived tones.
    'colors' => null,
    // velaris: base backdrop hex. Defaults to --background.
    'bg' => null,
    // chromatic: distortion strength of the fluid flow.
    'flowStrength' => null,
    // chromatic: specular contrast of the metallic sheen. velora reuses
    // `flowStrength` for its streak/warp strength.
    'contrast' => null,
    // cloudscape, velora: three hex gradient stops. Default to theme-derived
    // tones (cloudscape: primary/accent/background; velora:
    // background/primary/accent).
    'colorBottom' => null,
    'colorMid' => null,
    'colorTop' => null,
    // cosmic-rift: background + star hex tints. Default to
    // --background / --foreground.
    'colorBackground' => null,
    'colorStars' => null,
    // cosmic-rift: star-field density multiplier.
    'density' => null,
])

@php
    // Keep in sync with the shader-<preset>.js modules shipped alongside
    // this file and with families.php's `shader` member/knowledge props.
    $shaderPresets = ['auralis', 'chromatic', 'cloudscape', 'cosmic-rift', 'luma-drift', 'velaris', 'velora'];

    if (! in_array($preset, $shaderPresets, true)) {
        throw new \InvalidArgumentException(
            "Unknown shader preset [{$preset}]. Expected one of: ".implode(', ', $shaderPresets).'.'
        );
    }

    // auralis -> uiShaderAuralis, cosmic-rift -> uiShaderCosmicRift, …
    $dataFn = 'uiShader'.implode('', array_map('ucfirst', explode('-', $preset)));

    $config = [];
    foreach ([
        'speed' => $speed !== null ? (float) $speed : null,
        'grain' => $grain !== null ? (float) $grain : null,
        'flowStrength' => $flowStrength !== null ? (float) $flowStrength : null,
        'contrast' => $contrast !== null ? (float) $contrast : null,
        'density' => $density !== null ? (float) $density : null,
        'bg' => $bg,
        'colorBottom' => $colorBottom,
        'colorMid' => $colorMid,
        'colorTop' => $colorTop,
        'colorBackground' => $colorBackground,
        'colorStars' => $colorStars,
    ] as $key => $value) {
        if ($value !== null) {
            $config[$key] = $value;
        }
    }
    if (is_array($colors) && $colors !== []) {
        $config['colors'] = array_values($colors);
    }
@endphp

{{--
    Shader: a decorative WebGL background (forgeui.in-style presets). The
    host is aria-hidden + pointer-events-none so it never traps focus or
    blocks the content layered above it. Presets that expose a colour config
    derive their default palette from theme tokens (--primary/--accent/
    --background/--foreground) at runtime; reduced motion paints a single
    static frame.

    A deprecated alias built on this primitive (auralis, cloudscape, …)
    passes its own data-slot; the browser keeps only the first of two
    identical attributes, so read the override and print exactly one.
--}}
@php
    $slotName = (string) ($attributes->get('data-slot') ?: 'shader');
@endphp

<div
    data-slot="{{ $slotName }}"
    data-preset="{{ $preset }}"
    x-data="{{ $dataFn }}(@js($config))"
    x-ref="root"
    aria-hidden="true"
    {{ $attributes->except('data-slot')->merge(['class' => 'pointer-events-none absolute inset-0 overflow-hidden bg-background']) }}
>
    <canvas x-ref="canvas" class="block h-full w-full"></canvas>
</div>
resources/js/ui/shader-auralis.js JS
/**
 * Auralis shader preset behaviour (WebGL-powered animated ambiance).
 *
 * A single full-bleed <canvas> renders layered simplex-noise light fields with a
 * vignette and subtle film grain, producing a slow, dramatic glow. The effect is
 * pure WebGL (vertex + fragment shader on a screen-filling triangle strip); no
 * library. The host is decorative (aria-hidden / pointer-events-none, set in
 * Blade) so it never traps focus or blocks the content layered above it.
 *
 * COLOUR: the glow stops default to the theme's `--primary` / `--accent` /
 * `--background` tokens, resolved to WebGL rgb via `tokenRgb()` (a probe
 * element + getComputedStyle, same technique as the `backdrop` gradient
 * variant). A consumer may still override with hex via the `colors` prop; the
 * fixed ember floats below are only the fallback before tokens resolve.
 *
 * PERFORMANCE: device-pixel ratio is capped and the backing store is rebuilt
 * only when a ResizeObserver fires. Reduced motion paints a single static frame
 * and never starts the rAF loop. destroy() cancels the loop, disconnects the
 * observer and frees the GL program so unmounting mid-animation cannot leak.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    const VERT = `
attribute vec2 position;
varying vec2 vUv;
void main() {
  vUv = position * 0.5 + 0.5;
  gl_Position = vec4(position, 0.0, 1.0);
}`;

    const FRAG = `
precision highp float;
varying vec2 vUv;

uniform vec2  u_resolution;
uniform float u_time;
uniform float u_grain;
uniform vec3  u_colors[3];

vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }

float snoise(vec2 v) {
  const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
  vec2 i  = floor(v + dot(v, C.yy));
  vec2 x0 = v - i + dot(i, C.xx);
  vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
  vec4 x12 = x0.xyxy + C.xxzz;
  x12.xy -= i1;
  i = mod289(i);
  vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
  vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
  m = m*m; m = m*m;
  vec3 x = 2.0 * fract(p * C.www) - 1.0;
  vec3 h = abs(x) - 0.5;
  vec3 ox = floor(x + 0.5);
  vec3 a0 = x - ox;
  m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
  vec3 g;
  g.x  = a0.x  * x0.x  + h.x  * x0.y;
  g.yz = a0.yz * x12.xz + h.yz * x12.yw;
  return 130.0 * dot(m, g);
}

void main() {
  vec2 uv = vUv;
  float ratio = u_resolution.x / u_resolution.y;
  vec2 p = uv * vec2(ratio, 1.0);
  float t = u_time * 0.2;

  float n1 = snoise(p * 0.5 + t);
  float n2 = snoise(p * 0.9 - t * 0.5 + n1);

  float light = pow(abs(n2), 2.5) * 0.5;

  vec3 col = vec3(0.02, 0.01, 0.01);
  col += u_colors[0] * smoothstep(0.1, 1.0, n1) * 0.5;
  col += u_colors[1] * light;

  float grain = fract(sin(dot(uv, vec2(12.9898, 78.233))) * 43758.5453 + u_time);
  col += (grain - 0.5) * u_grain * 0.5;

  float dist = length(uv - 0.5);
  col *= smoothstep(1.2, 0.2, dist);

  gl_FragColor = vec4(col, 1.0);
}`;

    const hexToRgb = (hex) => {
        const h = String(hex).replace('#', '');
        return [
            parseInt(h.slice(0, 2), 16) / 255,
            parseInt(h.slice(2, 4), 16) / 255,
            parseInt(h.slice(4, 6), 16) / 255,
        ];
    };

    // Reads a CSS custom property off the document root and resolves it to a
    // normalised [r,g,b] (0..1) triple via a probe element (so oklch/hsl
    // tokens collapse to concrete rgb WebGL can use), as the backdrop WebGL
    // variants do. Falls back to a fixed tone when the property is unset or
    // unresolvable.
    const tokenRgb = (name, fallback) => {
        const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
        if (!raw) return fallback;
        const probe = document.createElement('span');
        probe.style.display = 'none';
        document.body.appendChild(probe);
        probe.style.color = raw.includes('(') || raw.startsWith('#') || /[a-z]/i.test(raw) ? raw : `oklch(${raw})`;
        const resolved = getComputedStyle(probe).color;
        probe.remove();
        const m = resolved.match(/-?\d*\.?\d+/g);
        return m && m.length >= 3 ? [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255] : fallback;
    };

    // asRgb normalises either a hex string (a consumer override) or an
    // already-resolved [r,g,b] triple (a token default) to floats.
    const asRgb = (c) => (Array.isArray(c) ? c : hexToRgb(c));

    // colors[0]/[1] are the two glow stops the fragment shader actually reads
    // (colors[2] is kept for the 3-slot uniform's shape and left unused).
    const DEFAULT_COLORS = [
        tokenRgb('--primary', [0.937, 0.267, 0.267]),
        tokenRgb('--accent', [0.863, 0.149, 0.149]),
        tokenRgb('--background', [0.725, 0.110, 0.110]),
    ];

    window.Alpine.data('uiShaderAuralis', (config = {}) => ({
        colors:
            Array.isArray(config.colors) && config.colors.length
                ? config.colors.slice(0, 3)
                : DEFAULT_COLORS,
        speed: Math.max(0, config.speed ?? 0.3),
        grain: Math.max(0, config.grain ?? 0.6),

        _canvas: null,
        _gl: null,
        _program: null,
        _buffer: null,
        _locs: null,
        _raf: null,
        _ro: null,
        _reduced: false,
        _onLost: null,
        _onRestored: null,
        _viewObserver: null,
        _offscreen: false,

        init() {
            this._canvas = this.$refs.canvas;
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

            // WebGL context loss: stop the loop on lost, rebuild + restart on restore.
            this._onLost = (e) => {
                e.preventDefault();
                if (this._raf) {
                    cancelAnimationFrame(this._raf);
                    this._raf = null;
                }
            };
            this._onRestored = () => this.boot();
            this._canvas.addEventListener('webglcontextlost', this._onLost, false);
            this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);

            this.boot();

            // Pause the rAF loop while fully scrolled out of view (same-tab;
            // backgrounded tabs are already throttled by the browser) so
            // off-screen instances don't burn GPU/CPU. Resume on re-entry.
            // Static/reduced-motion renders never set _raf, so the `&& this._raf`
            // guard leaves them untouched.
            if (typeof IntersectionObserver !== 'undefined') {
                this._viewObserver = new IntersectionObserver((entries) => {
                    const visible = entries.some((e) => e.isIntersecting);
                    if (!visible && this._raf) {
                        cancelAnimationFrame(this._raf);
                        this._raf = null;
                        this._offscreen = true;
                    } else if (visible && this._offscreen) {
                        this._offscreen = false;
                        this._raf = requestAnimationFrame((t) => this.loop(t));
                    }
                });
                this._viewObserver.observe(this.$refs.root);
            }
        },

        boot() {
            const gl = this._canvas.getContext('webgl', { antialias: true });
            if (!gl) return;
            this._gl = gl;

            const shader = (type, src) => {
                const s = gl.createShader(type);
                gl.shaderSource(s, src);
                gl.compileShader(s);
                return s;
            };

            const program = gl.createProgram();
            gl.attachShader(program, shader(gl.VERTEX_SHADER, VERT));
            gl.attachShader(program, shader(gl.FRAGMENT_SHADER, FRAG));
            gl.linkProgram(program);
            gl.useProgram(program);
            this._program = program;

            this._buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
            gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
            const pos = gl.getAttribLocation(program, 'position');
            gl.enableVertexAttribArray(pos);
            gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);

            this._locs = {
                res: gl.getUniformLocation(program, 'u_resolution'),
                time: gl.getUniformLocation(program, 'u_time'),
                grain: gl.getUniformLocation(program, 'u_grain'),
                colors: gl.getUniformLocation(program, 'u_colors'),
            };

            this.resize();
            if (!this._ro) {
                this._ro = new ResizeObserver(() => this.resize());
                this._ro.observe(this.$refs.root);
            }

            if (this._reduced) {
                this.draw(0);
                return;
            }
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        resize() {
            const gl = this._gl;
            if (!gl || gl.isContextLost()) return;
            const root = this.$refs.root;
            const canvas = this.$refs.canvas;
            const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
            canvas.width = Math.max(1, Math.floor(root.clientWidth * dpr));
            canvas.height = Math.max(1, Math.floor(root.clientHeight * dpr));
            gl.viewport(0, 0, canvas.width, canvas.height);
            if (this._reduced) this.draw(0);
        },

        draw(timeMs) {
            const gl = this._gl;
            if (!gl || gl.isContextLost()) return;
            const canvas = this.$refs.canvas;
            gl.uniform2f(this._locs.res, canvas.width, canvas.height);
            gl.uniform1f(this._locs.time, timeMs * 0.001 * this.speed);
            gl.uniform1f(this._locs.grain, this.grain);
            gl.uniform3fv(this._locs.colors, new Float32Array(this.colors.flatMap(asRgb)));
            gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
        },

        loop(timeMs) {
            this.draw(timeMs);
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        destroy() {
            if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
            if (this._raf) cancelAnimationFrame(this._raf);
            if (this._ro) this._ro.disconnect();
            if (this._canvas) {
                if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
                if (this._onRestored) this._canvas.removeEventListener('webglcontextrestored', this._onRestored, false);
            }
            const gl = this._gl;
            if (gl) {
                if (this._buffer) gl.deleteBuffer(this._buffer);
                if (this._program) gl.deleteProgram(this._program);
                gl.getExtension('WEBGL_lose_context')?.loseContext();
            }
            this._raf = null;
            this._ro = null;
            this._gl = this._program = this._buffer = this._locs = null;
        },
    }));
});
resources/js/ui/shader-chromatic.js JS
/**
 * Chromatic shader preset behaviour (WebGL molten-metal sheen).
 *
 * A full-bleed <canvas> renders flowing fractal-noise fields lit as a specular
 * metallic surface, with per-channel offset glints producing a subtle chromatic
 * shimmer, a vignette and film grain. Pure WebGL on a screen-filling triangle
 * strip; no library. The host is decorative (aria-hidden / pointer-events-none,
 * set in Blade) so it never traps focus or blocks layered content.
 *
 * The palette is monochrome specular highlight, so there is no colour config —
 * the look follows from `flowStrength`, `contrast`, `grain` and `speed`.
 *
 * PERFORMANCE: device-pixel ratio is capped; the backing store is rebuilt only
 * when a ResizeObserver fires. Reduced motion paints a single static frame and
 * never starts the rAF loop. destroy() cancels the loop, disconnects the
 * observer and frees the GL program so unmounting mid-animation cannot leak.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    const VERT = `
attribute vec2 position;
varying vec2 vUv;
void main() {
  vUv = (position + 1.0) * 0.5;
  gl_Position = vec4(position, 0.0, 1.0);
}`;

    const FRAG = `
precision highp float;

uniform vec2 u_resolution;
uniform float u_time;
uniform float u_flowStrength;
uniform float u_grain;
uniform float u_contrast;
uniform float u_speed;

varying vec2 vUv;

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}

float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
             mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
}

float fbm(vec2 p) {
  float v = 0.0;
  float a = 0.5;
  mat2 rot = mat2(cos(0.5), sin(0.5), -sin(0.5), cos(0.5));
  for (int i = 0; i < 4; i++) {
    v += a * noise(p);
    p = rot * p * 2.0;
    a *= 0.5;
  }
  return v;
}

void main() {
  vec2 uv = vUv;
  float t = u_time * u_speed;
  vec2 p = (uv - 0.5) * vec2(u_resolution.x / u_resolution.y, 1.0) * 2.0;

  float baseNoise = fbm(p * 0.8 + t * 0.2);
  float fluid = fbm(p * 1.2 + baseNoise * u_flowStrength + t * 0.3);

  float eps = 0.01;
  float nx = fbm(p + vec2(eps, 0.0) + baseNoise * u_flowStrength + t * 0.3) - fluid;
  float ny = fbm(p + vec2(0.0, eps) + baseNoise * u_flowStrength + t * 0.3) - fluid;
  vec3 normal = normalize(vec3(nx, ny, eps * 1.5));

  vec3 lightDir = normalize(vec3(1.0, 1.0, 0.8));
  vec3 viewDir = vec3(0.0, 0.0, 1.0);
  vec3 halfVector = normalize(lightDir + viewDir);

  vec3 color = vec3(0.0);
  float glintIntensity = 64.0;

  float specR = pow(max(dot(normalize(vec3(nx + 0.005, ny, eps*1.5)), halfVector), 0.0), glintIntensity);
  float specG = pow(max(dot(normal, halfVector), 0.0), glintIntensity);
  float specB = pow(max(dot(normalize(vec3(nx - 0.005, ny, eps*1.5)), halfVector), 0.0), glintIntensity);

  vec3 specular = vec3(specR, specG, specB) * 1.5;
  color += specular;

  color = mix(vec3(0.5), color, u_contrast);

  float vig = smoothstep(1.8, 0.2, length(uv - 0.5));
  color *= vig;

  float grainAmount = (hash(uv + t) - 0.5) * u_grain;
  color += grainAmount;

  gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}`;

    window.Alpine.data('uiShaderChromatic', (config = {}) => ({
        flowStrength: config.flowStrength ?? 0.8,
        grain: Math.max(0, config.grain ?? 0.04),
        contrast: config.contrast ?? 1.1,
        speed: Math.max(0, config.speed ?? 0.4),

        _canvas: null,
        _gl: null,
        _program: null,
        _buffer: null,
        _locs: null,
        _raf: null,
        _ro: null,
        _start: 0,
        _reduced: false,
        _onLost: null,
        _onRestored: null,
        _viewObserver: null,
        _offscreen: false,

        init() {
            this._canvas = this.$refs.canvas;
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
            this._start = performance.now();

            this._onLost = (e) => {
                e.preventDefault();
                if (this._raf) {
                    cancelAnimationFrame(this._raf);
                    this._raf = null;
                }
            };
            this._onRestored = () => this.boot();
            this._canvas.addEventListener('webglcontextlost', this._onLost, false);
            this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);

            this.boot();

            // Pause the rAF loop while fully scrolled out of view (same-tab;
            // backgrounded tabs are already throttled by the browser) so
            // off-screen instances don't burn GPU/CPU. Resume on re-entry.
            // Static/reduced-motion renders never set _raf, so the `&& this._raf`
            // guard leaves them untouched.
            if (typeof IntersectionObserver !== 'undefined') {
                this._viewObserver = new IntersectionObserver((entries) => {
                    const visible = entries.some((e) => e.isIntersecting);
                    if (!visible && this._raf) {
                        cancelAnimationFrame(this._raf);
                        this._raf = null;
                        this._offscreen = true;
                    } else if (visible && this._offscreen) {
                        this._offscreen = false;
                        this._raf = requestAnimationFrame((t) => this.loop(t));
                    }
                });
                this._viewObserver.observe(this.$refs.root);
            }
        },

        boot() {
            const gl = this._canvas.getContext('webgl', { antialias: true });
            if (!gl) return;
            this._gl = gl;

            const compile = (type, src) => {
                const s = gl.createShader(type);
                gl.shaderSource(s, src);
                gl.compileShader(s);
                return s;
            };

            const program = gl.createProgram();
            gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
            gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
            gl.linkProgram(program);
            gl.useProgram(program);
            this._program = program;

            this._buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
            gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
            const pos = gl.getAttribLocation(program, 'position');
            gl.enableVertexAttribArray(pos);
            gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);

            this._locs = {
                res: gl.getUniformLocation(program, 'u_resolution'),
                time: gl.getUniformLocation(program, 'u_time'),
                flow: gl.getUniformLocation(program, 'u_flowStrength'),
                grain: gl.getUniformLocation(program, 'u_grain'),
                contrast: gl.getUniformLocation(program, 'u_contrast'),
                speed: gl.getUniformLocation(program, 'u_speed'),
            };

            this.resize();
            if (!this._ro) {
                this._ro = new ResizeObserver(() => this.resize());
                this._ro.observe(this.$refs.root);
            }

            if (this._reduced) {
                this.draw(0);
                return;
            }
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        resize() {
            const gl = this._gl;
            if (!gl || gl.isContextLost()) return;
            const root = this.$refs.root;
            const canvas = this.$refs.canvas;
            const dpr = Math.min(window.devicePixelRatio || 1, 2);
            canvas.width = Math.max(1, Math.floor(root.clientWidth * dpr));
            canvas.height = Math.max(1, Math.floor(root.clientHeight * dpr));
            gl.viewport(0, 0, canvas.width, canvas.height);
            gl.uniform2f(this._locs.res, canvas.width, canvas.height);
            if (this._reduced) this.draw(0);
        },

        draw(elapsedMs) {
            const gl = this._gl;
            if (!gl || gl.isContextLost()) return;
            gl.uniform1f(this._locs.time, elapsedMs / 1000);
            gl.uniform1f(this._locs.flow, this.flowStrength);
            gl.uniform1f(this._locs.grain, this.grain);
            gl.uniform1f(this._locs.contrast, this.contrast);
            gl.uniform1f(this._locs.speed, this.speed);
            gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
        },

        loop(now) {
            this.draw(now - this._start);
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        destroy() {
            if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
            if (this._raf) cancelAnimationFrame(this._raf);
            if (this._ro) this._ro.disconnect();
            if (this._canvas) {
                if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
                if (this._onRestored) this._canvas.removeEventListener('webglcontextrestored', this._onRestored, false);
            }
            const gl = this._gl;
            if (gl) {
                if (this._buffer) gl.deleteBuffer(this._buffer);
                if (this._program) gl.deleteProgram(this._program);
                gl.getExtension('WEBGL_lose_context')?.loseContext();
            }
            this._raf = null;
            this._ro = null;
            this._gl = this._program = this._buffer = this._locs = null;
        },
    }));
});
resources/js/ui/shader-cloudscape.js JS
/**
 * Cloudscape shader preset behaviour (WebGL drifting cloud bands).
 *
 * A full-bleed <canvas> renders morphing fractal-noise cloud layers that drift
 * on a slow wind, banded into three colour stops (bottom → mid → top). Pure
 * WebGL on a screen-filling triangle strip; no library. The host is decorative
 * (aria-hidden / pointer-events-none, set in Blade) so it never traps focus or
 * blocks the content layered above it.
 *
 * COLOUR: the three stops default to the theme's `--primary` / `--accent` /
 * `--background` tokens, resolved to WebGL rgb via `tokenRgb()` (a probe
 * element + getComputedStyle, same technique as the `backdrop` gradient
 * variant). A consumer may still override any stop with hex via
 * `colorBottom`/`colorMid`/`colorTop`; the fixed daylight floats below are
 * only the fallback before tokens resolve.
 *
 * PERFORMANCE: device-pixel ratio is capped; the backing store is rebuilt only
 * when a ResizeObserver fires. Reduced motion paints a single static frame and
 * never starts the rAF loop. destroy() tears everything down so unmounting
 * mid-animation cannot leak.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    const VERT = `
attribute vec2 position;
void main() {
  gl_Position = vec4(position, 0.0, 1.0);
}`;

    const FRAG = `
precision highp float;

uniform vec2 u_resolution;
uniform float u_time;
uniform vec3 u_colorBottom;
uniform vec3 u_colorMid;
uniform vec3 u_colorTop;
uniform float u_speed;

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}

float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  float a = hash(i);
  float b = hash(i + vec2(1.0, 0.0));
  float c = hash(i + vec2(0.0, 1.0));
  float d = hash(i + vec2(1.0, 1.0));
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}

float fbm(vec2 p, float t) {
  float v = 0.0;
  float a = 0.5;
  float fi = 0.0;
  mat2 rot = mat2(0.86, 0.51, -0.51, 0.86);
  for (int i = 0; i < 6; i++) {
    vec2 morph = vec2(sin(t * 0.5 + fi), cos(t * 0.3 - fi)) * 0.05;
    v += a * noise(p + morph);
    p = rot * p * 2.0;
    a *= 0.5;
    fi += 1.0;
  }
  return v;
}

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;
  float t = u_time * u_speed;
  vec2 aspect = vec2(u_resolution.x / max(u_resolution.y, 1.0), 1.0);
  vec2 p = (uv - 0.5) * aspect;

  vec2 wind = vec2(t * 0.1, t * 0.02);
  float pattern = fbm(p * 2.2 - wind, t);

  float bandLow = smoothstep(0.3, 0.65, pattern);
  float bandHigh = smoothstep(0.7, 0.95, pattern);

  vec3 color = mix(u_colorBottom, u_colorMid, bandLow);
  color = mix(color, u_colorTop, bandHigh);

  gl_FragColor = vec4(color, 1.0);
}`;

    const HEX = /^#?[0-9a-fA-F]{6}$/;

    // Reads a CSS custom property off the document root and resolves it to a
    // normalised [r,g,b] (0..1) triple via a probe element (so oklch/hsl
    // tokens collapse to concrete rgb WebGL can use), as the backdrop WebGL
    // variants do. Falls back to a fixed tone when the property is unset or
    // unresolvable.
    const tokenRgb = (name, fallback) => {
        const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
        if (!raw) return fallback;
        const probe = document.createElement('span');
        probe.style.display = 'none';
        document.body.appendChild(probe);
        probe.style.color = raw.includes('(') || raw.startsWith('#') || /[a-z]/i.test(raw) ? raw : `oklch(${raw})`;
        const resolved = getComputedStyle(probe).color;
        probe.remove();
        const m = resolved.match(/-?\d*\.?\d+/g);
        return m && m.length >= 3 ? [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255] : fallback;
    };

    // A hex override resolves via straight hex parsing; anything else (the
    // token-derived default, an [r,g,b] triple already) passes through.
    const toRgb = (value, fallback) => {
        if (typeof value === 'string' && HEX.test(value.trim())) {
            const h = value.trim().replace('#', '');
            return [
                parseInt(h.slice(0, 2), 16) / 255,
                parseInt(h.slice(2, 4), 16) / 255,
                parseInt(h.slice(4, 6), 16) / 255,
            ];
        }
        return fallback;
    };

    // Sky tones derived from the theme (bottom = primary, mid = accent, top =
    // background); fixed daylight floats are the fallback before tokens resolve.
    const DEFAULTS = {
        bottom: tokenRgb('--primary', [0.529, 0.808, 0.922]),
        mid: tokenRgb('--accent', [0.973, 0.973, 0.973]),
        top: tokenRgb('--background', [1, 1, 1]),
    };

    window.Alpine.data('uiShaderCloudscape', (config = {}) => ({
        colorBottom: config.colorBottom ?? DEFAULTS.bottom,
        colorMid: config.colorMid ?? DEFAULTS.mid,
        colorTop: config.colorTop ?? DEFAULTS.top,
        speed: Math.max(0, config.speed ?? 1),

        _canvas: null,
        _gl: null,
        _program: null,
        _buffer: null,
        _locs: null,
        _raf: null,
        _ro: null,
        _start: 0,
        _reduced: false,
        _onLost: null,
        _onRestored: null,
        _viewObserver: null,
        _offscreen: false,

        init() {
            this._canvas = this.$refs.canvas;
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
            this._start = performance.now();

            this._onLost = (e) => {
                e.preventDefault();
                if (this._raf) {
                    cancelAnimationFrame(this._raf);
                    this._raf = null;
                }
            };
            this._onRestored = () => this.boot();
            this._canvas.addEventListener('webglcontextlost', this._onLost, false);
            this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);

            this.boot();

            // Pause the rAF loop while fully scrolled out of view (same-tab;
            // backgrounded tabs are already throttled by the browser) so
            // off-screen instances don't burn GPU/CPU. Resume on re-entry.
            // Static/reduced-motion renders never set _raf, so the `&& this._raf`
            // guard leaves them untouched.
            if (typeof IntersectionObserver !== 'undefined') {
                this._viewObserver = new IntersectionObserver((entries) => {
                    const visible = entries.some((e) => e.isIntersecting);
                    if (!visible && this._raf) {
                        cancelAnimationFrame(this._raf);
                        this._raf = null;
                        this._offscreen = true;
                    } else if (visible && this._offscreen) {
                        this._offscreen = false;
                        this._raf = requestAnimationFrame((t) => this.loop(t));
                    }
                });
                this._viewObserver.observe(this.$refs.root);
            }
        },

        boot() {
            const gl = this._canvas.getContext('webgl', { antialias: true, alpha: true });
            if (!gl) return;
            this._gl = gl;

            const compile = (type, src) => {
                const s = gl.createShader(type);
                gl.shaderSource(s, src);
                gl.compileShader(s);
                return s;
            };

            const program = gl.createProgram();
            gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
            gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
            gl.linkProgram(program);
            gl.useProgram(program);
            this._program = program;

            this._buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
            gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
            const pos = gl.getAttribLocation(program, 'position');
            gl.enableVertexAttribArray(pos);
            gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);

            this._locs = {
                res: gl.getUniformLocation(program, 'u_resolution'),
                time: gl.getUniformLocation(program, 'u_time'),
                bottom: gl.getUniformLocation(program, 'u_colorBottom'),
                mid: gl.getUniformLocation(program, 'u_colorMid'),
                top: gl.getUniformLocation(program, 'u_colorTop'),
                speed: gl.getUniformLocation(program, 'u_speed'),
            };

            this.resize();
            if (!this._ro) {
                this._ro = new ResizeObserver(() => this.resize());
                this._ro.observe(this.$refs.root);
            }

            if (this._reduced) {
                this.draw(0);
                return;
            }
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        resize() {
            const gl = this._gl;
            if (!gl || gl.isContextLost()) return;
            const root = this.$refs.root;
            const canvas = this.$refs.canvas;
            const dpr = Math.min(window.devicePixelRatio || 1, 2);
            canvas.width = Math.max(1, Math.floor(root.clientWidth * dpr));
            canvas.height = Math.max(1, Math.floor(root.clientHeight * dpr));
            gl.viewport(0, 0, canvas.width, canvas.height);
            gl.uniform2f(this._locs.res, canvas.width, canvas.height);
            if (this._reduced) this.draw(0);
        },

        draw(elapsedMs) {
            const gl = this._gl;
            if (!gl || gl.isContextLost()) return;
            const cb = toRgb(this.colorBottom, DEFAULTS.bottom);
            const cm = toRgb(this.colorMid, DEFAULTS.mid);
            const ct = toRgb(this.colorTop, DEFAULTS.top);
            gl.clearColor(0, 0, 0, 0);
            gl.clear(gl.COLOR_BUFFER_BIT);
            gl.uniform1f(this._locs.time, elapsedMs / 1000);
            gl.uniform3f(this._locs.bottom, cb[0], cb[1], cb[2]);
            gl.uniform3f(this._locs.mid, cm[0], cm[1], cm[2]);
            gl.uniform3f(this._locs.top, ct[0], ct[1], ct[2]);
            gl.uniform1f(this._locs.speed, this.speed);
            gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
        },

        loop(now) {
            this.draw(now - this._start);
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        destroy() {
            if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
            if (this._raf) cancelAnimationFrame(this._raf);
            if (this._ro) this._ro.disconnect();
            if (this._canvas) {
                if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
                if (this._onRestored) this._canvas.removeEventListener('webglcontextrestored', this._onRestored, false);
            }
            const gl = this._gl;
            if (gl) {
                if (this._buffer) gl.deleteBuffer(this._buffer);
                if (this._program) gl.deleteProgram(this._program);
                gl.getExtension('WEBGL_lose_context')?.loseContext();
            }
            this._raf = null;
            this._ro = null;
            this._gl = this._program = this._buffer = this._locs = null;
        },
    }));
});
resources/js/ui/shader-cosmic-rift.js JS
/**
 * Cosmic Rift shader preset behaviour (WebGL starfield + plasma rift).
 *
 * A full-bleed <canvas> renders a twinkling procedural starfield over a glowing
 * multi-layer plasma "rift" that ripples across the middle of the frame, with a
 * soft vignette. Pure WebGL on a screen-filling triangle strip; no library. The
 * host is decorative (aria-hidden / pointer-events-none, set in Blade) so it
 * never traps focus or blocks the content layered above it.
 *
 * COLOUR: the background and star tints default to the theme's `--background`
 * and `--foreground` tokens (rift colours stay baked into the shader as
 * procedural artwork), resolved to WebGL rgb via `tokenRgb()` (a probe element
 * + getComputedStyle, same technique as the `backdrop` gradient variant). A
 * consumer may still override either tint with hex via `colorBackground` /
 * `colorStars`; the fixed void/star floats below are only the fallback before
 * tokens resolve.
 *
 * PERFORMANCE: device-pixel ratio is used as-is from the host; the backing store
 * is rebuilt only when a ResizeObserver fires. Reduced motion paints a single
 * static frame and never starts the rAF loop. destroy() tears everything down so
 * unmounting mid-animation cannot leak.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    const VERT = `
attribute vec2 position;
void main() {
  gl_Position = vec4(position, 0.0, 1.0);
}`;

    const FRAG = `
precision highp float;

uniform vec2 u_resolution;
uniform float u_time;
uniform vec3 u_colorBackground;
uniform vec3 u_colorStars;
uniform float u_density;
uniform float u_speed;

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;
  float aspect = u_resolution.x / u_resolution.y;

  vec2 p = uv;
  p.x *= aspect;

  float t = u_time * u_speed * 0.5;

  vec3 color = u_colorBackground;

  float gridSize = 35.0 * u_density;
  vec2 gridPos = floor(p * gridSize);
  vec2 localUV = fract(p * gridSize) - 0.5;

  float r = hash(gridPos);
  float threshold = 0.85;

  if (r > threshold) {
    vec2 offset = vec2(hash(gridPos + 12.0), hash(gridPos + 24.0)) - 0.5;
    float dist = length(localUV - offset * 0.7);

    float size = 0.025 + (r - threshold) * 0.6;
    float brightness = smoothstep(size, 0.0, dist);

    float twinkleSpeed = 1.0 + hash(gridPos + 100.0) * 3.0;
    float twinkle = sin(t * twinkleSpeed * 2.0 + hash(gridPos + 200.0) * 6.28) * 0.5 + 0.5;
    brightness *= (0.3 + twinkle * 0.7);

    vec3 starCol = mix(u_colorStars, vec3(0.6, 0.8, 1.0), hash(gridPos + 300.0));
    color += starCol * brightness * (r - threshold) * 7.0;
  }

  vec2 cuv = uv * 2.0 - 1.0;
  cuv.x *= aspect;

  vec3 waveColor = vec3(0.0);

  for (float i = 1.0; i <= 4.0; i++) {
    float xDistort = sin(cuv.y * 2.0 * i + t * (0.5 + i * 0.15)) * 0.2;
    float yDistort = cos(cuv.x * 2.0 * i - t * 0.4) * 0.15;

    vec2 distortedUV = cuv + vec2(xDistort, yDistort);
    float dist = abs(distortedUV.y);

    float glow = 0.015 / (dist + 0.015);

    vec3 c1 = vec3(0.1, 0.02, 0.3);
    vec3 c2 = vec3(0.0, 0.9, 1.0);
    vec3 c3 = vec3(0.9, 0.1, 0.7);

    vec3 layerCol = mix(c1, c2, sin(cuv.x * 2.5 + t + i) * 0.5 + 0.5);
    layerCol = mix(layerCol, c3, cos(cuv.y * 3.0 - t) * 0.5 + 0.5);

    waveColor += layerCol * glow * (1.5 / i);
  }

  float mask = smoothstep(1.0, 0.05, abs(cuv.y));
  color += waveColor * mask;

  float vignette = uv.x * (1.0 - uv.x) * uv.y * (1.0 - uv.y) * 15.0;
  color *= pow(vignette, 0.15);

  gl_FragColor = vec4(color, 1.0);
}`;

    const HEX = /^#?[0-9a-fA-F]{6}$/;

    // Reads a CSS custom property off the document root and resolves it to a
    // normalised [r,g,b] (0..1) triple via a probe element (so oklch/hsl
    // tokens collapse to concrete rgb WebGL can use), as the backdrop WebGL
    // variants do. Falls back to a fixed tone when the property is unset or
    // unresolvable.
    const tokenRgb = (name, fallback) => {
        const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
        if (!raw) return fallback;
        const probe = document.createElement('span');
        probe.style.display = 'none';
        document.body.appendChild(probe);
        probe.style.color = raw.includes('(') || raw.startsWith('#') || /[a-z]/i.test(raw) ? raw : `oklch(${raw})`;
        const resolved = getComputedStyle(probe).color;
        probe.remove();
        const m = resolved.match(/-?\d*\.?\d+/g);
        return m && m.length >= 3 ? [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255] : fallback;
    };

    // A hex override resolves via straight hex parsing; anything else (the
    // token-derived default, an [r,g,b] triple already) passes through.
    const toRgb = (value, fallback) => {
        if (typeof value === 'string' && HEX.test(value.trim())) {
            const h = value.trim().replace('#', '');
            return [
                parseInt(h.slice(0, 2), 16) / 255,
                parseInt(h.slice(2, 4), 16) / 255,
                parseInt(h.slice(4, 6), 16) / 255,
            ];
        }
        return fallback;
    };

    // The void and star tints derived from the theme (background = the
    // `--background` token, stars = `--foreground`); fixed midnight floats
    // are the fallback before tokens resolve.
    const DEFAULTS = {
        background: tokenRgb('--background', [0.008, 0.004, 0.024]),
        stars: tokenRgb('--foreground', [1, 1, 1]),
    };

    window.Alpine.data('uiShaderCosmicRift', (config = {}) => ({
        colorBackground: config.colorBackground ?? DEFAULTS.background,
        colorStars: config.colorStars ?? DEFAULTS.stars,
        density: config.density ?? 1.2,
        speed: Math.max(0, config.speed ?? 1),

        _canvas: null,
        _gl: null,
        _program: null,
        _buffer: null,
        _locs: null,
        _raf: null,
        _ro: null,
        _reduced: false,
        _onLost: null,
        _onRestored: null,
        _viewObserver: null,
        _offscreen: false,

        init() {
            this._canvas = this.$refs.canvas;
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

            this._onLost = (e) => {
                e.preventDefault();
                if (this._raf) {
                    cancelAnimationFrame(this._raf);
                    this._raf = null;
                }
            };
            this._onRestored = () => this.boot();
            this._canvas.addEventListener('webglcontextlost', this._onLost, false);
            this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);

            this.boot();

            // Pause the rAF loop while fully scrolled out of view (same-tab;
            // backgrounded tabs are already throttled by the browser) so
            // off-screen instances don't burn GPU/CPU. Resume on re-entry.
            // Static/reduced-motion renders never set _raf, so the `&& this._raf`
            // guard leaves them untouched.
            if (typeof IntersectionObserver !== 'undefined') {
                this._viewObserver = new IntersectionObserver((entries) => {
                    const visible = entries.some((e) => e.isIntersecting);
                    if (!visible && this._raf) {
                        cancelAnimationFrame(this._raf);
                        this._raf = null;
                        this._offscreen = true;
                    } else if (visible && this._offscreen) {
                        this._offscreen = false;
                        this._raf = requestAnimationFrame((t) => this.loop(t));
                    }
                });
                this._viewObserver.observe(this.$refs.root);
            }
        },

        boot() {
            const gl = this._canvas.getContext('webgl', { antialias: true, alpha: true });
            if (!gl) return;
            this._gl = gl;

            const compile = (type, src) => {
                const s = gl.createShader(type);
                gl.shaderSource(s, src);
                gl.compileShader(s);
                return s;
            };

            const program = gl.createProgram();
            gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
            gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
            gl.linkProgram(program);
            gl.useProgram(program);
            this._program = program;

            this._buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
            gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
            const pos = gl.getAttribLocation(program, 'position');
            gl.enableVertexAttribArray(pos);
            gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);

            this._locs = {
                res: gl.getUniformLocation(program, 'u_resolution'),
                time: gl.getUniformLocation(program, 'u_time'),
                bg: gl.getUniformLocation(program, 'u_colorBackground'),
                stars: gl.getUniformLocation(program, 'u_colorStars'),
                density: gl.getUniformLocation(program, 'u_density'),
                speed: gl.getUniformLocation(program, 'u_speed'),
            };

            this.resize();
            if (!this._ro) {
                this._ro = new ResizeObserver(() => this.resize());
                this._ro.observe(this.$refs.root);
            }

            if (this._reduced) {
                this.draw(0);
                return;
            }
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        resize() {
            const gl = this._gl;
            if (!gl || gl.isContextLost()) return;
            const root = this.$refs.root;
            const canvas = this.$refs.canvas;
            const dpr = window.devicePixelRatio || 1;
            canvas.width = Math.max(1, Math.floor(root.clientWidth * dpr));
            canvas.height = Math.max(1, Math.floor(root.clientHeight * dpr));
            gl.viewport(0, 0, canvas.width, canvas.height);
            if (this._reduced) this.draw(0);
        },

        draw(timeMs) {
            const gl = this._gl;
            if (!gl || gl.isContextLost()) return;
            const canvas = this.$refs.canvas;
            const bg = toRgb(this.colorBackground, DEFAULTS.background);
            const st = toRgb(this.colorStars, DEFAULTS.stars);
            gl.uniform2f(this._locs.res, canvas.width, canvas.height);
            gl.uniform1f(this._locs.time, timeMs * 0.001);
            gl.uniform3f(this._locs.bg, bg[0], bg[1], bg[2]);
            gl.uniform3f(this._locs.stars, st[0], st[1], st[2]);
            gl.uniform1f(this._locs.density, this.density);
            gl.uniform1f(this._locs.speed, this.speed);
            gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
        },

        loop(timeMs) {
            this.draw(timeMs);
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        destroy() {
            if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
            if (this._raf) cancelAnimationFrame(this._raf);
            if (this._ro) this._ro.disconnect();
            if (this._canvas) {
                if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
                if (this._onRestored) this._canvas.removeEventListener('webglcontextrestored', this._onRestored, false);
            }
            const gl = this._gl;
            if (gl) {
                if (this._buffer) gl.deleteBuffer(this._buffer);
                if (this._program) gl.deleteProgram(this._program);
                gl.getExtension('WEBGL_lose_context')?.loseContext();
            }
            this._raf = null;
            this._ro = null;
            this._gl = this._program = this._buffer = this._locs = null;
        },
    }));
});
resources/js/ui/shader-luma-drift.js JS
/**
 * Luma Drift shader preset behaviour (WebGL aurora curtains).
 *
 * A full-bleed <canvas> renders soft aurora-like curtains: warped value-noise
 * bands that drift and glow across the lower-middle of the frame, with a faint
 * film grain. Pure WebGL on a screen-filling triangle strip; no library. The
 * host is decorative (aria-hidden / pointer-events-none, set in Blade) so it
 * never traps focus or blocks the content layered above it.
 *
 * The aurora palette is monochrome blue artwork baked into the shader, so there
 * is no colour config — only `speed`.
 *
 * PERFORMANCE: device-pixel ratio is used as-is; the backing store is rebuilt
 * only when a ResizeObserver fires. Reduced motion paints a single static frame
 * and never starts the rAF loop. destroy() tears everything down so unmounting
 * mid-animation cannot leak.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    const VERT = `
attribute vec2 position;
varying vec2 vUv;
void main() {
  gl_Position = vec4(position, 0.0, 1.0);
  vUv = (position.xy + 1.0) * 0.5;
}`;

    const FRAG = `
precision highp float;

uniform vec2 u_resolution;
uniform float u_time;

varying vec2 vUv;

vec2 hash(vec2 p) {
  p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
  return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
}

float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(dot(hash(i + vec2(0.0, 0.0)), f - vec2(0.0, 0.0)),
                 dot(hash(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x),
             mix(dot(hash(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)),
                 dot(hash(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x), u.y);
}

void main() {
  vec2 uv = vUv;
  float t = u_time * 0.0004;

  float warp = noise(vec2(uv.x * 1.2 + t, t * 0.2));
  vec2 uvWarped = uv;
  uvWarped.x += warp * 0.1;

  float n1 = noise(vec2(uvWarped.x * 2.0 + t * 0.5, uvWarped.y * 0.2 - t * 0.1));
  float n2 = noise(vec2(uvWarped.x * 4.0 - t * 0.8, uvWarped.y * 0.5 + t * 0.2));

  float aurora = abs(n1 + n2);
  aurora = pow(1.0 - smoothstep(0.0, 0.4, aurora), 4.0);

  float mask = smoothstep(0.0, 0.4, uv.y) * smoothstep(1.0, 0.5, uv.y);
  aurora *= mask;

  vec3 darkBlue = vec3(0.02, 0.04, 0.08);
  vec3 oceanBlue = vec3(0.05, 0.15, 0.35);
  vec3 skyBlue = vec3(0.1, 0.3, 0.6);
  vec3 cyan = vec3(0.2, 0.6, 0.9);
  vec3 brightGlow = vec3(0.4, 0.8, 1.0);

  vec3 col = darkBlue;
  col = mix(col, oceanBlue, aurora * 0.8);
  col = mix(col, skyBlue, pow(aurora, 2.0));
  col = mix(col, cyan, pow(aurora, 3.0));
  col += brightGlow * pow(aurora, 5.0) * 0.5;

  float grain = fract(sin(dot(uv, vec2(12.9898, 78.233))) * 43758.5453);
  col += grain * 0.02;

  gl_FragColor = vec4(col, 1.0);
}`;

    window.Alpine.data('uiShaderLumaDrift', (config = {}) => ({
        speed: Math.max(0, config.speed ?? 1),

        _canvas: null,
        _gl: null,
        _program: null,
        _buffer: null,
        _locs: null,
        _raf: null,
        _ro: null,
        _start: 0,
        _reduced: false,
        _onLost: null,
        _onRestored: null,
        _viewObserver: null,
        _offscreen: false,

        init() {
            this._canvas = this.$refs.canvas;
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
            this._start = performance.now();

            this._onLost = (e) => {
                e.preventDefault();
                if (this._raf) {
                    cancelAnimationFrame(this._raf);
                    this._raf = null;
                }
            };
            this._onRestored = () => this.boot();
            this._canvas.addEventListener('webglcontextlost', this._onLost, false);
            this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);

            this.boot();

            // Pause the rAF loop while fully scrolled out of view (same-tab;
            // backgrounded tabs are already throttled by the browser) so
            // off-screen instances don't burn GPU/CPU. Resume on re-entry.
            // Static/reduced-motion renders never set _raf, so the `&& this._raf`
            // guard leaves them untouched.
            if (typeof IntersectionObserver !== 'undefined') {
                this._viewObserver = new IntersectionObserver((entries) => {
                    const visible = entries.some((e) => e.isIntersecting);
                    if (!visible && this._raf) {
                        cancelAnimationFrame(this._raf);
                        this._raf = null;
                        this._offscreen = true;
                    } else if (visible && this._offscreen) {
                        this._offscreen = false;
                        this._raf = requestAnimationFrame((t) => this.loop(t));
                    }
                });
                this._viewObserver.observe(this.$refs.root);
            }
        },

        boot() {
            const gl = this._canvas.getContext('webgl') || this._canvas.getContext('experimental-webgl');
            if (!gl) return;
            this._gl = gl;

            const compile = (type, src) => {
                const s = gl.createShader(type);
                gl.shaderSource(s, src);
                gl.compileShader(s);
                return s;
            };

            const program = gl.createProgram();
            gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
            gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
            gl.linkProgram(program);
            gl.useProgram(program);
            this._program = program;

            this._buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
            gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
            const pos = gl.getAttribLocation(program, 'position');
            gl.enableVertexAttribArray(pos);
            gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);

            this._locs = {
                res: gl.getUniformLocation(program, 'u_resolution'),
                time: gl.getUniformLocation(program, 'u_time'),
            };

            this.resize();
            if (!this._ro) {
                this._ro = new ResizeObserver(() => this.resize());
                this._ro.observe(this.$refs.root);
            }

            if (this._reduced) {
                this.draw(0);
                return;
            }
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        resize() {
            const gl = this._gl;
            if (!gl || gl.isContextLost()) return;
            const root = this.$refs.root;
            const canvas = this.$refs.canvas;
            const dpr = window.devicePixelRatio || 1;
            canvas.width = Math.max(1, Math.floor(root.clientWidth * dpr));
            canvas.height = Math.max(1, Math.floor(root.clientHeight * dpr));
            gl.viewport(0, 0, canvas.width, canvas.height);
            if (this._reduced) this.draw(0);
        },

        draw(elapsedMs) {
            const gl = this._gl;
            if (!gl || gl.isContextLost()) return;
            const canvas = this.$refs.canvas;
            gl.uniform2f(this._locs.res, canvas.width, canvas.height);
            gl.uniform1f(this._locs.time, elapsedMs * this.speed);
            gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
        },

        loop(now) {
            this.draw(now - this._start);
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        destroy() {
            if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
            if (this._raf) cancelAnimationFrame(this._raf);
            if (this._ro) this._ro.disconnect();
            if (this._canvas) {
                if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
                if (this._onRestored) this._canvas.removeEventListener('webglcontextrestored', this._onRestored, false);
            }
            const gl = this._gl;
            if (gl) {
                if (this._buffer) gl.deleteBuffer(this._buffer);
                if (this._program) gl.deleteProgram(this._program);
                gl.getExtension('WEBGL_lose_context')?.loseContext();
            }
            this._raf = null;
            this._ro = null;
            this._gl = this._program = this._buffer = this._locs = null;
        },
    }));
});
resources/js/ui/shader-velaris.js JS
/**
 * Velaris shader preset behaviour (WebGL layered simplex glow).
 *
 * A full-bleed <canvas> renders three layered simplex-noise fields blended
 * across a four-stop palette over a base colour, with a radial glow, vignette
 * and film grain — a slow, lava-lamp-like wash. Pure WebGL on a screen-filling
 * triangle strip; no library. The host is decorative (aria-hidden /
 * pointer-events-none, set in Blade) so it never traps focus or blocks the
 * content layered above it.
 *
 * COLOUR: the base `bg` defaults to the theme's `--background` token and the
 * four `colors` stops cycle `--primary` / `--accent`, resolved to WebGL rgb
 * via `tokenRgb()` (a probe element + getComputedStyle, same technique as the
 * `backdrop` gradient variant). A consumer may still override `bg`/`colors`
 * with hex via config; the fixed lava-lamp-green floats below are only the
 * fallback before tokens resolve.
 *
 * PERFORMANCE: device-pixel ratio is capped; the backing store is rebuilt only
 * when a ResizeObserver fires. Reduced motion paints a single static frame and
 * never starts the rAF loop. destroy() tears everything down so unmounting
 * mid-animation cannot leak.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    const VERT = `
attribute vec2 position;
varying vec2 vUv;
void main() {
  vUv = position * 0.5 + 0.5;
  gl_Position = vec4(position, 0.0, 1.0);
}`;

    const FRAG = `
precision highp float;
varying vec2 vUv;

uniform vec2  u_resolution;
uniform float u_time;
uniform float u_grain;
uniform vec3  u_colors[4];
uniform vec3  u_bg;

vec3 permute(vec3 x) { return mod(((x*34.0)+1.0)*x, 289.0); }

float snoise(vec2 v){
  const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
  vec2 i  = floor(v + dot(v, C.yy));
  vec2 x0 = v - i + dot(i, C.xx);
  vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
  vec4 x12 = x0.xyxy + C.xxzz;
  x12.xy -= i1;
  i = mod(i, 289.0);
  vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
  vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
  m = m*m; m = m*m;
  vec3 x = 2.0 * fract(p * C.www) - 1.0;
  vec3 h = abs(x) - 0.5;
  vec3 ox = floor(x + 0.5);
  vec3 a0 = x - ox;
  m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
  vec3 g;
  g.x  = a0.x  * x0.x  + h.x  * x0.y;
  g.yz = a0.yz * x12.xz + h.yz * x12.yw;
  return 130.0 * dot(m, g);
}

void main() {
  vec2 uv = vUv;
  float ratio = u_resolution.x / u_resolution.y;
  vec2 p = uv - 0.5;
  p.x *= ratio;

  float t = u_time * 0.1;

  float n1 = snoise(p * 0.4 + vec2(t * 0.2, -t * 0.3));
  float n2 = snoise(p * 0.55 + vec2(-t * 0.15, t * 0.25) + n1 * 0.25);
  float n3 = snoise(p * 0.75 + vec2(t * 0.1, -t * 0.2) + n2 * 0.2);

  vec3 col = u_bg;

  float dist = length(p) * 1.5;
  float vignette = 1.0 - smoothstep(0.3, 1.2, dist);

  col = mix(col, u_colors[0], smoothstep(-0.2, 0.5, n1) * 0.85);
  col = mix(col, u_colors[1], smoothstep(-0.1, 0.6, n2) * 0.7);
  col = mix(col, u_colors[2], smoothstep(-0.3, 0.4, n3) * 0.6);
  col = mix(col, u_colors[3], smoothstep(0.0, 0.7, n1 * n2) * 0.5);

  float glow = smoothstep(0.8, 0.0, dist) * 0.3;
  col += u_colors[1] * glow;

  col = mix(col * 0.2, col, vignette);

  float grain = fract(sin(dot(uv, vec2(12.9898, 78.233))) * 43758.5453 + u_time);
  col += (grain - 0.5) * u_grain * 0.1;

  gl_FragColor = vec4(col, 1.0);
}`;

    const hexToRgb = (hex) => {
        const h = String(hex).replace('#', '');
        return [
            parseInt(h.slice(0, 2), 16) / 255,
            parseInt(h.slice(2, 4), 16) / 255,
            parseInt(h.slice(4, 6), 16) / 255,
        ];
    };

    // Reads a CSS custom property off the document root and resolves it to a
    // normalised [r,g,b] (0..1) triple via a probe element (so oklch/hsl
    // tokens collapse to concrete rgb WebGL can use), as the backdrop WebGL
    // variants do. Falls back to a fixed tone when the property is unset or
    // unresolvable.
    const tokenRgb = (name, fallback) => {
        const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
        if (!raw) return fallback;
        const probe = document.createElement('span');
        probe.style.display = 'none';
        document.body.appendChild(probe);
        probe.style.color = raw.includes('(') || raw.startsWith('#') || /[a-z]/i.test(raw) ? raw : `oklch(${raw})`;
        const resolved = getComputedStyle(probe).color;
        probe.remove();
        const m = resolved.match(/-?\d*\.?\d+/g);
        return m && m.length >= 3 ? [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255] : fallback;
    };

    // asRgb normalises either a hex string (a consumer override) or an
    // already-resolved [r,g,b] triple (a token default) to floats.
    const asRgb = (c) => (Array.isArray(c) ? c : hexToRgb(c));

    // Glow stops derived from the theme (cycling primary/accent); fixed
    // lava-lamp-green floats are the fallback before tokens resolve.
    const DEFAULT_COLORS = [
        tokenRgb('--primary', [0.525, 0.937, 0.675]),
        tokenRgb('--accent', [0.290, 0.871, 0.502]),
        tokenRgb('--primary', [0.020, 0.588, 0.412]),
        tokenRgb('--accent', [0, 0, 0]),
    ];
    const DEFAULT_BG = tokenRgb('--background', [0, 0, 0]);

    window.Alpine.data('uiShaderVelaris', (config = {}) => ({
        bg: typeof config.bg === 'string' ? config.bg : DEFAULT_BG,
        colors:
            Array.isArray(config.colors) && config.colors.length >= 4
                ? config.colors.slice(0, 4)
                : DEFAULT_COLORS,
        speed: Math.max(0, config.speed ?? 2),
        grain: Math.max(0, config.grain ?? 0.3),

        _canvas: null,
        _gl: null,
        _program: null,
        _buffer: null,
        _locs: null,
        _raf: null,
        _ro: null,
        _reduced: false,
        _onLost: null,
        _onRestored: null,
        _viewObserver: null,
        _offscreen: false,

        init() {
            this._canvas = this.$refs.canvas;
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

            this._onLost = (e) => {
                e.preventDefault();
                if (this._raf) {
                    cancelAnimationFrame(this._raf);
                    this._raf = null;
                }
            };
            this._onRestored = () => this.boot();
            this._canvas.addEventListener('webglcontextlost', this._onLost, false);
            this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);

            this.boot();

            // Pause the rAF loop while fully scrolled out of view (same-tab;
            // backgrounded tabs are already throttled by the browser) so
            // off-screen instances don't burn GPU/CPU. Resume on re-entry.
            // Static/reduced-motion renders never set _raf, so the `&& this._raf`
            // guard leaves them untouched.
            if (typeof IntersectionObserver !== 'undefined') {
                this._viewObserver = new IntersectionObserver((entries) => {
                    const visible = entries.some((e) => e.isIntersecting);
                    if (!visible && this._raf) {
                        cancelAnimationFrame(this._raf);
                        this._raf = null;
                        this._offscreen = true;
                    } else if (visible && this._offscreen) {
                        this._offscreen = false;
                        this._raf = requestAnimationFrame((t) => this.loop(t));
                    }
                });
                this._viewObserver.observe(this.$refs.root);
            }
        },

        boot() {
            const gl = this._canvas.getContext('webgl');
            if (!gl) return;
            this._gl = gl;

            const shader = (type, src) => {
                const s = gl.createShader(type);
                gl.shaderSource(s, src);
                gl.compileShader(s);
                return s;
            };

            const program = gl.createProgram();
            gl.attachShader(program, shader(gl.VERTEX_SHADER, VERT));
            gl.attachShader(program, shader(gl.FRAGMENT_SHADER, FRAG));
            gl.linkProgram(program);
            gl.useProgram(program);
            this._program = program;

            this._buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
            gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
            const pos = gl.getAttribLocation(program, 'position');
            gl.enableVertexAttribArray(pos);
            gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);

            this._locs = {
                res: gl.getUniformLocation(program, 'u_resolution'),
                time: gl.getUniformLocation(program, 'u_time'),
                grain: gl.getUniformLocation(program, 'u_grain'),
                colors: gl.getUniformLocation(program, 'u_colors'),
                bg: gl.getUniformLocation(program, 'u_bg'),
            };

            this.resize();
            if (!this._ro) {
                this._ro = new ResizeObserver(() => this.resize());
                this._ro.observe(this.$refs.root);
            }

            if (this._reduced) {
                this.draw(0);
                return;
            }
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        resize() {
            const gl = this._gl;
            if (!gl || gl.isContextLost()) return;
            const root = this.$refs.root;
            const canvas = this.$refs.canvas;
            const dpr = Math.min(window.devicePixelRatio || 1, 2);
            canvas.width = Math.max(1, Math.floor(root.clientWidth * dpr));
            canvas.height = Math.max(1, Math.floor(root.clientHeight * dpr));
            gl.viewport(0, 0, canvas.width, canvas.height);
            if (this._reduced) this.draw(0);
        },

        draw(timeMs) {
            const gl = this._gl;
            if (!gl || gl.isContextLost()) return;
            const canvas = this.$refs.canvas;
            gl.uniform2f(this._locs.res, canvas.width, canvas.height);
            gl.uniform1f(this._locs.time, timeMs * 0.001 * this.speed);
            gl.uniform1f(this._locs.grain, this.grain);
            const bg = asRgb(this.bg);
            gl.uniform3f(this._locs.bg, bg[0], bg[1], bg[2]);
            gl.uniform3fv(this._locs.colors, new Float32Array(this.colors.flatMap(asRgb)));
            gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
        },

        loop(timeMs) {
            this.draw(timeMs);
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        destroy() {
            if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
            if (this._raf) cancelAnimationFrame(this._raf);
            if (this._ro) this._ro.disconnect();
            if (this._canvas) {
                if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
                if (this._onRestored) this._canvas.removeEventListener('webglcontextrestored', this._onRestored, false);
            }
            const gl = this._gl;
            if (gl) {
                if (this._buffer) gl.deleteBuffer(this._buffer);
                if (this._program) gl.deleteProgram(this._program);
                gl.getExtension('WEBGL_lose_context')?.loseContext();
            }
            this._raf = null;
            this._ro = null;
            this._gl = this._program = this._buffer = this._locs = null;
        },
    }));
});
resources/js/ui/shader-velora.js JS
/**
 * Velora shader preset behaviour (WebGL warp-tunnel streaks).
 *
 * A full-bleed <canvas> renders a hyperspace-style tunnel: 3D fractal-noise
 * streaks projected through a twisting cylinder toward the centre, graded across
 * three colour stops with centre/edge fades and film grain. Pure WebGL on a
 * screen-filling triangle strip; no library. The host is decorative (aria-hidden
 * / pointer-events-none, set in Blade) so it never traps focus or blocks the
 * content layered above it.
 *
 * COLOUR: the three stops default to the theme's `--background` / `--primary` /
 * `--accent` tokens (bottom = background, mid = primary, top = accent),
 * resolved to WebGL rgb via `tokenRgb()` (a probe element + getComputedStyle,
 * same technique as the `backdrop` gradient variant). A consumer may still
 * override any stop with hex via `colorBottom`/`colorMid`/`colorTop`; the
 * fixed indigo floats below are only the fallback before tokens resolve.
 *
 * PERFORMANCE: device-pixel ratio is capped; the backing store is rebuilt only
 * when a ResizeObserver fires. Reduced motion paints a single static frame and
 * never starts the rAF loop. destroy() tears everything down so unmounting
 * mid-animation cannot leak.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    const VERT = `
attribute vec2 position;
varying vec2 vUv;
void main() {
  vUv = (position + 1.0) * 0.5;
  gl_Position = vec4(position, 0.0, 1.0);
}`;

    const FRAG = `
precision highp float;

uniform vec2 u_resolution;
uniform float u_time;
uniform vec3 u_colorBottom;
uniform vec3 u_colorMid;
uniform vec3 u_colorTop;
uniform float u_flowStrength;
uniform float u_grain;
uniform float u_speed;

varying vec2 vUv;

float hash(vec3 p) {
  return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453123);
}

float noise(vec3 p) {
  vec3 i = floor(p);
  vec3 f = fract(p);
  vec3 u = f * f * (3.0 - 2.0 * f);
  return mix(
    mix(mix(hash(i + vec3(0.0, 0.0, 0.0)), hash(i + vec3(1.0, 0.0, 0.0)), u.x),
        mix(hash(i + vec3(0.0, 1.0, 0.0)), hash(i + vec3(1.0, 1.0, 0.0)), u.x), u.y),
    mix(mix(hash(i + vec3(0.0, 0.0, 1.0)), hash(i + vec3(1.0, 0.0, 1.0)), u.x),
        mix(hash(i + vec3(0.0, 1.0, 1.0)), hash(i + vec3(1.0, 1.0, 1.0)), u.x), u.y), u.z
  );
}

float fbm(vec3 p) {
  float v = 0.0;
  float a = 0.5;
  for (int i = 0; i < 4; i++) {
    v += a * noise(p);
    p *= 2.0;
    a *= 0.5;
  }
  return v;
}

void main() {
  vec2 uv = (vUv - 0.5) * vec2(u_resolution.x / u_resolution.y, 1.0);

  float radius = length(uv);
  float angle = atan(uv.y, uv.x);

  float z = 1.0 / max(radius, 0.001);
  float t = u_time * u_speed * 5.0;

  float streakScale = 5.0 * u_flowStrength;

  vec3 cylPos = vec3(
    cos(angle) * streakScale,
    sin(angle) * streakScale,
    (z - t) * 0.2
  );

  float twist = sin(t * 0.1 + z * 0.05) * 2.5;
  float c = cos(twist);
  float s = sin(twist);
  cylPos.xy = mat2(c, -s, s, c) * cylPos.xy;

  float n = fbm(cylPos);
  float streak = smoothstep(0.4, 0.8, n);

  vec3 color = mix(u_colorBottom, u_colorMid, smoothstep(0.3, 0.6, n));
  color = mix(color, u_colorTop, smoothstep(0.6, 1.0, n));

  float centerFade = smoothstep(0.0, 0.25, radius);
  float edgeFade = smoothstep(1.5, 0.4, radius);

  vec3 finalColor = color * streak * centerFade * edgeFade * 3.0;
  finalColor += (fract(sin(dot(vUv + u_time, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) * u_grain;

  gl_FragColor = vec4(clamp(finalColor, 0.0, 1.0), 1.0);
}`;

    const HEX = /^#?[0-9a-fA-F]{6}$/;

    // Reads a CSS custom property off the document root and resolves it to a
    // normalised [r,g,b] (0..1) triple via a probe element (so oklch/hsl
    // tokens collapse to concrete rgb WebGL can use), as the backdrop WebGL
    // variants do. Falls back to a fixed tone when the property is unset or
    // unresolvable.
    const tokenRgb = (name, fallback) => {
        const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
        if (!raw) return fallback;
        const probe = document.createElement('span');
        probe.style.display = 'none';
        document.body.appendChild(probe);
        probe.style.color = raw.includes('(') || raw.startsWith('#') || /[a-z]/i.test(raw) ? raw : `oklch(${raw})`;
        const resolved = getComputedStyle(probe).color;
        probe.remove();
        const m = resolved.match(/-?\d*\.?\d+/g);
        return m && m.length >= 3 ? [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255] : fallback;
    };

    // A hex override resolves via straight hex parsing; anything else (the
    // token-derived default, an [r,g,b] triple already) passes through.
    const toRgb = (value, fallback) => {
        if (typeof value === 'string' && HEX.test(value.trim())) {
            const h = value.trim().replace('#', '');
            return [
                parseInt(h.slice(0, 2), 16) / 255,
                parseInt(h.slice(2, 4), 16) / 255,
                parseInt(h.slice(4, 6), 16) / 255,
            ];
        }
        return fallback;
    };

    // Tunnel tones derived from the theme (bottom = background, mid =
    // primary, top = accent); fixed indigo floats are the fallback before
    // tokens resolve.
    const DEFAULTS = {
        bottom: tokenRgb('--background', [0.016, 0.020, 0.043]),
        mid: tokenRgb('--primary', [0.263, 0.220, 0.792]),
        top: tokenRgb('--accent', [0.647, 0.706, 0.980]),
    };

    window.Alpine.data('uiShaderVelora', (config = {}) => ({
        colorBottom: config.colorBottom ?? DEFAULTS.bottom,
        colorMid: config.colorMid ?? DEFAULTS.mid,
        colorTop: config.colorTop ?? DEFAULTS.top,
        flowStrength: config.flowStrength ?? 1,
        grain: Math.max(0, config.grain ?? 0.04),
        speed: Math.max(0, config.speed ?? 0.5),

        _canvas: null,
        _gl: null,
        _program: null,
        _buffer: null,
        _locs: null,
        _raf: null,
        _ro: null,
        _start: 0,
        _reduced: false,
        _onLost: null,
        _onRestored: null,
        _viewObserver: null,
        _offscreen: false,

        init() {
            this._canvas = this.$refs.canvas;
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
            this._start = performance.now();

            this._onLost = (e) => {
                e.preventDefault();
                if (this._raf) {
                    cancelAnimationFrame(this._raf);
                    this._raf = null;
                }
            };
            this._onRestored = () => this.boot();
            this._canvas.addEventListener('webglcontextlost', this._onLost, false);
            this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);

            this.boot();

            // Pause the rAF loop while fully scrolled out of view (same-tab;
            // backgrounded tabs are already throttled by the browser) so
            // off-screen instances don't burn GPU/CPU. Resume on re-entry.
            // Static/reduced-motion renders never set _raf, so the `&& this._raf`
            // guard leaves them untouched.
            if (typeof IntersectionObserver !== 'undefined') {
                this._viewObserver = new IntersectionObserver((entries) => {
                    const visible = entries.some((e) => e.isIntersecting);
                    if (!visible && this._raf) {
                        cancelAnimationFrame(this._raf);
                        this._raf = null;
                        this._offscreen = true;
                    } else if (visible && this._offscreen) {
                        this._offscreen = false;
                        this._raf = requestAnimationFrame((t) => this.loop(t));
                    }
                });
                this._viewObserver.observe(this.$refs.root);
            }
        },

        boot() {
            const gl = this._canvas.getContext('webgl', { antialias: true });
            if (!gl) return;
            this._gl = gl;

            const compile = (type, src) => {
                const s = gl.createShader(type);
                gl.shaderSource(s, src);
                gl.compileShader(s);
                return s;
            };

            const program = gl.createProgram();
            gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
            gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
            gl.linkProgram(program);
            gl.useProgram(program);
            this._program = program;

            this._buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
            gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
            const pos = gl.getAttribLocation(program, 'position');
            gl.enableVertexAttribArray(pos);
            gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);

            this._locs = {
                res: gl.getUniformLocation(program, 'u_resolution'),
                time: gl.getUniformLocation(program, 'u_time'),
                bottom: gl.getUniformLocation(program, 'u_colorBottom'),
                mid: gl.getUniformLocation(program, 'u_colorMid'),
                top: gl.getUniformLocation(program, 'u_colorTop'),
                flow: gl.getUniformLocation(program, 'u_flowStrength'),
                grain: gl.getUniformLocation(program, 'u_grain'),
                speed: gl.getUniformLocation(program, 'u_speed'),
            };

            this.resize();
            if (!this._ro) {
                this._ro = new ResizeObserver(() => this.resize());
                this._ro.observe(this.$refs.root);
            }

            if (this._reduced) {
                this.draw(0);
                return;
            }
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        resize() {
            const gl = this._gl;
            if (!gl || gl.isContextLost()) return;
            const root = this.$refs.root;
            const canvas = this.$refs.canvas;
            const dpr = Math.min(window.devicePixelRatio || 1, 2);
            canvas.width = Math.max(1, Math.floor(root.clientWidth * dpr));
            canvas.height = Math.max(1, Math.floor(root.clientHeight * dpr));
            gl.viewport(0, 0, canvas.width, canvas.height);
            gl.uniform2f(this._locs.res, canvas.width, canvas.height);
            if (this._reduced) this.draw(0);
        },

        draw(elapsedMs) {
            const gl = this._gl;
            if (!gl || gl.isContextLost()) return;
            const cb = toRgb(this.colorBottom, DEFAULTS.bottom);
            const cm = toRgb(this.colorMid, DEFAULTS.mid);
            const ct = toRgb(this.colorTop, DEFAULTS.top);
            gl.uniform1f(this._locs.time, elapsedMs / 1000);
            gl.uniform3f(this._locs.bottom, cb[0], cb[1], cb[2]);
            gl.uniform3f(this._locs.mid, cm[0], cm[1], cm[2]);
            gl.uniform3f(this._locs.top, ct[0], ct[1], ct[2]);
            gl.uniform1f(this._locs.flow, this.flowStrength);
            gl.uniform1f(this._locs.grain, this.grain);
            gl.uniform1f(this._locs.speed, this.speed);
            gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
        },

        loop(now) {
            this.draw(now - this._start);
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        destroy() {
            if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
            if (this._raf) cancelAnimationFrame(this._raf);
            if (this._ro) this._ro.disconnect();
            if (this._canvas) {
                if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
                if (this._onRestored) this._canvas.removeEventListener('webglcontextrestored', this._onRestored, false);
            }
            const gl = this._gl;
            if (gl) {
                if (this._buffer) gl.deleteBuffer(this._buffer);
                if (this._program) gl.deleteProgram(this._program);
                gl.getExtension('WEBGL_lose_context')?.loseContext();
            }
            this._raf = null;
            this._ro = null;
            this._gl = this._program = this._buffer = this._locs = null;
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.0.1
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0