Files
mintel.me/apps/web/src/components/bits/BitAura.tsx
T
mmintelandClaude Opus 5.5 60f094a754 fix: quiet sparks instead of dashed rings around the automation panel
The pulsing dashed rings around the example workflow read as a second,
broken frame. The panel now sheds a few digits at its left edge like the
other frames on light backgrounds, and the ring motion, which nothing else
used, is removed from BitAura.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:58:34 +02:00

407 lines
12 KiB
TypeScript

"use client";
import * as React from "react";
type AuraSource = "top" | "right" | "bottom" | "left" | "around";
type AuraTone = "dark" | "light";
/** How the energy behaves: `sparks` drift off the edge, `rain` falls onto it. */
type AuraMotion = "sparks" | "rain";
interface BitAuraProps {
/** The edge the energy sits on, or `around` to let it travel round the element. */
source: AuraSource;
/** The background the element stands on: `dark` adds light, `light` only ink digits. */
tone: AuraTone;
motion?: AuraMotion;
/** Share of the usual amount of digits (1 = default). */
density?: number;
/** Corner radius of the lit element in px. */
radius?: number;
className?: string;
children: React.ReactNode;
}
interface Box {
width: number;
height: number;
}
/** A bright zone on the outline, placed by its position along the perimeter (0..1). */
interface Spot {
at: number;
power: number;
}
interface Digit {
x: number;
y: number;
vx: number;
vy: number;
age: number;
life: number;
seed: number;
power: number;
large: boolean;
/** Outward normal at the point of the outline the digit belongs to. */
nx: number;
ny: number;
}
interface Palette {
blend: GlobalCompositeOperation;
glow: string;
core: string;
digit: string;
gain: number;
/** Share of the digit sparks that are kept (1 = all). */
density: number;
}
const MARGIN = 180;
const FRAME_MS = 1000 / 30;
const MAX_STEP_S = 0.1;
const MAX_PIXEL_RATIO = 2;
/** `rounded-3xl` in this project's Tailwind theme. */
const DEFAULT_RADIUS = 32;
const STILL_FRAME_STEPS = 90;
/** The glow is the outline stroked from wide and faint to thin and bright. */
const GLOW_LAYERS = [
{ width: 150, alpha: 0.035, hot: false },
{ width: 110, alpha: 0.05, hot: false },
{ width: 78, alpha: 0.07, hot: false },
{ width: 52, alpha: 0.1, hot: false },
{ width: 32, alpha: 0.15, hot: false },
{ width: 18, alpha: 0.22, hot: false },
{ width: 7, alpha: 0.45, hot: true },
{ width: 2, alpha: 0.95, hot: true },
] as const;
const LIGHT_PALETTE: Palette = {
blend: "source-over",
glow: "15, 23, 42",
core: "15, 23, 42",
digit: "15, 23, 42",
// No haze on light backgrounds: coloured light only reads on dark. Ink digits only.
gain: 0,
density: 0.6,
};
const DARK_PALETTE: Palette = {
blend: "lighter",
glow: "99, 102, 241",
core: "224, 231, 255",
digit: "199, 210, 254",
gain: 1,
density: 1,
};
const MOTION_DENSITY: Record<AuraMotion, number> = {
sparks: 0.45,
rain: 0.3,
};
/** How often a drifting digit switches between 0 and 1. */
const FLIPS_PER_S = 0.9;
const fraction = (value: number) => value - Math.floor(value);
const perimeter = ({ width, height }: Box) => 2 * (width + height);
/** Point and outward normal on the outline, walking clockwise from the top left corner. */
const outlinePoint = (at: number, { width, height }: Box) => {
let distance = fraction(at) * 2 * (width + height);
if (distance < width) return { x: distance, y: 0, nx: 0, ny: -1 };
distance -= width;
if (distance < height) return { x: width, y: distance, nx: 1, ny: 0 };
distance -= height;
if (distance < width) return { x: width - distance, y: height, nx: 0, ny: 1 };
distance -= width;
return { x: 0, y: height - distance, nx: -1, ny: 0 };
};
/** The stretch of the perimeter (0..1) that one edge covers. */
const edgeRange = (
source: Exclude<AuraSource, "around">,
box: Box,
): readonly [number, number] => {
const total = perimeter(box);
const top = box.width / total;
const right = (box.width + box.height) / total;
const bottom = (2 * box.width + box.height) / total;
if (source === "top") return [0, top];
if (source === "right") return [top, right];
if (source === "bottom") return [right, bottom];
return [bottom, 1];
};
/** Where the energy is at time `t` (seconds): it wanders along one edge or circles the element. */
const spotsAt = (
source: AuraSource,
motion: AuraMotion,
t: number,
box: Box,
): readonly Spot[] => {
if (source === "around") {
return [
{ at: fraction(t * 0.03), power: 1 },
{ at: fraction(t * 0.03 + 0.5), power: 0.7 },
{ at: fraction(1.2 - t * 0.018), power: 0.5 },
];
}
const [from, to] = edgeRange(source, box);
const along = (share: number) => from + (to - from) * share;
return [
{
at: along(0.5 + 0.32 * Math.sin(t * 0.5)),
power: 0.85 + 0.15 * Math.sin(t * 0.9),
},
{ at: along(0.5 + 0.4 * Math.sin(t * 0.31 + 2)), power: 0.6 },
{
at: along(0.5 + 0.45 * Math.sin(t * 0.83 + 4)),
power: 0.35 + 0.2 * Math.sin(t * 1.3),
},
];
};
const spawnDigit = (
spots: readonly Spot[],
motion: AuraMotion,
box: Box,
): Digit => {
const total = spots.reduce((sum, spot) => sum + spot.power, 0);
let pick = Math.random() * total;
const spot =
spots.find((candidate) => (pick -= candidate.power) <= 0) ?? spots[0];
const power = spot?.power ?? 1;
const base = {
age: 0,
seed: Math.random(),
power,
large: Math.random() < 0.22,
};
const wide = motion === "rain" ? 0.12 : 0.07;
const scatter = (Math.random() + Math.random() + Math.random() - 1.5) * wide;
const point = outlinePoint((spot?.at ?? 0) + scatter, box);
if (motion === "rain") {
const distance = 40 + Math.random() * 80;
const speed = 22 + Math.random() * 26;
return {
...base,
x: point.x + point.nx * distance,
y: point.y + point.ny * distance,
vx: -point.nx * speed,
vy: -point.ny * speed,
nx: point.nx,
ny: point.ny,
life: distance / speed,
};
}
const speed = 10 + Math.random() * 24;
const slide = (Math.random() - 0.5) * 12;
return {
...base,
x: point.x,
y: point.y,
vx: point.nx * speed - point.ny * slide,
vy: point.ny * speed + point.nx * slide,
nx: point.nx,
ny: point.ny,
life: 2.4 + Math.random() * 2.6,
};
};
/**
* BitAura: energy that hugs the outline of the element it wraps. The glow flows
* along the edge and binary digits drift off it like sparks. Decorative only:
* it runs while in view and stands still when reduced motion is requested.
*/
export const BitAura: React.FC<BitAuraProps> = ({
source,
tone,
motion = "sparks",
density = 1,
radius = DEFAULT_RADIUS,
className = "",
children,
}) => {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const wrapper = wrapperRef.current;
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!wrapper || !canvas || !context) return;
const palette = tone === "light" ? LIGHT_PALETTE : DARK_PALETTE;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const fontFamily = window.getComputedStyle(canvas).fontFamily;
let box: Box = { width: 0, height: 0 };
let digits: Digit[] = [];
let owed = 0;
let clock = Math.random() * 40;
let last = 0;
let frame = 0;
let visible = false;
const step = (seconds: number) => {
clock += seconds;
const spots = spotsAt(source, motion, clock, box);
const rate =
Math.min(90, Math.max(28, perimeter(box) / 30)) *
palette.density *
MOTION_DENSITY[motion] *
density;
owed += rate * seconds;
while (owed >= 1) {
digits.push(spawnDigit(spots, motion, box));
owed -= 1;
}
const drag = 1 - 0.6 * seconds;
digits = digits.filter((digit) => {
digit.age += seconds;
if (motion !== "rain") {
digit.vx *= drag;
digit.vy = digit.vy * drag - 7 * seconds;
}
digit.x += digit.vx * seconds;
digit.y += digit.vy * seconds;
return digit.age < digit.life;
});
return spots;
};
const draw = (spots: readonly Spot[]) => {
const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
context.setTransform(ratio, 0, 0, ratio, MARGIN * ratio, MARGIN * ratio);
context.clearRect(
-MARGIN,
-MARGIN,
box.width + 2 * MARGIN,
box.height + 2 * MARGIN,
);
context.globalCompositeOperation = palette.blend;
context.globalAlpha = 1;
context.beginPath();
context.roundRect(0, 0, box.width, box.height, radius);
const reach = Math.min(560, Math.max(220, perimeter(box) * 0.2));
for (const spot of palette.gain > 0 ? spots : []) {
const point = outlinePoint(spot.at, box);
for (const layer of GLOW_LAYERS) {
const colour = layer.hot ? palette.core : palette.glow;
const light = context.createRadialGradient(
point.x,
point.y,
0,
point.x,
point.y,
reach,
);
light.addColorStop(
0,
`rgba(${colour}, ${layer.alpha * spot.power * palette.gain})`,
);
light.addColorStop(1, `rgba(${colour}, 0)`);
context.strokeStyle = light;
context.lineWidth = layer.width;
context.stroke();
}
}
context.fillStyle = `rgb(${palette.digit})`;
context.textAlign = "center";
context.textBaseline = "middle";
for (const large of [false, true]) {
context.font = `${large ? 700 : 500} ${large ? 14 : 11}px ${fontFamily}`;
for (const digit of digits) {
if (digit.large !== large) continue;
const fade = Math.sin((Math.PI * digit.age) / digit.life);
context.globalAlpha = Math.min(1, fade * digit.power * 1.1);
const one =
Math.floor(digit.age * FLIPS_PER_S + digit.seed * 10) % 2 === 0;
context.fillText(one ? "1" : "0", digit.x, digit.y);
}
}
};
const resize = () => {
const rect = wrapper.getBoundingClientRect();
box = { width: rect.width, height: rect.height };
const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
canvas.width = Math.round((box.width + 2 * MARGIN) * ratio);
canvas.height = Math.round((box.height + 2 * MARGIN) * ratio);
if (still) {
digits = [];
let spots = spotsAt(source, motion, clock, box);
for (let i = 0; i < STILL_FRAME_STEPS; i++)
spots = step(FRAME_MS / 1000);
draw(spots);
}
};
const tick = (now: number) => {
frame = window.requestAnimationFrame(tick);
if (now - last < FRAME_MS) return;
const seconds = Math.min(MAX_STEP_S, (now - last) / 1000);
last = now;
draw(step(seconds));
};
const sizeObserver = new ResizeObserver(resize);
sizeObserver.observe(wrapper);
resize();
const viewObserver = new IntersectionObserver(
([entry]) => {
const next = entry?.isIntersecting ?? false;
if (next === visible || still) return;
visible = next;
if (visible) {
last = performance.now();
frame = window.requestAnimationFrame(tick);
} else {
window.cancelAnimationFrame(frame);
}
},
{ rootMargin: `${MARGIN}px` },
);
viewObserver.observe(wrapper);
return () => {
sizeObserver.disconnect();
viewObserver.disconnect();
window.cancelAnimationFrame(frame);
};
}, [source, tone, motion, density, radius]);
return (
<div
ref={wrapperRef}
// The wrapper has to be positioned; keep the caller's own positioning if it brings one.
className={
className.split(" ").includes("absolute")
? className
: `relative ${className}`
}
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="bit-aura pointer-events-none absolute font-mono"
style={{
left: -MARGIN,
top: -MARGIN,
width: `calc(100% + ${2 * MARGIN}px)`,
height: `calc(100% + ${2 * MARGIN}px)`,
}}
/>
<div className="relative h-full">{children}</div>
</div>
);
};