Files
CashuMints.space/web/src/components/ColorBends.astro
T
2026-08-20 22:41:25 +02:00

123 lines
4.3 KiB
Plaintext

---
/**
* Island: the animated colour field behind the hero.
*
* The renderer is in scripts/color-bends.ts; this is the layer it draws into and the
* fallback for everyone who never reaches it. The canvas is decorative, so it is
* hidden from assistive technology and takes no pointer events: the hero's search box
* sits on top of it and has to stay clickable.
*
* With JavaScript off, without WebGL, or in the moment before the first frame, the
* gradient below is all there is — the same colour in the same place, holding still.
* The hero never renders as a blank band.
*/
import type { ColorBendsOptions } from '../scripts/color-bends';
interface Props extends ColorBendsOptions {
/** Opacity of the whole layer. The shader parameters stay as tuned; this is the mix. */
opacity?: number;
class?: string;
}
const { opacity = 0.55, class: className, ...bends } = Astro.props;
/**
* Serialised for the module script, which is a separate file and cannot read props.
* The renderer's own defaults fill in anything not passed here.
*/
const config = JSON.stringify(bends);
const first = bends.colors?.[0] ?? '#A78BFA';
---
<div class={['color-bends', className].filter(Boolean).join(' ')} style={`--cb-opacity:${opacity};--cb-tint:${first}`}>
<canvas data-color-bends data-config={config} aria-hidden="true"></canvas>
</div>
<style>
.color-bends {
position: absolute;
inset: 0;
overflow: hidden;
/* Decoration, behind the hero's own content and deaf to the pointer. */
z-index: 0;
pointer-events: none;
opacity: var(--cb-opacity);
/*
* The field is a wash, not a panel: it fades out before it reaches any edge, so
* there is no rectangle for the eye to find and no seam where it meets the pulse
* strip below. Both axes, multiplied together by the two mask layers.
*/
-webkit-mask-image:
linear-gradient(to bottom, transparent, #000 22%, #000 62%, transparent 100%),
linear-gradient(to right, transparent, #000 18%, #000 82%, transparent);
-webkit-mask-composite: source-in;
mask-image:
linear-gradient(to bottom, transparent, #000 22%, #000 62%, transparent 100%),
linear-gradient(to right, transparent, #000 18%, #000 82%, transparent);
mask-composite: intersect;
}
/*
* The base wash, under the canvas and never removed: the bands are sparse at this
* scale and it is what fills the space between them. It doubles as the fallback,
* which is why nothing has to be swapped in when there is no WebGL — what is left is
* this, and it reads as a deliberate glow rather than a hole. `--cb-tint` is the
* first colour passed to the shader, so the two agree without repeating the hex.
*/
.color-bends::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(120% 80% at 50% 18%, var(--cb-tint), transparent 62%);
opacity: .5;
}
.color-bends canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
</style>
<script>
import { createColorBends, type ColorBendsOptions, type ColorBendsHandle } from '../scripts/color-bends';
import { onLeave, onReady } from '../scripts/reveal';
const setup = () => {
const canvas = document.querySelector<HTMLCanvasElement>('[data-color-bends]');
if (!canvas) return;
let options: ColorBendsOptions = {};
try {
options = JSON.parse(canvas.dataset.config ?? '{}') as ColorBendsOptions;
} catch {
// Unreadable config is not worth a dead hero: the renderer's defaults will do.
}
let handle: ColorBendsHandle | null = null;
/*
* One frame's delay. The hero's own entrance is the first thing that happens on
* this page and compiling a shader on the same frame stutters it; nothing is
* missing in the meantime, because the gradient fallback is already on screen.
*/
const armed = requestAnimationFrame(() => {
handle = createColorBends(canvas, options);
});
/*
* The router replaces this page's DOM but not its JavaScript. Without this the
* context outlives its canvas, and browsers drop the oldest live context once
* enough of them pile up — which would blank a hero somebody navigated back to.
*/
onLeave(() => {
cancelAnimationFrame(armed);
handle?.destroy();
handle = null;
});
};
onReady(setup);
</script>