@wavegrid/canvas 0.1.1 → 0.2.1

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 CHANGED
@@ -1,7 +1,20 @@
1
- All Rights Reserved.
1
+ The San Francisco License (SF License)
2
2
 
3
- Copyright (c) 2024 Interweb, Inc.
3
+ Copyright (c) 2026 Interweb, Inc.
4
4
 
5
- This software and associated documentation files (the "Software") may not be
6
- reproduced, distributed, or used without express written permission from
7
- Interweb, Inc.
5
+ Permission is granted, free of charge, to any person or organization
6
+ obtaining a copy of this software and associated documentation files
7
+ (the "Software"), to use, copy, modify, merge, publish, distribute,
8
+ sublicense, and/or sell copies of the Software, and to permit others
9
+ to whom the Software is furnished to do the same.
10
+
11
+ The only requirement is that this license notice and copyright notice
12
+ shall be included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18
+ CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19
+ TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE
20
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/esm/server.js CHANGED
@@ -3,7 +3,16 @@ import { WebSocket, WebSocketServer } from 'ws';
3
3
  import { getCanvasHTML } from './ui';
4
4
  const PORT = parseInt(process.env.PORT || '3001', 10);
5
5
  const SIMULATOR_URL = process.env.SIMULATOR_URL || 'ws://localhost:3000';
