libraries-dev-svelte 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jakub Antalik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # Libraries.dev Svelte Ports
2
+
3
+ Svelte 5 ports of the Libraries.dev Bot Avatars and Gooey radial plus menu. The implementation is ported from the cloned repository at `C:\Users\admin\Downloads\Libraries.dev`, including the original drawing/animation source, the Bot Avatars internal README, and the `liquid-gooey` source and README.
4
+
5
+ ## Bot Avatars
6
+
7
+ ```svelte
8
+ <script lang="ts">
9
+ import { BotAvatar, type BotAvatarState } from 'libraries-dev-svelte';
10
+ let state = $state<BotAvatarState>('working');
11
+ </script>
12
+
13
+ <BotAvatar type="clover" {state} size={64} />
14
+ ```
15
+
16
+ The port retains all 18 shapes; idle, working and sleeping animation states; eyes and optional state-aware mouth; pointer following and click jumps; `plastic`, `crisp`, `smooth` and `flat` shading; lighting, whirl and jump controls; reduced-motion and paused poses; seed synchronization; accessible labels; and the canvas drawing exports. Canvas properties and native click handlers pass through.
17
+
18
+ Types, presets, palette, shape paths, simulation, renderer, and plastic-material helpers are exported from the package entry point. See `packages/bot-avatars/README.md` in the source clone for the complete original prop descriptions.
19
+
20
+ ## Gooey radial menu
21
+
22
+ ```svelte
23
+ <script lang="ts">
24
+ import { GooeyRadialMenu } from 'libraries-dev-svelte';
25
+ </script>
26
+
27
+ <GooeyRadialMenu blur={6} contrast={18} shadow="0 2px 6px rgba(0, 0, 0, .08)" />
28
+ ```
29
+
30
+ This ports the source `PlusMenu.tsx` demo and the liquid-gooey behavior it uses: filtered merged silhouettes and shadows, a shared animation clock for each button and its SVG blob, stagger and easing controls, close anticipation, theme-aware fill, and the full tuning panel. The normal playground exposes duration and stagger; add `?pro` on `/gooey` for the full controls, copy-values output, and reset.
31
+
32
+ Props: `blur` (6), `contrast` (18), `fill` (`var(--modal-bg, #fff)`), `shadow` (empty), `pro` (false), and `bare` (false). `bare` renders only the menu stage. The host can set `--modal-bg`, `--btn-text`, `--stage-bg`, `--accent`, and `--hover-veil` to match its theme.
33
+
34
+ ## Demo routes
35
+
36
+ - `/` — Bot Avatars, including the source showcase and rendering diagnostics.
37
+ - `/gooey` — Gooey radial menu with live blur, contrast, theme, and shadow controls.
38
+
39
+ ## Development
40
+
41
+ ```sh
42
+ bun install
43
+ bun run dev
44
+ bun run check
45
+ bun run build
46
+ ```
47
+
48
+ MIT. Original source: [Libraries.dev](https://github.com/Jakubantalik/Libraries.dev).
@@ -0,0 +1,368 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { autoInk, shade } from './color';
4
+ import { draw, OVERSCAN, RISE, type DrawConfig } from './draw';
5
+ import { Sim, restPose } from './engine';
6
+ import { botAvatarPresets, botAvatarStates, stateLabels } from './presets';
7
+ import { warmPlastic } from './plastic';
8
+ import { bodyPath } from './path-cache';
9
+ import { SHAPE_PARTS, SHAPE_PATHS } from './shapes';
10
+ import { pointer, subscribe } from './ticker';
11
+ import type { BotAvatarClickEvent, BotAvatarProps, BotAvatarShading, BotAvatarState } from './types';
12
+
13
+ let {
14
+ type = 'clover',
15
+ face,
16
+ state: avatarState = 'default',
17
+ size = 64,
18
+ color,
19
+ ink,
20
+ brightness = 1,
21
+ saturation = 1.5,
22
+ speed = 1,
23
+ paused = false,
24
+ seed,
25
+ shading = 'plastic',
26
+ shadow = 0.35,
27
+ highlight = 1.3,
28
+ depth = 0.65,
29
+ light = 265,
30
+ rim = 0.5,
31
+ spread = 1.55,
32
+ interactive = true,
33
+ turn = 1,
34
+ theme = 'auto',
35
+ whirl = 0,
36
+ whirlSize = 1,
37
+ whirlWidth = 1,
38
+ whirlLength = 1,
39
+ whirlTilt = 1,
40
+ jumpHeight = 26,
41
+ jumpTime = 0.68,
42
+ jumpStretch = 1,
43
+ jumpSpin = 1,
44
+ jumpLean = 6,
45
+ jumpEvery = 8,
46
+ jumpLand = 0,
47
+ jumpSquash = 1.15,
48
+ jumpSquashTime = 0.37,
49
+ jumpSquashEase = 'pulse',
50
+ jumpGroundTime = 0.11,
51
+ jumpGroundEase = 'pulse',
52
+ jumpRiseTime = 0.33,
53
+ jumpRiseEase = 'pulse',
54
+ jumpClickSquashTime = 0.24,
55
+ sides = 'auto',
56
+ class: classAttr,
57
+ className,
58
+ style,
59
+ ref,
60
+ canvas = $bindable<HTMLCanvasElement | undefined>(undefined),
61
+ onClick,
62
+ onclick,
63
+ 'aria-label': ariaLabel,
64
+ ...rest
65
+ }: BotAvatarProps = $props();
66
+
67
+ let mounted = $state(false);
68
+ let motionReduced = $state(false);
69
+ let sim: Sim | null = null;
70
+ let cfg: DrawConfig | null = null;
71
+ let cssSize = 0;
72
+ let instanceSeed = 0;
73
+ let speedValue = 1;
74
+ let interactiveValue = true;
75
+
76
+ function hashSeed(id: string): number {
77
+ let hash = 2166136261;
78
+ for (let i = 0; i < id.length; i++) hash = Math.imul(hash ^ id.charCodeAt(i), 16777619);
79
+ return ((hash >>> 0) % 1000) / 1000;
80
+ }
81
+
82
+ const clamp = (value: number, lo: number, hi: number) =>
83
+ Math.min(hi, Math.max(lo, Number.isFinite(value) ? value : 1));
84
+
85
+ const preset = $derived(botAvatarPresets[type] ?? botAvatarPresets.clover);
86
+ const faceKind = $derived(face ?? preset.face);
87
+ const stateKey = $derived(botAvatarStates.includes(avatarState) ? avatarState : 'default');
88
+ const frozen = $derived(paused || !(speed > 0));
89
+ const shadingMode = $derived<BotAvatarShading>(
90
+ shading === true ? 'crisp' : shading === false ? 'flat' : shading
91
+ );
92
+
93
+ $effect(() => {
94
+ speedValue = speed;
95
+ interactiveValue = interactive;
96
+ });
97
+
98
+ function resolveTheme(el: HTMLElement | null): 'dark' | 'light' {
99
+ if (theme !== 'auto') return theme;
100
+ const host = el?.closest('[data-theme], .dark, .light') as HTMLElement | null;
101
+ if (host) {
102
+ const value = host.getAttribute('data-theme');
103
+ if (value === 'dark' || value === 'light') return value;
104
+ if (host.classList.contains('dark')) return 'dark';
105
+ if (host.classList.contains('light')) return 'light';
106
+ }
107
+ return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: light)').matches
108
+ ? 'light'
109
+ : 'dark';
110
+ }
111
+
112
+ function paint(poseOverride?: ReturnType<typeof restPose>) {
113
+ const el = canvas;
114
+ const current = cfg;
115
+ if (!el || !current || !current.path) return;
116
+
117
+ /* A hidden ancestor measures 0: keep the last size rather than wiping the backing store. */
118
+ const px = el.clientWidth / OVERSCAN || cssSize || (typeof size === 'number' ? size : 64);
119
+ if (!px) return;
120
+ const dpr = Math.min(2, (typeof devicePixelRatio === 'number' && devicePixelRatio) || 1);
121
+ const wanted = Math.round(px * OVERSCAN * dpr);
122
+ if (el.width !== wanted || el.height !== wanted) {
123
+ el.width = wanted;
124
+ el.height = wanted;
125
+ }
126
+ cssSize = px;
127
+ const ctx = el.getContext('2d');
128
+ if (!ctx) return;
129
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
130
+ current.dpr = dpr;
131
+ const pose = poseOverride ?? (sim ? sim.pose : restPose(stateKey));
132
+ draw(ctx, px, pose, current);
133
+ }
134
+
135
+ function applyJump(current: Sim) {
136
+ current.setJump({
137
+ height: jumpHeight,
138
+ time: Math.max(0.2, jumpTime),
139
+ stretch: jumpStretch,
140
+ spin: Math.max(0, Math.round(jumpSpin)),
141
+ lean: jumpLean,
142
+ every: jumpEvery,
143
+ land: jumpLand,
144
+ squash: jumpSquash,
145
+ squashTime: Math.max(0.05, jumpSquashTime),
146
+ squashEase: jumpSquashEase,
147
+ groundTime: Math.max(0, jumpGroundTime),
148
+ groundEase: jumpGroundEase,
149
+ riseTime: Math.max(0.05, jumpRiseTime),
150
+ riseEase: jumpRiseEase,
151
+ clickSquashTime: Math.max(0.05, jumpClickSquashTime),
152
+ });
153
+ }
154
+
155
+ onMount(() => {
156
+ const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
157
+ instanceSeed = hashSeed(id);
158
+ sim = new Sim(Math.min(1, Math.max(0, seed ?? instanceSeed)), stateKey);
159
+
160
+ const media = typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
161
+ motionReduced = media?.matches ?? false;
162
+ const updateMotion = (event: MediaQueryListEvent) => {
163
+ motionReduced = event.matches;
164
+ };
165
+ media?.addEventListener?.('change', updateMotion);
166
+
167
+ mounted = true;
168
+
169
+ return () => {
170
+ media?.removeEventListener?.('change', updateMotion);
171
+ };
172
+ });
173
+
174
+ /* Keep the rig and renderer in sync with the component's live props. */
175
+ $effect(() => {
176
+ if (!mounted || !canvas || !sim || typeof Path2D === 'undefined') return;
177
+ const selected = botAvatarPresets[type] ?? botAvatarPresets.clover;
178
+ const selectedState: BotAvatarState = botAvatarStates.includes(avatarState) ? avatarState : 'default';
179
+ sim.setState(selectedState);
180
+ sim.setTurn(clamp(turn, 0, 2));
181
+ applyJump(sim);
182
+
183
+ const selectedFace = face ?? selected.face;
184
+ const selectedColor = color ?? selected.color;
185
+ const selectedBody =
186
+ brightness === 1 && saturation === 1
187
+ ? selectedColor
188
+ : shade(
189
+ selectedColor,
190
+ (Math.min(2, Math.max(0, brightness)) - 1) * 0.35,
191
+ (Math.min(2, Math.max(0, saturation)) - 1) * 0.5
192
+ );
193
+ const reduced = motionReduced;
194
+ cfg = {
195
+ path: bodyPath(SHAPE_PATHS[type] ?? SHAPE_PATHS.clover),
196
+ face: selectedFace,
197
+ faceX: selected.faceX,
198
+ faceY: selected.faceY,
199
+ faceScale: selected.faceScale,
200
+ color: selectedBody,
201
+ ink: ink ?? autoInk(selectedBody),
202
+ shading: shadingMode,
203
+ shadow: clamp(shadow, 0, 2),
204
+ highlight: clamp(highlight, 0, 2),
205
+ depth: clamp(depth, 0.2, 2),
206
+ light,
207
+ rim: clamp(rim, 0, 2),
208
+ spread: clamp(spread, 0.4, 2.5),
209
+ typeKey: type,
210
+ still: frozen || reduced,
211
+ whirl: {
212
+ strength: clamp(whirl, 0, 2),
213
+ size: clamp(whirlSize, 0.6, 1.6),
214
+ width: clamp(whirlWidth, 0.4, 2),
215
+ length: clamp(whirlLength, 0.4, 1.6),
216
+ tilt: clamp(whirlTilt, 0.5, 1.8),
217
+ },
218
+ parts: SHAPE_PARTS[type] ? bodyPath(SHAPE_PARTS[type] as string) : undefined,
219
+ sides,
220
+ theme: resolveTheme(canvas),
221
+ };
222
+
223
+ if (reduced) paint(restPose(selectedState));
224
+ else paint();
225
+ });
226
+
227
+ /* Plastic forms bake during idle time; a running avatar shows the smooth stand-in until the form is ready. */
228
+ $effect(() => {
229
+ if (!mounted || shadingMode !== 'plastic' || typeof Path2D === 'undefined') return;
230
+ const path = bodyPath(SHAPE_PATHS[type] ?? SHAPE_PATHS.clover);
231
+ const dpr = Math.min(2, (typeof devicePixelRatio === 'number' && devicePixelRatio) || 1);
232
+ const devicePx = (typeof size === 'number' ? size : 64) * dpr;
233
+ const runtime = globalThis as typeof globalThis & {
234
+ requestIdleCallback?: (callback: () => void) => number;
235
+ cancelIdleCallback?: (id: number) => void;
236
+ };
237
+ const scheduleIdle = runtime.requestIdleCallback ?? ((callback: () => void) => setTimeout(callback, 1));
238
+ const id = scheduleIdle(() => warmPlastic(type, path, devicePx, depth));
239
+ return () => {
240
+ if (runtime.cancelIdleCallback) runtime.cancelIdleCallback(id);
241
+ else clearTimeout(id);
242
+ };
243
+ });
244
+
245
+ /* One shared loop, subscribed only while visible, animated and not reduced. */
246
+ $effect(() => {
247
+ const el = canvas;
248
+ const current = sim;
249
+ if (!mounted || !el || !current || frozen || motionReduced) return;
250
+ let onScreen = true;
251
+ let unsubscribe: (() => void) | null = null;
252
+ const reach = 3;
253
+ const tick = (dt: number) => {
254
+ if (interactiveValue && !Number.isNaN(pointer.x)) {
255
+ const rect = el.getBoundingClientRect();
256
+ const box = rect.width / OVERSCAN || 1;
257
+ const dx = (pointer.x - (rect.left + rect.width / 2)) / box;
258
+ const dy = (pointer.y - (rect.top + rect.height / 2 + RISE * box)) / box;
259
+ const distance = Math.hypot(dx, dy);
260
+ const strength = distance < 1 ? 1 : distance > reach ? 0 : 1 - (distance - 1) / (reach - 1);
261
+ current.setPointer(dx / Math.max(1, distance), dy / Math.max(1, distance), strength);
262
+ } else current.setPointer(0, 0, 0);
263
+ current.update(dt * speedValue);
264
+ paint();
265
+ };
266
+ const run = () => {
267
+ if (!unsubscribe) unsubscribe = subscribe(tick);
268
+ };
269
+ const stop = () => {
270
+ unsubscribe?.();
271
+ unsubscribe = null;
272
+ };
273
+ let observer: IntersectionObserver | null = null;
274
+ if (typeof IntersectionObserver === 'function') {
275
+ observer = new IntersectionObserver((entries) => {
276
+ onScreen = entries[0]?.isIntersecting ?? true;
277
+ if (onScreen) run();
278
+ else stop();
279
+ });
280
+ observer.observe(el);
281
+ } else run();
282
+ return () => {
283
+ stop();
284
+ observer?.disconnect();
285
+ };
286
+ });
287
+
288
+ $effect(() => {
289
+ const element = canvas;
290
+ const target = ref;
291
+ if (!element || !target) return;
292
+ if (typeof target === 'function') target(element);
293
+ else target.current = element;
294
+ return () => {
295
+ if (typeof target === 'function') target(null);
296
+ else if (target.current === element) target.current = null;
297
+ };
298
+ });
299
+
300
+ const unitlessStyle = new Set([
301
+ 'animationIterationCount', 'borderImageOutset', 'borderImageSlice', 'borderImageWidth',
302
+ 'boxFlex', 'boxFlexGroup', 'boxOrdinalGroup', 'columnCount', 'columns', 'flex', 'flexGrow',
303
+ 'flexPositive', 'flexShrink', 'flexNegative', 'flexOrder', 'gridArea', 'gridColumn',
304
+ 'gridColumnEnd', 'gridColumnSpan', 'gridColumnStart', 'gridRow', 'gridRowEnd', 'gridRowSpan',
305
+ 'gridRowStart', 'fontWeight', 'lineClamp', 'lineHeight', 'opacity', 'order', 'orphans',
306
+ 'tabSize', 'widows', 'zIndex', 'zoom', 'fillOpacity', 'floodOpacity', 'stopOpacity',
307
+ 'strokeDasharray', 'strokeDashoffset', 'strokeMiterlimit', 'strokeOpacity', 'strokeWidth',
308
+ ]);
309
+
310
+ function styleName(name: string) {
311
+ if (name.startsWith('--')) return name;
312
+ const kebab = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
313
+ return kebab.startsWith('ms-') ? `-${kebab}` : kebab;
314
+ }
315
+
316
+ function styleValue(name: string, value: string | number | null | undefined) {
317
+ if (value == null) return '';
318
+ if (typeof value === 'number' && value !== 0 && !unitlessStyle.has(name) && !name.startsWith('--')) return `${value}px`;
319
+ return String(value);
320
+ }
321
+
322
+ function serializeStyle(input: BotAvatarProps['style']): string {
323
+ const requestedSize = typeof size === 'number' ? `${size}px` : size;
324
+ const side = (OVERSCAN - 1) / 2;
325
+ const defaults: Record<string, string | number> = {
326
+ display: 'inline-block',
327
+ verticalAlign: 'middle',
328
+ width: typeof size === 'number' ? `${size * OVERSCAN}px` : `calc(${size} * ${OVERSCAN})`,
329
+ height: typeof size === 'number' ? `${size * OVERSCAN}px` : `calc(${size} * ${OVERSCAN})`,
330
+ marginLeft: typeof size === 'number' ? `${-size * side}px` : `calc(${requestedSize} * ${-side})`,
331
+ marginRight: typeof size === 'number' ? `${-size * side}px` : `calc(${requestedSize} * ${-side})`,
332
+ marginTop: typeof size === 'number' ? `${-size * (side + RISE)}px` : `calc(${requestedSize} * ${-(side + RISE)})`,
333
+ marginBottom: typeof size === 'number' ? `${-size * (side - RISE)}px` : `calc(${requestedSize} * ${-(side - RISE)})`,
334
+ flex: 'none',
335
+ };
336
+ if (typeof input === 'string') {
337
+ const base = Object.entries(defaults).map(([key, value]) => `${styleName(key)}:${styleValue(key, value)}`).join(';');
338
+ return `${base};${input}`;
339
+ }
340
+ const merged = { ...defaults, ...(input ?? {}) };
341
+ return Object.entries(merged)
342
+ .map(([key, value]) => `${styleName(key)}:${styleValue(key, value)}`)
343
+ .filter((declaration) => !declaration.endsWith(':'))
344
+ .join(';');
345
+ }
346
+
347
+ const canvasClass = $derived(['ba', className, classAttr].filter(Boolean).join(' '));
348
+ const canvasStyle = $derived(serializeStyle(style));
349
+
350
+ function handleClick(event: BotAvatarClickEvent) {
351
+ if (interactive && !frozen) sim?.poke();
352
+ onClick?.(event);
353
+ if (typeof onclick === 'function') onclick(event);
354
+ }
355
+ </script>
356
+
357
+ <canvas
358
+ bind:this={canvas}
359
+ class={canvasClass}
360
+ data-bot-avatar={type}
361
+ data-face={faceKind}
362
+ data-state={stateKey}
363
+ role="img"
364
+ aria-label={ariaLabel ?? `${preset.label} bot, ${stateLabels[stateKey]}`}
365
+ style={canvasStyle}
366
+ {...rest}
367
+ onclick={handleClick}
368
+ ></canvas>
@@ -0,0 +1,4 @@
1
+ import type { BotAvatarProps } from './types';
2
+ declare const BotAvatar: import("svelte").Component<BotAvatarProps, {}, "canvas">;
3
+ type BotAvatar = ReturnType<typeof BotAvatar>;
4
+ export default BotAvatar;
@@ -0,0 +1,14 @@
1
+ /** Parse #rgb / #rrggbb / rgb() / hsl() into 0–255 channels; null for anything else. */
2
+ export declare function parseColor(input: string): [number, number, number] | null;
3
+ /** Relative luminance (WCAG), 0–1. Unparseable colours count as mid-grey. */
4
+ export declare function luminance(color: string): number;
5
+ export declare const DARK_INK = "#1E1A33";
6
+ export declare const LIGHT_INK = "#F7F5F2";
7
+ /** Face ink for a body colour: dark ink, or light ink on a dark body. */
8
+ export declare function autoInk(color: string): string;
9
+ /**
10
+ * A shade of a colour: `dl` moves the lightness (−1..1), `ds` the
11
+ * saturation. Darker shades get a touch more saturation so they stay
12
+ * rich instead of going grey, the way a painted surface falls into shadow.
13
+ */
14
+ export declare function shade(color: string, dl: number, ds?: number): string;
@@ -0,0 +1,89 @@
1
+ /** Parse #rgb / #rrggbb / rgb() / hsl() into 0–255 channels; null for anything else. */
2
+ export function parseColor(input) {
3
+ const s = input.trim();
4
+ const hsl = s.match(/^hsla?\(\s*([\d.]+)(?:deg)?[,\s]+([\d.]+)%[,\s]+([\d.]+)%/i);
5
+ if (hsl)
6
+ return hslToRgb([Number(hsl[1]) / 360, Number(hsl[2]) / 100, Number(hsl[3]) / 100]);
7
+ const hex = s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
8
+ if (hex) {
9
+ let h = hex[1];
10
+ if (h.length === 3)
11
+ h = h.split('').map((c) => c + c).join('');
12
+ const n = parseInt(h, 16);
13
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
14
+ }
15
+ const rgb = s.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
16
+ if (rgb)
17
+ return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
18
+ return null;
19
+ }
20
+ /** Relative luminance (WCAG), 0–1. Unparseable colours count as mid-grey. */
21
+ export function luminance(color) {
22
+ const c = parseColor(color);
23
+ if (!c)
24
+ return 0.5;
25
+ const lin = (v) => {
26
+ const x = v / 255;
27
+ return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
28
+ };
29
+ return 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]);
30
+ }
31
+ export const DARK_INK = '#1E1A33';
32
+ export const LIGHT_INK = '#F7F5F2';
33
+ /** Face ink for a body colour: dark ink, or light ink on a dark body. */
34
+ export function autoInk(color) {
35
+ return luminance(color) < 0.13 ? LIGHT_INK : DARK_INK;
36
+ }
37
+ /* ── HSL, for the body's shades ─────────────────────────────────────── */
38
+ function rgbToHsl([r, g, b]) {
39
+ r /= 255;
40
+ g /= 255;
41
+ b /= 255;
42
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
43
+ const l = (max + min) / 2;
44
+ if (max === min)
45
+ return [0, 0, l];
46
+ const d = max - min;
47
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
48
+ let h = 0;
49
+ if (max === r)
50
+ h = (g - b) / d + (g < b ? 6 : 0);
51
+ else if (max === g)
52
+ h = (b - r) / d + 2;
53
+ else
54
+ h = (r - g) / d + 4;
55
+ return [h / 6, s, l];
56
+ }
57
+ function hslToRgb([h, s, l]) {
58
+ if (s === 0)
59
+ return [l * 255, l * 255, l * 255];
60
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
61
+ const p = 2 * l - q;
62
+ const f = (t) => {
63
+ t = ((t % 1) + 1) % 1;
64
+ if (t < 1 / 6)
65
+ return p + (q - p) * 6 * t;
66
+ if (t < 1 / 2)
67
+ return q;
68
+ if (t < 2 / 3)
69
+ return p + (q - p) * (2 / 3 - t) * 6;
70
+ return p;
71
+ };
72
+ return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255];
73
+ }
74
+ function hslToCss([h, s, l]) {
75
+ return `hsl(${(h * 360).toFixed(1)} ${(s * 100).toFixed(1)}% ${(l * 100).toFixed(1)}%)`;
76
+ }
77
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
78
+ /**
79
+ * A shade of a colour: `dl` moves the lightness (−1..1), `ds` the
80
+ * saturation. Darker shades get a touch more saturation so they stay
81
+ * rich instead of going grey, the way a painted surface falls into shadow.
82
+ */
83
+ export function shade(color, dl, ds = 0) {
84
+ const c = parseColor(color);
85
+ if (!c)
86
+ return color;
87
+ const [h, s, l] = rgbToHsl(c);
88
+ return hslToCss([h, clamp01(s + ds + (dl < 0 ? -dl * 0.25 : 0)), clamp01(l + dl)]);
89
+ }
@@ -0,0 +1,55 @@
1
+ import type { Pose } from './engine';
2
+ import type { BotAvatarFace, BotAvatarShading } from './types';
3
+ export interface DrawConfig {
4
+ path: Path2D;
5
+ face: BotAvatarFace;
6
+ faceX: number;
7
+ faceY: number;
8
+ faceScale: number;
9
+ color: string;
10
+ ink: string;
11
+ shading: BotAvatarShading;
12
+ /** intensities and geometry of the lighting; omitted means the stock look */
13
+ shadow?: number;
14
+ highlight?: number;
15
+ depth?: number;
16
+ /** degrees clockwise from the top, where the light comes from */
17
+ light?: number;
18
+ rim?: number;
19
+ spread?: number;
20
+ /** identifies the outline for the material caches (the type name) */
21
+ typeKey?: string;
22
+ /** no animation loop follows this draw (reduced motion, paused): build
23
+ * materials now instead of on idle time */
24
+ still?: boolean;
25
+ /** thin parts (antennae) drawn behind the body with `partsDepth` of its depth */
26
+ parts?: Path2D;
27
+ partsDepth?: number;
28
+ /** the resolved surface: the whirl is white on dark, black on light */
29
+ theme?: 'dark' | 'light';
30
+ /** the device pixel ratio the context is scaled by: with it given the
31
+ context's transform is taken as that scale and never read back */
32
+ dpr?: number;
33
+ /** plastic's side slices: filled as vectors, or blitted from sprites of
34
+ the outline. `auto` (the default) blits on WebKit, where a
35
+ conic-gradient fill costs thirty times a flat one. */
36
+ sides?: 'auto' | 'vector' | 'sprite';
37
+ /** the whirl's knobs; 1 everywhere is the stock look */
38
+ whirl?: {
39
+ strength: number;
40
+ size: number;
41
+ width: number;
42
+ length: number;
43
+ tilt: number;
44
+ };
45
+ }
46
+ export declare const OVERSCAN = 1.5;
47
+ /** the body's centre sits this fraction of the box below the canvas
48
+ centre: hops and flips need the room above, not below */
49
+ export declare const RISE = 0.1;
50
+ /**
51
+ * Draw one frame. `box` is the avatar's layout size in CSS px; the canvas
52
+ * is `box * OVERSCAN` square with the body's centre `RISE * box` below
53
+ * its middle, and the context already scaled for the device pixel ratio.
54
+ */
55
+ export declare function draw(ctx: CanvasRenderingContext2D, box: number, pose: Pose, cfg: DrawConfig): void;