panelui-web 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The shapes NotFound draws, as grids of cells.
3
+ *
4
+ * A digit is drawn at 5 by 7 and scaled up three times, so every stroke is
5
+ * three cells wide. Kept whole that is a solid pixel digit; reduced to the
6
+ * cells on its edge it is a digit drawn in outline, two rows of dots with a
7
+ * gap between, which is the look a stroked numeral has at this size.
8
+ *
9
+ * Digits are drawn without diagonal strokes. Two blocks that meet only at a
10
+ * corner come apart when reduced to their edges, so a slanted stroke would
11
+ * outline as a stair of separate rings.
12
+ *
13
+ * The icons Scramble turns a digit into are drawn straight onto the same 15 by
14
+ * 21 grid, from circles and lines, so they arrive at exactly the size of the
15
+ * digit they replace and need no scaling of their own.
16
+ */
17
+ /** Cells across one glyph. */
18
+ export declare const GLYPH_COLUMNS = 15;
19
+ /** Cells down one glyph. */
20
+ export declare const GLYPH_ROWS = 21;
21
+ /** A glyph: which cells are on, row by row. */
22
+ export type Glyph = boolean[][];
23
+ /** A digit, solid or as its outline. */
24
+ export declare function digitGlyph(character: string, fill: 'outline' | 'solid'): Glyph | null;
25
+ /** The icons Scramble cycles through, in order. */
26
+ export declare const ICONS: readonly Glyph[];
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The shapes NotFound draws, as grids of cells.
3
+ *
4
+ * A digit is drawn at 5 by 7 and scaled up three times, so every stroke is
5
+ * three cells wide. Kept whole that is a solid pixel digit; reduced to the
6
+ * cells on its edge it is a digit drawn in outline, two rows of dots with a
7
+ * gap between, which is the look a stroked numeral has at this size.
8
+ *
9
+ * Digits are drawn without diagonal strokes. Two blocks that meet only at a
10
+ * corner come apart when reduced to their edges, so a slanted stroke would
11
+ * outline as a stair of separate rings.
12
+ *
13
+ * The icons Scramble turns a digit into are drawn straight onto the same 15 by
14
+ * 21 grid, from circles and lines, so they arrive at exactly the size of the
15
+ * digit they replace and need no scaling of their own.
16
+ */
17
+ /** Cells across one glyph. */
18
+ export const GLYPH_COLUMNS = 15;
19
+ /** Cells down one glyph. */
20
+ export const GLYPH_ROWS = 21;
21
+ const SCALE = 3;
22
+ const DIGITS = {
23
+ '0': ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
24
+ '1': ['.##..', '..#..', '..#..', '..#..', '..#..', '..#..', '.###.'],
25
+ '2': ['.###.', '#...#', '....#', '.###.', '#....', '#....', '#####'],
26
+ '3': ['####.', '....#', '....#', '.###.', '....#', '....#', '####.'],
27
+ '4': ['#..#.', '#..#.', '#..#.', '#####', '...#.', '...#.', '...#.'],
28
+ '5': ['#####', '#....', '####.', '....#', '....#', '....#', '####.'],
29
+ '6': ['.###.', '#....', '#....', '####.', '#...#', '#...#', '.###.'],
30
+ '7': ['#####', '....#', '....#', '...##', '...#.', '...#.', '...#.'],
31
+ '8': ['.###.', '#...#', '#...#', '.###.', '#...#', '#...#', '.###.'],
32
+ '9': ['.###.', '#...#', '#...#', '.####', '....#', '....#', '.###.'],
33
+ };
34
+ function empty() {
35
+ return Array.from({ length: GLYPH_ROWS }, () => Array(GLYPH_COLUMNS).fill(false));
36
+ }
37
+ /** A digit as a solid grid of cells, or `null` for a character there is no drawing of. */
38
+ function solidDigit(character) {
39
+ const rows = DIGITS[character];
40
+ if (!rows)
41
+ return null;
42
+ const glyph = empty();
43
+ for (let y = 0; y < GLYPH_ROWS; y += 1) {
44
+ for (let x = 0; x < GLYPH_COLUMNS; x += 1) {
45
+ glyph[y][x] = rows[Math.floor(y / SCALE)][Math.floor(x / SCALE)] === '#';
46
+ }
47
+ }
48
+ return glyph;
49
+ }
50
+ /**
51
+ * Only the cells of a glyph that touch a cell outside it, corners included.
52
+ * Counting corners keeps a diagonal stroke as a continuous band; counting
53
+ * only sides leaves it as a scatter of single dots.
54
+ */
55
+ function outlineOf(glyph) {
56
+ const on = (x, y) => glyph[y]?.[x] === true;
57
+ return glyph.map((row, y) => row.map((cell, x) => {
58
+ if (!cell)
59
+ return false;
60
+ for (let dy = -1; dy <= 1; dy += 1) {
61
+ for (let dx = -1; dx <= 1; dx += 1)
62
+ if (!on(x + dx, y + dy))
63
+ return true;
64
+ }
65
+ return false;
66
+ }));
67
+ }
68
+ /** A digit, solid or as its outline. */
69
+ export function digitGlyph(character, fill) {
70
+ const solid = STROKED[character] ?? solidDigit(character);
71
+ if (!solid)
72
+ return null;
73
+ return fill === 'solid' ? solid : outlineOf(solid);
74
+ }
75
+ /* -------------------------------------------------------------------------- */
76
+ /* Icons */
77
+ /* -------------------------------------------------------------------------- */
78
+ /** Distance from a point to the segment from a to b. */
79
+ function toSegment(px, py, ax, ay, bx, by) {
80
+ const dx = bx - ax;
81
+ const dy = by - ay;
82
+ const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy || 1)));
83
+ return Math.hypot(px - (ax + dx * t), py - (ay + dy * t));
84
+ }
85
+ /** A glyph from a test of each cell's centre. */
86
+ function drawn(test) {
87
+ const glyph = empty();
88
+ for (let y = 0; y < GLYPH_ROWS; y += 1) {
89
+ for (let x = 0; x < GLYPH_COLUMNS; x += 1)
90
+ glyph[y][x] = test(x + 0.5, y + 0.5);
91
+ }
92
+ return glyph;
93
+ }
94
+ /** A ring: cells whose centre is within half a stroke of the circle. */
95
+ const ring = (x, y, cx, cy, r, stroke) => Math.abs(Math.hypot(x - cx, y - cy) - r) <= stroke / 2;
96
+ /** A question mark: a hook, a short stem, a dot. */
97
+ const question = drawn((x, y) => {
98
+ const angle = Math.atan2(y - 6.5, x - 7.5);
99
+ // The hook runs from the left of the circle, over the top, round to the
100
+ // lower right, and stops there so the stem can drop from it.
101
+ const onHook = ring(x, y, 7.5, 6.5, 4.6, 2) && (y < 6.5 || angle < Math.PI * 0.35);
102
+ const stem = toSegment(x, y, 9.4, 10.6, 7.5, 13.2) <= 1 || toSegment(x, y, 7.5, 13.2, 7.5, 14.5) <= 1;
103
+ const dot = Math.hypot(x - 7.5, y - 18) <= 1.3;
104
+ return onHook || stem || dot;
105
+ });
106
+ /** A magnifying glass, the lens high and the handle running to the lower right. */
107
+ const magnifier = drawn((x, y) => ring(x, y, 6.5, 7.5, 4.8, 2) || toSegment(x, y, 10.2, 11.6, 13.4, 17.6) <= 1.2);
108
+ /** A map pin: a ring with a dot in it, narrowing to a point. */
109
+ const pin = drawn((x, y) => {
110
+ const head = ring(x, y, 7.5, 7.5, 5, 2) && y < 10.5;
111
+ // The two sides run from the widest part of the ring down to the point.
112
+ const sides = toSegment(x, y, 3, 9.6, 7.5, 18.6) <= 1 || toSegment(x, y, 12, 9.6, 7.5, 18.6) <= 1;
113
+ const dot = Math.hypot(x - 7.5, y - 7.5) <= 1.5;
114
+ return head || sides || dot;
115
+ });
116
+ /*
117
+ * Digits drawn from strokes rather than scaled up, for the ones whose shape
118
+ * needs a diagonal. Each stroke is three cells wide, the same as a scaled-up
119
+ * pixel, so they sit with the others.
120
+ */
121
+ const stroke = (x, y, ax, ay, bx, by) => toSegment(x, y, ax, ay, bx, by) <= 1.5;
122
+ /** Distance from a point to an ellipse's outline, near enough for drawing at this size. */
123
+ const toEllipse = (x, y, cx, cy, rx, ry) => {
124
+ const nx = (x - cx) / rx;
125
+ const ny = (y - cy) / ry;
126
+ const r = Math.hypot(nx, ny);
127
+ // Scale the radial error back to cells by the radius in that direction.
128
+ const along = r === 0 ? Math.min(rx, ry) : Math.hypot(nx * rx, ny * ry) / r;
129
+ return Math.abs(r - 1) * along;
130
+ };
131
+ const STROKED = {
132
+ // An oval nought, not a box with its corners cut.
133
+ '0': drawn((x, y) => toEllipse(x, y, 7.5, 10.5, 6, 9) <= 1.5),
134
+ // A closed four: the diagonal runs from the top of the stem down to the
135
+ // crossbar, and the crossbar reaches a little past the stem.
136
+ '4': drawn((x, y) => stroke(x, y, 10.5, 1.5, 10.5, 19.5) ||
137
+ stroke(x, y, 10.5, 1.5, 1.5, 14.5) ||
138
+ stroke(x, y, 1.5, 14.5, 13.5, 14.5)),
139
+ };
140
+ /** The icons Scramble cycles through, in order. */
141
+ export const ICONS = [question, magnifier, pin];
@@ -0,0 +1,118 @@
1
+ /**
2
+ * NotFound — an error code drawn in pixels that come apart when the pointer
3
+ * reaches them, with a title, a line of help and a way out underneath.
4
+ *
5
+ * ```tsx
6
+ * <NotFound>
7
+ * <NotFound.Code code="404" effect="scatter" />
8
+ * <NotFound.Title>Nothing lives here</NotFound.Title>
9
+ * <NotFound.Description>The address may be mistyped, or the page has moved.</NotFound.Description>
10
+ * <NotFound.Actions>…</NotFound.Actions>
11
+ * </NotFound>
12
+ * ```
13
+ *
14
+ * ## A grid of cells, not a font
15
+ *
16
+ * Each digit is a small bitmap laid on a grid of cells, and every cell is
17
+ * drawn as a dot or a rounded square of its own size and strength. The sizes
18
+ * and strengths are fixed per cell from a seed, so the code has the uneven,
19
+ * hand-placed texture of pixel art and still looks the same on every render.
20
+ * Nothing depends on a typeface loading.
21
+ *
22
+ * ## One digit reacts at a time
23
+ *
24
+ * The digit nearest the pointer is the one that reacts, and it is put back
25
+ * when the pointer moves away. `effect` chooses how: its cells fly apart
26
+ * (`scatter`), drop out one by one (`dissolve`), or travel into a small icon
27
+ * and back (`scramble`). On a touch screen a tap does the same, and the digit
28
+ * is put back a moment after the finger lifts.
29
+ *
30
+ * ## Two colours, mixed by light
31
+ *
32
+ * Every cell is either the text colour, faint, or the accent. Which one it is
33
+ * depends on how much light falls on it: the highlighted digit is always
34
+ * partly lit, a band of light goes slowly round the whole code, and the
35
+ * pointer lights the cells near it and leaves them fading as it moves on. Each
36
+ * cell turns accent at its own level of light, so a shape in half light is
37
+ * dithered between the two colours, the way pixel art is shaded.
38
+ *
39
+ * ## What it costs
40
+ *
41
+ * The cells are drawn on a canvas, a few hundred of them in about sixteen
42
+ * fills. The band asks for 30 frames a second while the code is on screen and
43
+ * none once it is scrolled away; with `ambient={false}` a frame is only drawn
44
+ * while something is moving. The canvas reads its colours from the theme each
45
+ * time it draws, so a change of theme needs nothing from the page.
46
+ */
47
+ import { type ComponentProps } from 'react';
48
+ /** How the digit near the pointer reacts. */
49
+ export type NotFoundEffect = 'scatter' | 'dissolve' | 'scramble';
50
+ /** Which of the theme's roles the highlighted digit is drawn in. */
51
+ export type NotFoundColor = 'primary' | 'info' | 'success' | 'warning' | 'destructive';
52
+ export interface NotFoundProps extends ComponentProps<'div'> {
53
+ /** Draws a faint grid behind everything, fading out towards the edges. On by default. */
54
+ backdrop?: boolean;
55
+ }
56
+ /** The column the code, the title, the description and the actions stand in, centred. */
57
+ declare function NotFoundRoot({ backdrop, className, children, ...props }: NotFoundProps): import("react").JSX.Element;
58
+ declare namespace NotFoundRoot {
59
+ var displayName: string;
60
+ }
61
+ export interface NotFoundCodeProps extends Omit<ComponentProps<'div'>, 'children' | 'color'> {
62
+ /** The code to draw. Digits only; any other character leaves a blank the width of a digit. */
63
+ code?: string;
64
+ /** How the digit nearest the pointer reacts. */
65
+ effect?: NotFoundEffect;
66
+ /** Digits drawn with solid strokes three cells wide, or as their outline. */
67
+ fill?: 'outline' | 'solid';
68
+ /**
69
+ * A band of light that goes slowly round the code on its own. While it is
70
+ * on, the canvas draws about 30 frames a second when the code is on screen;
71
+ * turn it off and nothing is drawn until the pointer comes near.
72
+ */
73
+ ambient?: boolean;
74
+ /** The theme role of the accent: the highlighted digit, the band of light and the pointer's light. */
75
+ color?: NotFoundColor;
76
+ /**
77
+ * Which digit is lit in `color` even with nothing else lighting it, counting
78
+ * from 0. Defaults to the middle one. `null` lights none of them.
79
+ */
80
+ highlight?: number | null;
81
+ /** What a screen reader says for the code. Defaults to "Error" and the code. */
82
+ label?: string;
83
+ }
84
+ /** The code, drawn in cells on a canvas. Its box keeps the code's shape before anything is drawn. */
85
+ declare function NotFoundCode({ code, effect, fill, color, ambient, highlight, label, className, style, ...props }: NotFoundCodeProps): import("react").JSX.Element;
86
+ declare namespace NotFoundCode {
87
+ var displayName: string;
88
+ }
89
+ export interface NotFoundTitleProps extends ComponentProps<'h1'> {
90
+ /** The heading level. A page that is only this one message wants `h1`. */
91
+ as?: 'h1' | 'h2' | 'h3';
92
+ }
93
+ /** What happened, in a few words. */
94
+ declare function NotFoundTitle({ as: Tag, className, ...props }: NotFoundTitleProps): import("react").JSX.Element;
95
+ declare namespace NotFoundTitle {
96
+ var displayName: string;
97
+ }
98
+ export interface NotFoundDescriptionProps extends ComponentProps<'p'> {
99
+ }
100
+ /** A line on what to do next. */
101
+ declare function NotFoundDescription({ className, ...props }: NotFoundDescriptionProps): import("react").JSX.Element;
102
+ declare namespace NotFoundDescription {
103
+ var displayName: string;
104
+ }
105
+ export interface NotFoundActionsProps extends ComponentProps<'div'> {
106
+ }
107
+ /** The ways out: links or buttons in a row that wraps on a narrow screen. */
108
+ declare function NotFoundActions({ className, ...props }: NotFoundActionsProps): import("react").JSX.Element;
109
+ declare namespace NotFoundActions {
110
+ var displayName: string;
111
+ }
112
+ export declare const NotFound: typeof NotFoundRoot & {
113
+ Code: typeof NotFoundCode;
114
+ Title: typeof NotFoundTitle;
115
+ Description: typeof NotFoundDescription;
116
+ Actions: typeof NotFoundActions;
117
+ };
118
+ export { NotFoundCode, NotFoundTitle, NotFoundDescription, NotFoundActions };
@@ -0,0 +1,791 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * NotFound — an error code drawn in pixels that come apart when the pointer
5
+ * reaches them, with a title, a line of help and a way out underneath.
6
+ *
7
+ * ```tsx
8
+ * <NotFound>
9
+ * <NotFound.Code code="404" effect="scatter" />
10
+ * <NotFound.Title>Nothing lives here</NotFound.Title>
11
+ * <NotFound.Description>The address may be mistyped, or the page has moved.</NotFound.Description>
12
+ * <NotFound.Actions>…</NotFound.Actions>
13
+ * </NotFound>
14
+ * ```
15
+ *
16
+ * ## A grid of cells, not a font
17
+ *
18
+ * Each digit is a small bitmap laid on a grid of cells, and every cell is
19
+ * drawn as a dot or a rounded square of its own size and strength. The sizes
20
+ * and strengths are fixed per cell from a seed, so the code has the uneven,
21
+ * hand-placed texture of pixel art and still looks the same on every render.
22
+ * Nothing depends on a typeface loading.
23
+ *
24
+ * ## One digit reacts at a time
25
+ *
26
+ * The digit nearest the pointer is the one that reacts, and it is put back
27
+ * when the pointer moves away. `effect` chooses how: its cells fly apart
28
+ * (`scatter`), drop out one by one (`dissolve`), or travel into a small icon
29
+ * and back (`scramble`). On a touch screen a tap does the same, and the digit
30
+ * is put back a moment after the finger lifts.
31
+ *
32
+ * ## Two colours, mixed by light
33
+ *
34
+ * Every cell is either the text colour, faint, or the accent. Which one it is
35
+ * depends on how much light falls on it: the highlighted digit is always
36
+ * partly lit, a band of light goes slowly round the whole code, and the
37
+ * pointer lights the cells near it and leaves them fading as it moves on. Each
38
+ * cell turns accent at its own level of light, so a shape in half light is
39
+ * dithered between the two colours, the way pixel art is shaded.
40
+ *
41
+ * ## What it costs
42
+ *
43
+ * The cells are drawn on a canvas, a few hundred of them in about sixteen
44
+ * fills. The band asks for 30 frames a second while the code is on screen and
45
+ * none once it is scrolled away; with `ambient={false}` a frame is only drawn
46
+ * while something is moving. The canvas reads its colours from the theme each
47
+ * time it draws, so a change of theme needs nothing from the page.
48
+ */
49
+ import { useEffect, useRef, } from 'react';
50
+ import { tv } from 'tailwind-variants';
51
+ import { GLYPH_COLUMNS, GLYPH_ROWS, ICONS, digitGlyph } from './glyphs.js';
52
+ const notFoundVariants = tv({
53
+ slots: {
54
+ // `overflow-x-clip` keeps flying cells from widening the page on a phone.
55
+ // It clips one axis only, so nothing above or below is cut off.
56
+ root: 'relative isolate flex w-full flex-col items-center gap-3 overflow-x-clip px-4 text-center',
57
+ // A faint grid with a small cross where its lines meet, fading out towards
58
+ // the edges. Drawn in the text colour at a few percent, so it suits either theme.
59
+ backdrop: 'pointer-events-none absolute inset-0 -z-10 bg-foreground/15',
60
+ code: 'relative mb-6 w-full max-w-xl select-none',
61
+ // The canvas is larger than the box the code takes in the layout, so
62
+ // scattered cells have somewhere to go. It takes no pointer events: the
63
+ // page under it stays usable while cells are flying over it.
64
+ canvas: 'pointer-events-none absolute text-foreground [outline-color:var(--pu-color)]',
65
+ title: 'text-balance text-2xl font-semibold tracking-tight text-foreground sm:text-3xl',
66
+ description: 'max-w-md text-balance text-base text-muted-foreground',
67
+ actions: 'mt-4 flex flex-wrap items-center justify-center gap-2',
68
+ },
69
+ variants: {
70
+ backdrop: {
71
+ true: { root: 'py-12' },
72
+ },
73
+ color: {
74
+ primary: { code: '[--pu-color:var(--primary)]' },
75
+ info: { code: '[--pu-color:var(--info)]' },
76
+ success: { code: '[--pu-color:var(--success)]' },
77
+ warning: { code: '[--pu-color:var(--warning)]' },
78
+ destructive: { code: '[--pu-color:var(--destructive)]' },
79
+ },
80
+ },
81
+ defaultVariants: { color: 'info' },
82
+ });
83
+ /* -------------------------------------------------------------------------- */
84
+ /* Layout */
85
+ /* -------------------------------------------------------------------------- */
86
+ /**
87
+ * Empty cells between two digits: two of a digit's own pixels. Any closer and
88
+ * the outlines of neighbouring digits read as one pattern.
89
+ */
90
+ const GAP = 6;
91
+ /** Room round the code, in cells, for scattered cells to fly into. */
92
+ const PAD_X = 10;
93
+ const PAD_Y = 8;
94
+ function layoutOf(code) {
95
+ const lefts = [];
96
+ let x = 0;
97
+ for (let i = 0; i < code.length; i += 1) {
98
+ if (i > 0)
99
+ x += GAP;
100
+ lefts.push(x);
101
+ x += GLYPH_COLUMNS;
102
+ }
103
+ return { columns: Math.max(x, 1), lefts };
104
+ }
105
+ /* -------------------------------------------------------------------------- */
106
+ /* Engine */
107
+ /* -------------------------------------------------------------------------- */
108
+ /** A small fast hash, so a cell's look is the same on every render. */
109
+ function seeded(n) {
110
+ let t = (n * 0x9e3779b9) >>> 0;
111
+ t = Math.imul(t ^ (t >>> 16), 0x85ebca6b) >>> 0;
112
+ t = Math.imul(t ^ (t >>> 13), 0xc2b2ae35) >>> 0;
113
+ return ((t ^ (t >>> 16)) >>> 0) / 4294967296;
114
+ }
115
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
116
+ const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
117
+ /** How strong an unlit cell is drawn: three strengths, picked per cell. */
118
+ const PLAIN_TONES = [0.22, 0.34, 0.48];
119
+ /** How lit the highlighted digit is with nothing else lighting it. */
120
+ const HIGHLIGHT_LIGHT = 0.72;
121
+ /** How far the pointer's light reaches, in cells. */
122
+ const HEAT_RADIUS = 5;
123
+ /** How fast a cell lights under the pointer, and how fast it fades after, per second. */
124
+ const HEAT_RISE = 22;
125
+ const HEAT_FALL = 2.6;
126
+ /** How fast the band of light goes round the code, in radians a second. */
127
+ const BAND_SPEED = 0.55;
128
+ /** How wide the band is, in radians either side of its middle. */
129
+ const BAND_WIDTH = 0.7;
130
+ /** Steps of opacity the cells are batched into. Each is one fill per frame. */
131
+ const ALPHA_STEPS = 12;
132
+ /** Cells within this distance of a digit, in cells, make it react. */
133
+ const APPROACH = 2.5;
134
+ /** And it is put back once the pointer is this far away. */
135
+ const RELEASE = 4.5;
136
+ /** How long a tapped digit stays apart after the finger lifts, in ms. */
137
+ const TOUCH_HOLD = 700;
138
+ function makeCell(hx, hy, salt) {
139
+ const seed = seeded(salt);
140
+ const pick = seeded(salt + 7919);
141
+ return {
142
+ hx,
143
+ hy,
144
+ x: hx,
145
+ y: hy,
146
+ vx: 0,
147
+ vy: 0,
148
+ sx: 0,
149
+ sy: 0,
150
+ // Mostly dots, with a square here and there.
151
+ square: pick < 0.28,
152
+ size: 0.56 + seeded(salt + 104729) * 0.34,
153
+ tone: Math.floor(seeded(salt + 1299709) * 3),
154
+ seed,
155
+ pick: seeded(salt + 15485863),
156
+ heat: 0,
157
+ threshold: seed,
158
+ fx: hx,
159
+ fy: hy,
160
+ tx: hx,
161
+ ty: hy,
162
+ fade: 0,
163
+ };
164
+ }
165
+ function cellsOf(glyph, left, salt) {
166
+ const cells = [];
167
+ glyph.forEach((row, y) => row.forEach((on, x) => {
168
+ if (on)
169
+ cells.push(makeCell(PAD_X + left + x, PAD_Y + y, salt * 4099 + y * 97 + x));
170
+ }));
171
+ return cells;
172
+ }
173
+ /** Cells sorted by their angle round a point, so two shapes can be paired off in order. */
174
+ function byAngle(cells, cx, cy) {
175
+ return [...cells].sort((a, b) => Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cx));
176
+ }
177
+ function createEngine({ canvas, code, fill, highlight, effect: initialEffect, ambient }) {
178
+ const context = canvas.getContext('2d');
179
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
180
+ const layout = layoutOf(code);
181
+ const columns = layout.columns + PAD_X * 2;
182
+ let effect = initialEffect;
183
+ let pitch = 0;
184
+ let width = 0;
185
+ let height = 0;
186
+ let frame = 0;
187
+ let last = 0;
188
+ let time = 0;
189
+ let destroyed = false;
190
+ let visible = true;
191
+ /** The pointer, in cells, or `null` when it is not over the page. */
192
+ let pointer = null;
193
+ let touchTimer = 0;
194
+ /** 1 while the code is first being drawn in, falling to 0. */
195
+ let intro = reducedMotion.matches ? 0 : 1;
196
+ /** Whether any cell is still lit by the pointer, as of the last frame drawn. */
197
+ let warm = false;
198
+ let lastDraw = 0;
199
+ /** The middle of the code, and half its size, in cells. The band goes round this. */
200
+ const middle = { x: PAD_X + layout.columns / 2, y: PAD_Y + GLYPH_ROWS / 2 };
201
+ const half = { x: layout.columns / 2, y: GLYPH_ROWS / 2 };
202
+ const digits = [...code].map((character, index) => {
203
+ const glyph = digitGlyph(character, fill);
204
+ const cells = glyph ? cellsOf(glyph, layout.lefts[index] ?? 0, index + 1) : [];
205
+ return {
206
+ left: layout.lefts[index] ?? 0,
207
+ accent: index === highlight,
208
+ cells,
209
+ particles: cells,
210
+ active: false,
211
+ amount: 0,
212
+ icon: index % ICONS.length,
213
+ mx: 0,
214
+ my: 0,
215
+ };
216
+ });
217
+ // The highlighted digit is drawn last, so its debris flies over the others'.
218
+ const order = digits.map((_, i) => i).sort((a, b) => Number(digits[a].accent) - Number(digits[b].accent));
219
+ /* -------------------------- starting each effect -------------------------- */
220
+ const centreOf = (digit) => ({
221
+ x: PAD_X + digit.left + GLYPH_COLUMNS / 2,
222
+ y: PAD_Y + GLYPH_ROWS / 2,
223
+ });
224
+ const startScatter = (digit) => {
225
+ const from = pointer ?? centreOf(digit);
226
+ for (const cell of digit.cells) {
227
+ // Away from the pointer, spread a little either side, and further for
228
+ // the cells that were nearest it.
229
+ const away = Math.atan2(cell.hy - from.y, cell.hx - from.x);
230
+ const angle = away + (cell.seed - 0.5) * 1.6;
231
+ const near = 1 - clamp(Math.hypot(cell.hx - from.x, cell.hy - from.y) / 16, 0, 1);
232
+ const distance = 3 + seeded(cell.seed * 1e6) * 7 + near * 6;
233
+ cell.sx = Math.cos(angle) * distance;
234
+ cell.sy = Math.sin(angle) * distance;
235
+ // A kick in the same direction, so they burst rather than drift.
236
+ cell.vx += Math.cos(angle) * distance * 5;
237
+ cell.vy += Math.sin(angle) * distance * 5;
238
+ }
239
+ };
240
+ const startDissolve = (digit) => {
241
+ const from = pointer ?? centreOf(digit);
242
+ // Nearest the pointer goes first, with enough noise that it reads as
243
+ // pixels dropping out and not as a wipe.
244
+ for (const cell of digit.cells) {
245
+ const distance = clamp(Math.hypot(cell.hx - from.x, cell.hy - from.y) / 22, 0, 1);
246
+ cell.threshold = 0.62 * distance + 0.38 * cell.seed;
247
+ }
248
+ };
249
+ const startScramble = (digit) => {
250
+ const centre = centreOf(digit);
251
+ const from = byAngle(digit.cells.map((cell) => ({ x: cell.hx, y: cell.hy, cell })), centre.x, centre.y);
252
+ const icon = ICONS[digit.icon % ICONS.length];
253
+ const to = byAngle(cellsOf(icon, digit.left, 0).map((cell) => ({ x: cell.hx, y: cell.hy })), centre.x, centre.y);
254
+ const count = Math.max(from.length, to.length);
255
+ const particles = [];
256
+ const usedFrom = new Set();
257
+ const usedTo = new Set();
258
+ for (let i = 0; i < count; i += 1) {
259
+ const a = Math.floor((i * from.length) / count);
260
+ const b = Math.floor((i * to.length) / count);
261
+ const source = from[a];
262
+ const target = to[b];
263
+ if (!source || !target)
264
+ continue;
265
+ const particle = { ...source.cell };
266
+ particle.fx = source.x;
267
+ particle.fy = source.y;
268
+ particle.tx = target.x;
269
+ particle.ty = target.y;
270
+ // A cell that already left, or a target already reached, is a second
271
+ // copy: it fades in or out so the count can change without a pile-up.
272
+ particle.fade = usedFrom.has(a) ? 1 : usedTo.has(b) ? -1 : 0;
273
+ usedFrom.add(a);
274
+ usedTo.add(b);
275
+ particles.push(particle);
276
+ }
277
+ digit.particles = particles;
278
+ // They gather near the pointer on the way, as if it had picked them up.
279
+ const at = pointer ?? centre;
280
+ digit.mx = at.x + (seeded(digit.icon + 31) - 0.5) * 3;
281
+ digit.my = at.y + (seeded(digit.icon + 37) - 0.5) * 3;
282
+ };
283
+ const activate = (index) => {
284
+ digits.forEach((digit, i) => {
285
+ if (i !== index && digit.active)
286
+ digit.active = false;
287
+ });
288
+ const digit = digits[index];
289
+ if (!digit || digit.active)
290
+ return;
291
+ digit.active = true;
292
+ if (effect === 'scatter')
293
+ startScatter(digit);
294
+ else if (effect === 'dissolve')
295
+ startDissolve(digit);
296
+ // Scramble only picks a new icon when the digit was whole. Caught on the
297
+ // way back, it turns round and heads for the same one.
298
+ else if (digit.amount === 0)
299
+ startScramble(digit);
300
+ invalidate();
301
+ };
302
+ const releaseAll = () => {
303
+ let changed = false;
304
+ for (const digit of digits) {
305
+ if (digit.active) {
306
+ digit.active = false;
307
+ changed = true;
308
+ }
309
+ }
310
+ if (changed)
311
+ invalidate();
312
+ };
313
+ /* --------------------------------- motion --------------------------------- */
314
+ /** Steps every digit on by `dt` seconds. True while anything is still moving. */
315
+ const step = (dt) => {
316
+ let moving = false;
317
+ const still = reducedMotion.matches;
318
+ if (intro > 0) {
319
+ intro = still ? 0 : Math.max(0, intro - dt / 0.9);
320
+ moving ||= intro > 0;
321
+ }
322
+ moving ||= warm;
323
+ for (const digit of digits) {
324
+ if (effect === 'scatter') {
325
+ if (still) {
326
+ digit.amount = digit.active ? 1 : 0;
327
+ for (const cell of digit.cells) {
328
+ cell.x = cell.hx;
329
+ cell.y = cell.hy;
330
+ cell.vx = 0;
331
+ cell.vy = 0;
332
+ }
333
+ continue;
334
+ }
335
+ // Stiffer on the way home than on the way out: the burst should feel
336
+ // loose, the return should snap shut.
337
+ const stiffness = digit.active ? 60 : 150;
338
+ const damping = 2 * Math.sqrt(stiffness) * (digit.active ? 0.7 : 0.92);
339
+ for (const cell of digit.cells) {
340
+ let tx = cell.hx;
341
+ let ty = cell.hy;
342
+ if (digit.active) {
343
+ // Scattered cells keep drifting, so the debris is not a still picture.
344
+ tx += cell.sx + Math.sin(time * 1.4 + cell.seed * 40) * 0.5;
345
+ ty += cell.sy + Math.cos(time * 1.2 + cell.seed * 70) * 0.5;
346
+ }
347
+ // Two half steps keep a stiff spring from overshooting at 30fps.
348
+ for (let s = 0; s < 2; s += 1) {
349
+ const h = dt / 2;
350
+ cell.vx += ((tx - cell.x) * stiffness - cell.vx * damping) * h;
351
+ cell.vy += ((ty - cell.y) * stiffness - cell.vy * damping) * h;
352
+ cell.x += cell.vx * h;
353
+ cell.y += cell.vy * h;
354
+ }
355
+ if (!digit.active && Math.abs(cell.x - tx) < 0.004 && Math.abs(cell.y - ty) < 0.004 && Math.abs(cell.vx) + Math.abs(cell.vy) < 0.02) {
356
+ cell.x = tx;
357
+ cell.y = ty;
358
+ cell.vx = 0;
359
+ cell.vy = 0;
360
+ }
361
+ else {
362
+ moving = true;
363
+ }
364
+ }
365
+ continue;
366
+ }
367
+ // Dissolve and Scramble run on one number per digit.
368
+ const target = digit.active ? 1 : 0;
369
+ if (digit.amount !== target) {
370
+ if (still)
371
+ digit.amount = target;
372
+ else {
373
+ const duration = effect === 'scramble' ? 0.7 : digit.active ? 0.45 : 0.6;
374
+ digit.amount =
375
+ target > digit.amount
376
+ ? Math.min(target, digit.amount + dt / duration)
377
+ : Math.max(target, digit.amount - dt / duration);
378
+ }
379
+ moving ||= digit.amount !== target;
380
+ }
381
+ if (effect === 'scramble' && digit.amount === 0 && !digit.active && digit.particles !== digit.cells) {
382
+ // Home again. The next scramble goes to the next icon.
383
+ digit.particles = digit.cells;
384
+ digit.icon = (digit.icon + 1) % ICONS.length;
385
+ }
386
+ }
387
+ return moving;
388
+ };
389
+ /* --------------------------------- drawing -------------------------------- */
390
+ const draw = (dt = 0) => {
391
+ if (!context || width === 0)
392
+ return;
393
+ const ratio = window.devicePixelRatio || 1;
394
+ const style = getComputedStyle(canvas);
395
+ const plain = style.color;
396
+ const accent = style.outlineColor || plain;
397
+ const still = reducedMotion.matches;
398
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
399
+ context.clearRect(0, 0, width, height);
400
+ // One path per colour, opacity step and shape. A few hundred cells come
401
+ // out as a handful of fills.
402
+ const paths = new Map();
403
+ const add = (isAccent, alpha, square, x, y, r) => {
404
+ if (alpha <= 0.02 || r <= 0.2)
405
+ return;
406
+ const level = clamp(Math.round(alpha * ALPHA_STEPS), 1, ALPHA_STEPS);
407
+ const key = `${isAccent ? 1 : 0}${square ? 1 : 0}${level}`;
408
+ let path = paths.get(key);
409
+ if (!path) {
410
+ path = new Path2D();
411
+ paths.set(key, path);
412
+ }
413
+ if (square) {
414
+ if (typeof path.roundRect === 'function')
415
+ path.roundRect(x - r, y - r, r * 2, r * 2, r * 0.35);
416
+ else
417
+ path.rect(x - r, y - r, r * 2, r * 2);
418
+ }
419
+ else {
420
+ path.moveTo(x + r, y);
421
+ path.arc(x, y, r, 0, Math.PI * 2);
422
+ }
423
+ };
424
+ // The band of light: where it is now, or held on the left without motion.
425
+ const band = still || !ambient ? Math.PI : time * BAND_SPEED;
426
+ let anyWarm = false;
427
+ for (const index of order) {
428
+ const digit = digits[index];
429
+ const amount = digit.amount;
430
+ // Stepped, so dissolving pixels go in visible ticks, the way pixel art moves.
431
+ const stepped = Math.floor(amount * 24) / 24;
432
+ const scrambleAt = easeInOut(amount);
433
+ for (const cell of digit.particles) {
434
+ let x = cell.x;
435
+ let y = cell.y;
436
+ let alpha = 1;
437
+ let size = cell.size;
438
+ let isAccent = false;
439
+ // Drawn in on first sight, in the same pixel-by-pixel way.
440
+ if (intro > 0 && cell.seed * 0.7 + index * 0.1 < intro)
441
+ continue;
442
+ if (still && digit.active && effect !== 'scramble') {
443
+ // No movement asked for: the digit only dims.
444
+ alpha *= 0.25;
445
+ }
446
+ else if (effect === 'scatter') {
447
+ const flown = clamp(Math.hypot(x - cell.hx, y - cell.hy) / 9, 0, 1);
448
+ size *= 1 - flown * 0.35;
449
+ alpha *= 1 - flown * 0.3;
450
+ }
451
+ else if (effect === 'dissolve') {
452
+ if (cell.threshold < stepped)
453
+ continue;
454
+ // The cells about to go, or just back, flash in the accent first.
455
+ if (cell.threshold - stepped < 0.07 && stepped > 0) {
456
+ isAccent = true;
457
+ size *= 1.2;
458
+ alpha = 1;
459
+ }
460
+ }
461
+ else if (effect === 'scramble' && digit.particles !== digit.cells) {
462
+ // Each cell leaves a little after the last, through the gathering point.
463
+ const delay = cell.seed * 0.3;
464
+ const t = still ? amount : easeInOut(clamp((amount - delay) / 0.7, 0, 1));
465
+ const u = 1 - t;
466
+ x = u * u * cell.fx + 2 * u * t * digit.mx + t * t * cell.tx;
467
+ y = u * u * cell.fy + 2 * u * t * digit.my + t * t * cell.ty;
468
+ // Scattered a little mid-flight, so the crowd reads as noise.
469
+ const noise = Math.sin(Math.PI * t) * (still ? 0 : 1.4);
470
+ x += (seeded(cell.seed * 1e5) - 0.5) * noise;
471
+ y += (seeded(cell.seed * 2e5) - 0.5) * noise;
472
+ if (cell.fade === 1)
473
+ alpha *= scrambleAt;
474
+ if (cell.fade === -1)
475
+ alpha *= 1 - scrambleAt;
476
+ }
477
+ /*
478
+ * Light. Three things light a cell: being in the highlighted digit,
479
+ * the band going slowly round the code, and the pointer having been
480
+ * near. Each cell turns accent at its own level of light, so a shape
481
+ * half in the light is dithered between the two colours, the way
482
+ * pixel art shades.
483
+ */
484
+ let heat = 0;
485
+ if (pointer && !still) {
486
+ const reach = 1 - Math.hypot(x - pointer.x, y - pointer.y) / HEAT_RADIUS;
487
+ heat = reach > 0 ? reach * reach * (3 - 2 * reach) : 0;
488
+ }
489
+ else if (pointer) {
490
+ const reach = 1 - Math.hypot(x - pointer.x, y - pointer.y) / HEAT_RADIUS;
491
+ heat = reach > 0 ? 1 : 0;
492
+ }
493
+ if (dt > 0) {
494
+ const rate = heat > cell.heat ? HEAT_RISE : HEAT_FALL;
495
+ cell.heat += (heat - cell.heat) * (1 - Math.exp(-dt * rate));
496
+ if (cell.heat < 0.004)
497
+ cell.heat = 0;
498
+ }
499
+ if (cell.heat > 0)
500
+ anyWarm = true;
501
+ const around = Math.atan2((y - middle.y) / half.y, (x - middle.x) / half.x);
502
+ const off = Math.atan2(Math.sin(around - band), Math.cos(around - band));
503
+ const swept = ambient ? Math.exp(-(off * off) / (2 * BAND_WIDTH * BAND_WIDTH)) * 0.85 : 0;
504
+ const light = Math.min(1, Math.max(digit.accent ? HIGHLIGHT_LIGHT : 0, swept, cell.heat));
505
+ if (isAccent) {
506
+ // Dissolve's flash, already decided.
507
+ }
508
+ else if (light > 0.1 + cell.pick * 0.85) {
509
+ isAccent = true;
510
+ alpha *= 0.66 + 0.34 * light;
511
+ }
512
+ else {
513
+ alpha *= (PLAIN_TONES[cell.tone] ?? 0.34) + 0.18 * light;
514
+ }
515
+ size *= 0.9 + 0.2 * light + 0.35 * cell.heat;
516
+ add(isAccent, alpha, cell.square, (x + 0.5) * pitch, (y + 0.5) * pitch, (size * pitch) / 2);
517
+ }
518
+ }
519
+ warm = anyWarm;
520
+ for (const [key, path] of paths) {
521
+ context.fillStyle = key[0] === '1' ? accent : plain;
522
+ context.globalAlpha = Number(key.slice(2)) / ALPHA_STEPS;
523
+ context.fill(path);
524
+ }
525
+ context.globalAlpha = 1;
526
+ };
527
+ /* ---------------------------------- loop ---------------------------------- */
528
+ const tick = (now) => {
529
+ frame = 0;
530
+ if (destroyed)
531
+ return;
532
+ const dt = last === 0 ? 1 / 60 : Math.min(1 / 30, (now - last) / 1000);
533
+ last = now;
534
+ time += dt;
535
+ const moving = step(dt);
536
+ const turning = ambient && !reducedMotion.matches;
537
+ // With only the band moving, 30 frames a second is plenty and halves the work.
538
+ if (moving || now - lastDraw >= 32) {
539
+ draw(now - lastDraw > 0 && lastDraw !== 0 ? Math.min(1 / 15, (now - lastDraw) / 1000) : dt);
540
+ lastDraw = now;
541
+ }
542
+ if ((moving || turning) && visible)
543
+ frame = requestAnimationFrame(tick);
544
+ else {
545
+ last = 0;
546
+ lastDraw = 0;
547
+ }
548
+ };
549
+ function invalidate() {
550
+ if (frame === 0 && !destroyed)
551
+ frame = requestAnimationFrame(tick);
552
+ }
553
+ /* --------------------------------- pointer -------------------------------- */
554
+ /** Which digit is nearest the pointer, and how far away it is, in cells. */
555
+ const nearest = () => {
556
+ if (!pointer)
557
+ return { index: -1, distance: Infinity };
558
+ let index = -1;
559
+ let distance = Infinity;
560
+ digits.forEach((digit, i) => {
561
+ if (digit.cells.length === 0)
562
+ return;
563
+ const left = PAD_X + digit.left;
564
+ const dx = Math.max(left - pointer.x, 0, pointer.x - (left + GLYPH_COLUMNS));
565
+ const dy = Math.max(PAD_Y - pointer.y, 0, pointer.y - (PAD_Y + GLYPH_ROWS));
566
+ const d = Math.hypot(dx, dy);
567
+ if (d < distance) {
568
+ distance = d;
569
+ index = i;
570
+ }
571
+ });
572
+ return { index, distance };
573
+ };
574
+ const track = (event) => {
575
+ if (pitch === 0)
576
+ return;
577
+ const rect = canvas.getBoundingClientRect();
578
+ pointer = {
579
+ x: (event.clientX - rect.left) / pitch - 0.5,
580
+ y: (event.clientY - rect.top) / pitch - 0.5,
581
+ };
582
+ };
583
+ const onMove = (event) => {
584
+ // A finger only counts while it is down, and that is handled below.
585
+ if (event.pointerType === 'touch')
586
+ return;
587
+ track(event);
588
+ // Anywhere over the canvas the pointer lights cells, so a frame is wanted
589
+ // even when no digit is close enough to react.
590
+ if (pointer && pointer.x > -HEAT_RADIUS && pointer.x < columns + HEAT_RADIUS && pointer.y > -HEAT_RADIUS && pointer.y < GLYPH_ROWS + PAD_Y * 2 + HEAT_RADIUS)
591
+ invalidate();
592
+ const { index, distance } = nearest();
593
+ const current = digits.findIndex((digit) => digit.active);
594
+ if (index !== -1 && distance <= APPROACH)
595
+ activate(index);
596
+ else if (current !== -1 && (index !== current || distance > RELEASE))
597
+ releaseAll();
598
+ };
599
+ const onDown = (event) => {
600
+ if (event.pointerType !== 'touch')
601
+ return;
602
+ track(event);
603
+ const { index, distance } = nearest();
604
+ if (index === -1 || distance > APPROACH)
605
+ return;
606
+ window.clearTimeout(touchTimer);
607
+ activate(index);
608
+ };
609
+ const onUp = (event) => {
610
+ if (event.pointerType !== 'touch')
611
+ return;
612
+ window.clearTimeout(touchTimer);
613
+ touchTimer = window.setTimeout(releaseAll, TOUCH_HOLD);
614
+ };
615
+ const onLeave = () => {
616
+ pointer = null;
617
+ releaseAll();
618
+ };
619
+ const onOut = (event) => {
620
+ if (event.relatedTarget === null)
621
+ onLeave();
622
+ };
623
+ let listening = false;
624
+ const listen = (on) => {
625
+ if (on === listening)
626
+ return;
627
+ listening = on;
628
+ const root = document.documentElement;
629
+ if (on) {
630
+ window.addEventListener('pointermove', onMove, { passive: true });
631
+ window.addEventListener('pointerdown', onDown, { passive: true });
632
+ window.addEventListener('pointerup', onUp);
633
+ window.addEventListener('pointercancel', onUp);
634
+ root.addEventListener('pointerout', onOut);
635
+ window.addEventListener('blur', onLeave);
636
+ }
637
+ else {
638
+ window.removeEventListener('pointermove', onMove);
639
+ window.removeEventListener('pointerdown', onDown);
640
+ window.removeEventListener('pointerup', onUp);
641
+ window.removeEventListener('pointercancel', onUp);
642
+ root.removeEventListener('pointerout', onOut);
643
+ window.removeEventListener('blur', onLeave);
644
+ }
645
+ };
646
+ /* ------------------------------ observers -------------------------------- */
647
+ const resize = new ResizeObserver(() => {
648
+ const next = canvas.clientWidth;
649
+ if (next === 0)
650
+ return;
651
+ const ratio = window.devicePixelRatio || 1;
652
+ width = next;
653
+ pitch = width / columns;
654
+ height = pitch * (GLYPH_ROWS + PAD_Y * 2);
655
+ canvas.width = Math.round(width * ratio);
656
+ canvas.height = Math.round(height * ratio);
657
+ // Straight away: resizing cleared the canvas, and a frame's wait would
658
+ // show it empty.
659
+ draw();
660
+ invalidate();
661
+ });
662
+ resize.observe(canvas);
663
+ const theme = new MutationObserver(invalidate);
664
+ theme.observe(document.documentElement, {
665
+ attributes: true,
666
+ attributeFilter: ['class', 'style', 'data-theme'],
667
+ });
668
+ // Off screen nothing listens and nothing moves. Coming back finishes
669
+ // whatever was under way.
670
+ const viewport = new IntersectionObserver(([entry]) => {
671
+ visible = entry?.isIntersecting ?? true;
672
+ listen(visible);
673
+ if (!visible)
674
+ releaseAll();
675
+ invalidate();
676
+ });
677
+ viewport.observe(canvas);
678
+ reducedMotion.addEventListener('change', invalidate);
679
+ return {
680
+ setEffect(next) {
681
+ if (next === effect)
682
+ return;
683
+ // Put everything back first, so no digit is left half way into the
684
+ // previous effect.
685
+ for (const digit of digits) {
686
+ digit.active = false;
687
+ digit.amount = 0;
688
+ digit.particles = digit.cells;
689
+ for (const cell of digit.cells) {
690
+ cell.x = cell.hx;
691
+ cell.y = cell.hy;
692
+ cell.vx = 0;
693
+ cell.vy = 0;
694
+ }
695
+ }
696
+ effect = next;
697
+ invalidate();
698
+ },
699
+ destroy() {
700
+ destroyed = true;
701
+ cancelAnimationFrame(frame);
702
+ window.clearTimeout(touchTimer);
703
+ listen(false);
704
+ resize.disconnect();
705
+ theme.disconnect();
706
+ viewport.disconnect();
707
+ reducedMotion.removeEventListener('change', invalidate);
708
+ },
709
+ };
710
+ }
711
+ const GRID_TILE = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48'%3E%3Cpath d='M0 24.5H48M24.5 0V48' stroke='%23000' stroke-opacity='.45'/%3E%3Cpath d='M20 24.5H29M24.5 20V29' stroke='%23000'/%3E%3C/svg%3E\")";
712
+ const backdropStyle = {
713
+ maskImage: `${GRID_TILE}, radial-gradient(closest-side, #000 35%, transparent)`,
714
+ maskSize: '48px 48px, 100% 100%',
715
+ maskRepeat: 'repeat, no-repeat',
716
+ maskPosition: 'center, center',
717
+ maskComposite: 'intersect',
718
+ WebkitMaskImage: `${GRID_TILE}, radial-gradient(closest-side, #000 35%, transparent)`,
719
+ WebkitMaskSize: '48px 48px, 100% 100%',
720
+ WebkitMaskRepeat: 'repeat, no-repeat',
721
+ WebkitMaskPosition: 'center, center',
722
+ WebkitMaskComposite: 'source-in',
723
+ };
724
+ /** The column the code, the title, the description and the actions stand in, centred. */
725
+ function NotFoundRoot({ backdrop = true, className, children, ...props }) {
726
+ const slots = notFoundVariants({ backdrop });
727
+ return (_jsxs("div", { "data-slot": "not-found", className: slots.root({ className }), ...props, children: [backdrop ? _jsx("span", { "aria-hidden": "true", "data-slot": "not-found-backdrop", className: slots.backdrop(), style: backdropStyle }) : null, children] }));
728
+ }
729
+ NotFoundRoot.displayName = 'NotFound';
730
+ /** The code, drawn in cells on a canvas. Its box keeps the code's shape before anything is drawn. */
731
+ function NotFoundCode({ code = '404', effect = 'scatter', fill = 'solid', color = 'info', ambient = true, highlight, label, className, style, ...props }) {
732
+ const canvasRef = useRef(null);
733
+ const engineRef = useRef(null);
734
+ const effectRef = useRef(effect);
735
+ effectRef.current = effect;
736
+ const lit = highlight === undefined ? (code.length > 1 ? Math.floor((code.length - 1) / 2) : null) : highlight;
737
+ const { columns } = layoutOf(code);
738
+ const slots = notFoundVariants({ color });
739
+ useEffect(() => {
740
+ const canvas = canvasRef.current;
741
+ if (!canvas)
742
+ return;
743
+ const engine = createEngine({ canvas, code, fill, highlight: lit, effect: effectRef.current, ambient });
744
+ engineRef.current = engine;
745
+ return () => {
746
+ engine.destroy();
747
+ engineRef.current = null;
748
+ };
749
+ }, [code, fill, lit, ambient]);
750
+ useEffect(() => {
751
+ engineRef.current?.setEffect(effect);
752
+ }, [effect]);
753
+ // The canvas reaches past the box on every side by the padding, given as a
754
+ // share of the box so it scales with it.
755
+ // A canvas does not stretch between `left` and `right` the way a box does:
756
+ // it keeps the size of its pixel buffer. So its size is given outright.
757
+ const canvasStyle = {
758
+ left: `${(-PAD_X / columns) * 100}%`,
759
+ top: `${(-PAD_Y / GLYPH_ROWS) * 100}%`,
760
+ width: `${(1 + (2 * PAD_X) / columns) * 100}%`,
761
+ height: `${(1 + (2 * PAD_Y) / GLYPH_ROWS) * 100}%`,
762
+ };
763
+ return (_jsx("div", { "data-slot": "not-found-code", role: "img", "aria-label": label ?? `Error ${code}`, className: slots.code({ className }), style: { aspectRatio: `${columns} / ${GLYPH_ROWS}`, ...style }, ...props, children: _jsx("canvas", { ref: canvasRef, "aria-hidden": "true", className: slots.canvas(), style: canvasStyle }) }));
764
+ }
765
+ NotFoundCode.displayName = 'NotFound.Code';
766
+ /** What happened, in a few words. */
767
+ function NotFoundTitle({ as: Tag = 'h1', className, ...props }) {
768
+ return _jsx(Tag, { "data-slot": "not-found-title", className: notFoundVariants().title({ className }), ...props });
769
+ }
770
+ NotFoundTitle.displayName = 'NotFound.Title';
771
+ /** A line on what to do next. */
772
+ function NotFoundDescription({ className, ...props }) {
773
+ return _jsx("p", { "data-slot": "not-found-description", className: notFoundVariants().description({ className }), ...props });
774
+ }
775
+ NotFoundDescription.displayName = 'NotFound.Description';
776
+ /** The ways out: links or buttons in a row that wraps on a narrow screen. */
777
+ function NotFoundActions({ className, ...props }) {
778
+ return _jsx("div", { "data-slot": "not-found-actions", className: notFoundVariants().actions({ className }), ...props });
779
+ }
780
+ NotFoundActions.displayName = 'NotFound.Actions';
781
+ export const NotFound = Object.assign(NotFoundRoot, {
782
+ Code: NotFoundCode,
783
+ Title: NotFoundTitle,
784
+ Description: NotFoundDescription,
785
+ Actions: NotFoundActions,
786
+ });
787
+ /*
788
+ * The parts again, by name, for a page that is a Server Component: it cannot
789
+ * read `NotFound.Code` off a client module, only pass the import along.
790
+ */
791
+ export { NotFoundCode, NotFoundTitle, NotFoundDescription, NotFoundActions };
package/dist/index.d.ts CHANGED
@@ -14,3 +14,4 @@ export { Flow, FlowBackground, FlowNode, FlowHandle, FlowEdge, FlowGroup, FlowGr
14
14
  export { TiltCard, TiltCardLayer, TiltCardTitle, TiltCardDescription, type TiltCardProps, type TiltCardLayerProps, type TiltCardTitleProps, type TiltCardDescriptionProps, type TiltCardColor, } from './components/tilt-card/index.js';
15
15
  export { Stat, StatContent, StatLabel, StatDescription, StatValue, StatUnit, StatBadge, type StatProps, type StatContentProps, type StatLabelProps, type StatDescriptionProps, type StatValueProps, type StatUnitProps, type StatBadgeProps, } from './components/stat/index.js';
16
16
  export { CommandDock, CommandDockTrigger, CommandDockPanel, CommandDockInput, CommandDockResults, CommandDockRow, CommandDockRowTitle, CommandDockRowMeta, CommandDockStatus, type CommandDockProps, type CommandDockTriggerProps, type CommandDockPanelProps, type CommandDockInputProps, type CommandDockResultsProps, type CommandDockRowProps, type CommandDockRowTitleProps, type CommandDockRowMetaProps, type CommandDockStatusProps, } from './components/command-dock/index.js';
17
+ export { NotFound, NotFoundCode, NotFoundTitle, NotFoundDescription, NotFoundActions, type NotFoundProps, type NotFoundCodeProps, type NotFoundTitleProps, type NotFoundDescriptionProps, type NotFoundActionsProps, type NotFoundEffect, type NotFoundColor, } from './components/not-found/index.js';
package/dist/index.js CHANGED
@@ -14,3 +14,4 @@ export { Flow, FlowBackground, FlowNode, FlowHandle, FlowEdge, FlowGroup, FlowGr
14
14
  export { TiltCard, TiltCardLayer, TiltCardTitle, TiltCardDescription, } from './components/tilt-card/index.js';
15
15
  export { Stat, StatContent, StatLabel, StatDescription, StatValue, StatUnit, StatBadge, } from './components/stat/index.js';
16
16
  export { CommandDock, CommandDockTrigger, CommandDockPanel, CommandDockInput, CommandDockResults, CommandDockRow, CommandDockRowTitle, CommandDockRowMeta, CommandDockStatus, } from './components/command-dock/index.js';
17
+ export { NotFound, NotFoundCode, NotFoundTitle, NotFoundDescription, NotFoundActions, } from './components/not-found/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panelui-web",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "PanelUI for the web: animated, data-rich React components styled with Tailwind CSS on shadcn tokens.",
5
5
  "type": "module",
6
6
  "sideEffects": false,