6
- const server = http.createServer((_req, res) => {
6
+ // constructive.io brand mark served as the favicon
7
+ const FAVICON_SVG = `<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
8
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M23.3315 21.7046V28.9348L26.2354 27.2232L29.8206 25.1157L36.9909 29.3307V37.761L30.1657 41.7731V41.785L22.9955 46L19.4102 43.8925L15.8343 41.7848V41.7749L12.5759 39.8595L9 37.7518V21.2924L12.5759 19.1847L15.8343 17.2694V9.25722L19.4102 7.14956L22.6685 5.23418V5.21521L26.2445 3.10755L29.8297 1L37 5.21499V13.6453L30.1657 17.6628V17.6873L23.3315 21.7046ZM16.16 17.8789L12.9168 19.7854L10.0443 21.4784L16.0542 25.0113L22.2948 21.4903L19.4101 19.7945L16.16 17.8789ZM23.6598 5.43249L29.7813 9.0309L35.955 5.40169L33.0743 3.70829L29.8297 1.80095L26.5853 3.70818L23.6598 5.43249ZM22.5139 38.2327L16.8333 41.5721L19.7511 43.2918L22.5185 44.9187L22.5196 38.2427L22.5139 38.2327ZM29.0399 33.6349L29.0153 33.5916L29.1105 33.5357L26.24 31.8482L23.3405 30.1438V33.546V36.9854L29.0399 33.6349ZM29.0998 9.43154L26.24 7.75041L22.9953 5.84307L19.7509 7.7503L16.8486 9.461L22.97 13.0595L29.0348 9.49437L29.0244 9.47595L29.0998 9.43154ZM16.5153 10.0661V13.4722V17.2854L22.5224 20.8167L22.5236 13.598L16.5153 10.0661ZM35.9458 29.5176L33.0651 27.8242L29.8205 25.9168L26.5761 27.8241L23.6705 29.5367L29.7919 33.1352L35.9458 29.5176ZM15.794 33.7218L12.5758 31.8299L9.68105 30.1237V33.5369V37.3517L12.9167 39.2589L15.7928 40.9496L15.794 33.7218ZM15.7954 25.7332L9.68116 22.1389V25.5074V29.3222L12.9168 31.2293L15.7943 32.9208L15.7954 25.7332Z" fill="#01A1FF"/>
9
+ </svg>`;
10
+ const server = http.createServer((req, res) => {
11
+ if (req.url === '/favicon.svg' || req.url === '/favicon.ico') {
12
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8' });
13
+ res.end(FAVICON_SVG);
14
+ return;
15
+ }
7
16
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
8
17
  res.end(getCanvasHTML());
9
18
  });
package/esm/ui.js CHANGED
@@ -6,6 +6,7 @@ export function getCanvasHTML() {
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
7
7
  <meta name="apple-mobile-web-app-capable" content="yes">
8
8
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
9
10
  <title>Illuminate</title>
10
11
  <style>
11
12
  :root {
@@ -36,6 +37,9 @@ export function getCanvasHTML() {
36
37
  padding: 12px 20px; flex-shrink: 0;
37
38
  background: var(--surface); border-bottom: 1px solid var(--border);
38
39
  }
40
+ .scene-label-wrap {
41
+ min-width: 120px; flex-shrink: 0;
42
+ }
39
43
  .scene-label {
40
44
  font-size: 13px; font-weight: 500; color: var(--text2);
41
45
  letter-spacing: 0.04em;
@@ -55,6 +59,27 @@ export function getCanvasHTML() {
55
59
  box-shadow: 0 0 12px var(--glow); cursor: pointer;
56
60
  }
57
61
  .energy-val { font-size: 12px; color: var(--text2); min-width: 32px; text-align: right; }
62
+ .global-controls {
63
+ display: flex; align-items: center; gap: 16px;
64
+ }
65
+ .ctrl-wrap {
66
+ display: flex; align-items: center; gap: 6px;
67
+ }
68
+ .ctrl-icon { font-size: 12px; opacity: 0.5; }
69
+ .ctrl-label { font-size: 9px; color: var(--text2); letter-spacing: 0.05em; text-transform: uppercase; }
70
+ .ctrl-slider {
71
+ width: 80px; height: 4px; -webkit-appearance: none; appearance: none;
72
+ background: linear-gradient(to right, var(--accent), #1a1a2a);
73
+ border-radius: 2px; outline: none;
74
+ }
75
+ .ctrl-slider::-webkit-slider-thumb {
76
+ -webkit-appearance: none; width: 16px; height: 16px;
77
+ border-radius: 50%; background: var(--text);
78
+ box-shadow: 0 0 6px rgba(255,255,255,0.2); cursor: pointer;
79
+ }
80
+ .ctrl-slider.attack {
81
+ background: linear-gradient(to right, #1a1a2a, var(--accent));
82
+ }
58
83
 
59
84
  /* ─── Sculpture Canvas ─── */
60
85
  .sculpture-wrap {
@@ -276,7 +301,7 @@ export function getCanvasHTML() {
276
301
 
277
302
  <!-- ─── Top Bar ─── -->
278
303
  <div class="top-bar">
279
- <div style="display:flex;align-items:center;gap:8px">
304
+ <div class="scene-label-wrap" style="display:flex;align-items:center;gap:8px">
280
305
  <div class="status-dot" id="status-dot"></div>
281
306
  <span class="scene-label" id="scene-label">Civic Blue</span>
282
307
  </div>
@@ -285,6 +310,16 @@ export function getCanvasHTML() {
285
310
  <input type="range" class="energy-slider" id="energy" min="0" max="100" value="80">
286
311
  <span class="energy-val" id="energy-val">80</span>
287
312
  </div>
313
+ <div class="global-controls">
314
+ <div class="ctrl-wrap">
315
+ <span class="ctrl-label">Smooth</span>
316
+ <input type="range" class="ctrl-slider" id="smoothness" min="0" max="100" value="50">
317
+ </div>
318
+ <div class="ctrl-wrap">
319
+ <span class="ctrl-label">Attack</span>
320
+ <input type="range" class="ctrl-slider attack" id="attack" min="0" max="100" value="100">
321
+ </div>
322
+ </div>
288
323
  </div>
289
324
 
290
325
  <!-- ─── Sculpture Canvas ─── -->
@@ -300,6 +335,7 @@ export function getCanvasHTML() {
300
335
  <div class="mode-tab" data-mode="brush">Brush</div>
301
336
  <div class="mode-tab" data-mode="energy">Energy</div>
302
337
  <div class="mode-tab" data-mode="scenes">Scenes</div>
338
+ <div class="mode-tab" data-mode="animations">Animations</div>
303
339
  <div class="mode-tab" data-mode="motion">Motion</div>
304
340
  <div class="mode-tab" data-mode="drops">Drops</div>
305
341
  <div class="mode-tab" data-mode="symmetry">Symmetry</div>
@@ -365,6 +401,11 @@ export function getCanvasHTML() {
365
401
  <div class="scene-palette" id="scene-palette"></div>
366
402
  </div>
367
403
 
404
+ <!-- Animations -->
405
+ <div class="tool-panel" id="panel-animations">
406
+ <div class="scene-palette" id="animation-palette"></div>
407
+ </div>
408
+
368
409
  <!-- Motion -->
369
410
  <div class="tool-panel" id="panel-motion">
370
411
  <div class="motion-controls">
@@ -432,6 +473,7 @@ let currentBright = 80;
432
473
  let brushSize = 1;
433
474
  let brushFalloff = false;
434
475
  let activeScene = 'civic';
476
+ let activeAnimation = null;
435
477
  let symmetry = { h: false, v: false, radial: false, kaleidoscope: false };
436
478
 
437
479
  // Motion painter state
@@ -912,6 +954,30 @@ document.getElementById('energy-full').addEventListener('input', function() {
912
954
  send({ type: 'master_brightness', value: parseInt(this.value) / 100 });
913
955
  });
914
956
 
957
+ // ═══════════════════════════════════════════════════
958
+ // Smoothness control (global low-pass filter amount)
959
+ // ═══════════════════════════════════════════════════
960
+ document.getElementById('smoothness').addEventListener('input', function() {
961
+ // Slider 0–100 maps to alpha 1.0 (instant) → 0.002 (glacially smooth)
962
+ // Exponential curve: 10^(-2.7 * pct) gives wide usable range
963
+ // pct=0 → alpha=1.0 (no filter, instant)
964
+ // pct=0.5 → alpha≈0.045 (smooth, ~2s transitions)
965
+ // pct=1.0 → alpha≈0.002 (ultra-smooth, ~8-10s transitions)
966
+ const pct = parseInt(this.value) / 100;
967
+ const alpha = Math.pow(10, -2.7 * pct);
968
+ send({ type: 'smoothness', value: Math.max(0.002, Math.min(1.0, alpha)) });
969
+ });
970
+
971
+ document.getElementById('attack').addEventListener('input', function() {
972
+ // Slider 0–100 maps to attack 0.05 (soft) → 1.0 (full/instant)
973
+ // Controls how much of a new input overrides the current target per event
974
+ // Low attack = new inputs blend in gradually (soft onset)
975
+ // High attack = new inputs snap immediately to full target
976
+ const pct = parseInt(this.value) / 100;
977
+ const attack = 0.05 + pct * 0.95;
978
+ send({ type: 'attack', value: attack });
979
+ });
980
+
915
981
  // ═══════════════════════════════════════════════════
916
982
  // Scene Palette
917
983
  // ═══════════════════════════════════════════════════
@@ -936,15 +1002,56 @@ for (const [key, scene] of Object.entries(SCENES)) {
936
1002
  swatch.style.background = gradient;
937
1003
  swatch.innerHTML = '<div class="scene-swatch-label">' + scene.label + '</div>';
938
1004
  swatch.addEventListener('click', () => {
939
- document.querySelectorAll('.scene-swatch').forEach(s => s.classList.remove('active'));
1005
+ document.querySelectorAll('#scene-palette .scene-swatch').forEach(s => s.classList.remove('active'));
940
1006
  swatch.classList.add('active');
941
1007
  activeScene = key;
1008
+ activeAnimation = null;
1009
+ if (animPalette) animPalette.querySelectorAll('.scene-swatch').forEach(s => s.classList.remove('active'));
942
1010
  document.getElementById('scene-label').textContent = scene.label;
943
1011
  send({ type: 'scene', name: key });
944
1012
  });
945
1013
  scenePalette.appendChild(swatch);
946
1014
  }
947
1015
 
1016
+ // ═══════════════════════════════════════════════════
1017
+ // Animation Palette
1018
+ // ═══════════════════════════════════════════════════
1019
+ const ANIMATIONS = {
1020
+ wave: { label: 'Wave', colors: ['#1a5acc', '#3af5e8', '#1a5acc'] },
1021
+ breathe: { label: 'Breathe', colors: ['#1a3a7a', '#4a7cff', '#1a3a7a'] },
1022
+ rainbow: { label: 'Rainbow', colors: ['#e33', '#f90', '#ee0', '#3a5', '#35e', '#a3e'] },
1023
+ pacman: { label: 'Pac-Man', colors: ['#111', '#fc0', '#111'] },
1024
+ spiral: { label: 'Spiral', colors: ['#e33', '#3ae', '#e3a', '#3ea'] },
1025
+ rain: { label: 'Rain', colors: ['#0a1525', '#2a6acc', '#0a1525'] },
1026
+ heartbeat: { label: 'Heartbeat', colors: ['#200', '#e22', '#200'] },
1027
+ };
1028
+
1029
+ const animPalette = document.getElementById('animation-palette');
1030
+ for (const [key, anim] of Object.entries(ANIMATIONS)) {
1031
+ const swatch = document.createElement('div');
1032
+ swatch.className = 'scene-swatch';
1033
+ const gradient = anim.colors.length > 1
1034
+ ? 'linear-gradient(135deg, ' + anim.colors.join(', ') + ')'
1035
+ : anim.colors[0];
1036
+ swatch.style.background = gradient;
1037
+ swatch.innerHTML = '<div class="scene-swatch-label">' + anim.label + '</div>';
1038
+ swatch.addEventListener('click', () => {
1039
+ animPalette.querySelectorAll('.scene-swatch').forEach(s => s.classList.remove('active'));
1040
+ if (activeAnimation === key) {
1041
+ // Tap again to stop
1042
+ activeAnimation = null;
1043
+ send({ type: 'animation', name: 'stop' });
1044
+ document.getElementById('scene-label').textContent = 'Stopped';
1045
+ } else {
1046
+ swatch.classList.add('active');
1047
+ activeAnimation = key;
1048
+ document.getElementById('scene-label').textContent = anim.label;
1049
+ send({ type: 'animation', name: key });
1050
+ }
1051
+ });
1052
+ animPalette.appendChild(swatch);
1053
+ }
1054
+
948
1055
  // ═══════════════════════════════════════════════════
949
1056
  // Brush controls
950
1057
  // ═══════════════════════════════════════════════════
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavegrid/canvas",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "author": "Dan Lynch <pyramation@gmail.com>",
5
5
  "description": "Artist-facing creative canvas for painting with light on the 7×7 grid",
6
6
  "main": "index.js",
@@ -43,5 +43,5 @@
43
43
  "@types/ws": "^8.5.13",
44
44
  "makage": "^0.3.0"
45
45
  },
46
- "gitHead": "6f651b8d0dfbb6538c667dd3905ce6989ae18e46"
46
+ "gitHead": "60ed9683c2b2003a811d0298a086ee481cd310b1"
47
47
  }
package/server.js CHANGED
@@ -9,7 +9,16 @@ const ws_1 = require("ws");
9
9
  const ui_1 = require("./ui");
10
10
  const PORT = parseInt(process.env.PORT || '3001', 10);
11
11
  const SIMULATOR_URL = process.env.SIMULATOR_URL || 'ws://localhost:3000';
12
- const server = http_1.default.createServer((_req, res) => {
12
+ // constructive.io brand mark served as the favicon
13
+ const FAVICON_SVG = `<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
14
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M23.3315 21.7046V28.9348L26.2354 27.2232L29.8206 25.1157L36.9909 29.3307V37.761L30.1657 41.7731V41.785L22.9955 46L19.4102 43.8925L15.8343 41.7848V41.7749L12.5759 39.8595L9 37.7518V21.2924L12.5759 19.1847L15.8343 17.2694V9.25722L19.4102 7.14956L22.6685 5.23418V5.21521L26.2445 3.10755L29.8297 1L37 5.21499V13.6453L30.1657 17.6628V17.6873L23.3315 21.7046ZM16.16 17.8789L12.9168 19.7854L10.0443 21.4784L16.0542 25.0113L22.2948 21.4903L19.4101 19.7945L16.16 17.8789ZM23.6598 5.43249L29.7813 9.0309L35.955 5.40169L33.0743 3.70829L29.8297 1.80095L26.5853 3.70818L23.6598 5.43249ZM22.5139 38.2327L16.8333 41.5721L19.7511 43.2918L22.5185 44.9187L22.5196 38.2427L22.5139 38.2327ZM29.0399 33.6349L29.0153 33.5916L29.1105 33.5357L26.24 31.8482L23.3405 30.1438V33.546V36.9854L29.0399 33.6349ZM29.0998 9.43154L26.24 7.75041L22.9953 5.84307L19.7509 7.7503L16.8486 9.461L22.97 13.0595L29.0348 9.49437L29.0244 9.47595L29.0998 9.43154ZM16.5153 10.0661V13.4722V17.2854L22.5224 20.8167L22.5236 13.598L16.5153 10.0661ZM35.9458 29.5176L33.0651 27.8242L29.8205 25.9168L26.5761 27.8241L23.6705 29.5367L29.7919 33.1352L35.9458 29.5176ZM15.794 33.7218L12.5758 31.8299L9.68105 30.1237V33.5369V37.3517L12.9167 39.2589L15.7928 40.9496L15.794 33.7218ZM15.7954 25.7332L9.68116 22.1389V25.5074V29.3222L12.9168 31.2293L15.7943 32.9208L15.7954 25.7332Z" fill="#01A1FF"/>
15
+ </svg>`;
16
+ const server = http_1.default.createServer((req, res) => {
17
+ if (req.url === '/favicon.svg' || req.url === '/favicon.ico') {
18
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8' });
19
+ res.end(FAVICON_SVG);
20
+ return;
21
+ }
13
22
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
14
23
  res.end((0, ui_1.getCanvasHTML)());
15
24
  });
package/ui.js CHANGED
@@ -9,6 +9,7 @@ function getCanvasHTML() {
9
9
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
10
10
  <meta name="apple-mobile-web-app-capable" content="yes">
11
11
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
12
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
12
13
  <title>Illuminate</title>
13
14
  <style>
14
15
  :root {
@@ -39,6 +40,9 @@ function getCanvasHTML() {
39
40
  padding: 12px 20px; flex-shrink: 0;
40
41
  background: var(--surface); border-bottom: 1px solid var(--border);
41
42
  }
43
+ .scene-label-wrap {
44
+ min-width: 120px; flex-shrink: 0;
45
+ }
42
46
  .scene-label {
43
47
  font-size: 13px; font-weight: 500; color: var(--text2);
44
48
  letter-spacing: 0.04em;
@@ -58,6 +62,27 @@ function getCanvasHTML() {
58
62
  box-shadow: 0 0 12px var(--glow); cursor: pointer;
59
63
  }
60
64
  .energy-val { font-size: 12px; color: var(--text2); min-width: 32px; text-align: right; }
65
+ .global-controls {
66
+ display: flex; align-items: center; gap: 16px;
67
+ }
68
+ .ctrl-wrap {
69
+ display: flex; align-items: center; gap: 6px;
70
+ }
71
+ .ctrl-icon { font-size: 12px; opacity: 0.5; }
72
+ .ctrl-label { font-size: 9px; color: var(--text2); letter-spacing: 0.05em; text-transform: uppercase; }
73
+ .ctrl-slider {
74
+ width: 80px; height: 4px; -webkit-appearance: none; appearance: none;
75
+ background: linear-gradient(to right, var(--accent), #1a1a2a);
76
+ border-radius: 2px; outline: none;
77
+ }
78
+ .ctrl-slider::-webkit-slider-thumb {
79
+ -webkit-appearance: none; width: 16px; height: 16px;
80
+ border-radius: 50%; background: var(--text);
81
+ box-shadow: 0 0 6px rgba(255,255,255,0.2); cursor: pointer;
82
+ }
83
+ .ctrl-slider.attack {
84
+ background: linear-gradient(to right, #1a1a2a, var(--accent));
85
+ }
61
86
 
62
87
  /* ─── Sculpture Canvas ─── */
63
88
  .sculpture-wrap {
@@ -279,7 +304,7 @@ function getCanvasHTML() {
279
304
 
280
305
  <!-- ─── Top Bar ─── -->
281
306
  <div class="top-bar">
282
- <div style="display:flex;align-items:center;gap:8px">
307
+ <div class="scene-label-wrap" style="display:flex;align-items:center;gap:8px">
283
308
  <div class="status-dot" id="status-dot"></div>
284
309
  <span class="scene-label" id="scene-label">Civic Blue</span>
285
310
  </div>
@@ -288,6 +313,16 @@ function getCanvasHTML() {
288
313
  <input type="range" class="energy-slider" id="energy" min="0" max="100" value="80">
289
314
  <span class="energy-val" id="energy-val">80</span>
290
315
  </div>
316
+ <div class="global-controls">
317
+ <div class="ctrl-wrap">
318
+ <span class="ctrl-label">Smooth</span>
319
+ <input type="range" class="ctrl-slider" id="smoothness" min="0" max="100" value="50">
320
+ </div>
321
+ <div class="ctrl-wrap">
322
+ <span class="ctrl-label">Attack</span>
323
+ <input type="range" class="ctrl-slider attack" id="attack" min="0" max="100" value="100">
324
+ </div>
325
+ </div>
291
326
  </div>
292
327
 
293
328
  <!-- ─── Sculpture Canvas ─── -->
@@ -303,6 +338,7 @@ function getCanvasHTML() {
303
338
  <div class="mode-tab" data-mode="brush">Brush</div>
304
339
  <div class="mode-tab" data-mode="energy">Energy</div>
305
340
  <div class="mode-tab" data-mode="scenes">Scenes</div>
341
+ <div class="mode-tab" data-mode="animations">Animations</div>
306
342
  <div class="mode-tab" data-mode="motion">Motion</div>
307
343
  <div class="mode-tab" data-mode="drops">Drops</div>
308
344
  <div class="mode-tab" data-mode="symmetry">Symmetry</div>
@@ -368,6 +404,11 @@ function getCanvasHTML() {
368
404
  <div class="scene-palette" id="scene-palette"></div>
369
405
  </div>
370
406
 
407
+ <!-- Animations -->
408
+ <div class="tool-panel" id="panel-animations">
409
+ <div class="scene-palette" id="animation-palette"></div>
410
+ </div>
411
+
371
412
  <!-- Motion -->
372
413
  <div class="tool-panel" id="panel-motion">
373
414
  <div class="motion-controls">
@@ -435,6 +476,7 @@ let currentBright = 80;
435
476
  let brushSize = 1;
436
477
  let brushFalloff = false;
437
478
  let activeScene = 'civic';
479
+ let activeAnimation = null;
438
480
  let symmetry = { h: false, v: false, radial: false, kaleidoscope: false };
439
481
 
440
482
  // Motion painter state
@@ -915,6 +957,30 @@ document.getElementById('energy-full').addEventListener('input', function() {
915
957
  send({ type: 'master_brightness', value: parseInt(this.value) / 100 });
916
958
  });
917
959
 
960
+ // ═══════════════════════════════════════════════════
961
+ // Smoothness control (global low-pass filter amount)
962
+ // ═══════════════════════════════════════════════════
963
+ document.getElementById('smoothness').addEventListener('input', function() {
964
+ // Slider 0–100 maps to alpha 1.0 (instant) → 0.002 (glacially smooth)
965
+ // Exponential curve: 10^(-2.7 * pct) gives wide usable range
966
+ // pct=0 → alpha=1.0 (no filter, instant)
967
+ // pct=0.5 → alpha≈0.045 (smooth, ~2s transitions)
968
+ // pct=1.0 → alpha≈0.002 (ultra-smooth, ~8-10s transitions)
969
+ const pct = parseInt(this.value) / 100;
970
+ const alpha = Math.pow(10, -2.7 * pct);
971
+ send({ type: 'smoothness', value: Math.max(0.002, Math.min(1.0, alpha)) });
972
+ });
973
+
974
+ document.getElementById('attack').addEventListener('input', function() {
975
+ // Slider 0–100 maps to attack 0.05 (soft) → 1.0 (full/instant)
976
+ // Controls how much of a new input overrides the current target per event
977
+ // Low attack = new inputs blend in gradually (soft onset)
978
+ // High attack = new inputs snap immediately to full target
979
+ const pct = parseInt(this.value) / 100;
980
+ const attack = 0.05 + pct * 0.95;
981
+ send({ type: 'attack', value: attack });
982
+ });
983
+
918
984
  // ═══════════════════════════════════════════════════
919
985
  // Scene Palette
920
986
  // ═══════════════════════════════════════════════════
@@ -939,15 +1005,56 @@ for (const [key, scene] of Object.entries(SCENES)) {
939
1005
  swatch.style.background = gradient;
940
1006
  swatch.innerHTML = '<div class="scene-swatch-label">' + scene.label + '</div>';
941
1007
  swatch.addEventListener('click', () => {
942
- document.querySelectorAll('.scene-swatch').forEach(s => s.classList.remove('active'));
1008
+ document.querySelectorAll('#scene-palette .scene-swatch').forEach(s => s.classList.remove('active'));
943
1009
  swatch.classList.add('active');
944
1010
  activeScene = key;
1011
+ activeAnimation = null;
1012
+ if (animPalette) animPalette.querySelectorAll('.scene-swatch').forEach(s => s.classList.remove('active'));
945
1013
  document.getElementById('scene-label').textContent = scene.label;
946
1014
  send({ type: 'scene', name: key });
947
1015
  });
948
1016
  scenePalette.appendChild(swatch);
949
1017
  }
950
1018
 
1019
+ // ═══════════════════════════════════════════════════
1020
+ // Animation Palette
1021
+ // ═══════════════════════════════════════════════════
1022
+ const ANIMATIONS = {
1023
+ wave: { label: 'Wave', colors: ['#1a5acc', '#3af5e8', '#1a5acc'] },
1024
+ breathe: { label: 'Breathe', colors: ['#1a3a7a', '#4a7cff', '#1a3a7a'] },
1025
+ rainbow: { label: 'Rainbow', colors: ['#e33', '#f90', '#ee0', '#3a5', '#35e', '#a3e'] },
1026
+ pacman: { label: 'Pac-Man', colors: ['#111', '#fc0', '#111'] },
1027
+ spiral: { label: 'Spiral', colors: ['#e33', '#3ae', '#e3a', '#3ea'] },
1028
+ rain: { label: 'Rain', colors: ['#0a1525', '#2a6acc', '#0a1525'] },
1029
+ heartbeat: { label: 'Heartbeat', colors: ['#200', '#e22', '#200'] },
1030
+ };
1031
+
1032
+ const animPalette = document.getElementById('animation-palette');
1033
+ for (const [key, anim] of Object.entries(ANIMATIONS)) {
1034
+ const swatch = document.createElement('div');
1035
+ swatch.className = 'scene-swatch';
1036
+ const gradient = anim.colors.length > 1
1037
+ ? 'linear-gradient(135deg, ' + anim.colors.join(', ') + ')'
1038
+ : anim.colors[0];
1039
+ swatch.style.background = gradient;
1040
+ swatch.innerHTML = '<div class="scene-swatch-label">' + anim.label + '</div>';
1041
+ swatch.addEventListener('click', () => {
1042
+ animPalette.querySelectorAll('.scene-swatch').forEach(s => s.classList.remove('active'));
1043
+ if (activeAnimation === key) {
1044
+ // Tap again to stop
1045
+ activeAnimation = null;
1046
+ send({ type: 'animation', name: 'stop' });
1047
+ document.getElementById('scene-label').textContent = 'Stopped';
1048
+ } else {
1049
+ swatch.classList.add('active');
1050
+ activeAnimation = key;
1051
+ document.getElementById('scene-label').textContent = anim.label;
1052
+ send({ type: 'animation', name: key });
1053
+ }
1054
+ });
1055
+ animPalette.appendChild(swatch);
1056
+ }
1057
+
951
1058
  // ═══════════════════════════════════════════════════
952
1059
  // Brush controls
953
1060
  // ═══════════════════════════════════════════════════