@cruxgarden/plasma-ui 0.2.4 → 0.3.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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/PlasmaProvider.tsx
2
- import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
 
4
4
  // src/shaders.ts
5
5
  var DEFAULT_MAX_SHAPES = 16;
@@ -178,7 +178,7 @@ void main(){
178
178
  const compFrag = `#version 300 es
179
179
  ${common}
180
180
  uniform sampler2D uH, uS, uTint, uBg, uBgM, uBgH, uFrost;
181
- uniform float uRefract, uDisp, uRim, uRimMode, uRimWidth, uSpec, uHair;
181
+ uniform float uRefract, uDisp, uRim, uRimMode, uRimWidth, uSpec, uHair, uShim, uGlow, uWash, uGrain;
182
182
  uniform vec3 uRimColor;
183
183
  out vec4 o;
184
184
 
@@ -235,7 +235,7 @@ void main(){
235
235
  // Palette phases run on viewport position, like the grain: a frame drawn for
236
236
  // a taller region must colour its viewport band exactly as the live frame does.
237
237
  vec2 vp = p - uView.xy;
238
- col += pal(uTime*.03 + vp.x/1400.) * .06 * smoothstep(.0, .45, hHere) * (1.+uEnergy);
238
+ col += pal(uTime*.03 + vp.x/1400.) * .06 * smoothstep(.0, .45, hHere) * (1.+uEnergy) * uGlow;
239
239
 
240
240
  if(sd > -2.){
241
241
  vec2 th = 2. / vec2(textureSize(uH, 0));
@@ -255,7 +255,12 @@ void main(){
255
255
  seen(p + off, fr).g,
256
256
  seen(p + off*(1.-disp), fr).b
257
257
  );
258
+ // uWash 0 hands the background through untouched - glass with no cast of
259
+ // its own. 1 is the original desaturate-and-lift that gives the material
260
+ // its body.
261
+ vec3 clearRefr = refr;
258
262
  refr = mix(refr, vec3(dot(refr, vec3(.333))), .18) * mix(1.08, .9, uLight) + .03*(1.-uLight);
263
+ refr = mix(clearRefr, refr, uWash);
259
264
  // frosted: milkier and a little brighter
260
265
  refr = mix(refr, mix(refr, vec3(dot(refr, vec3(.333))), .25) * mix(1.12, .97, uLight) + mix(.05, .03, uLight), fr);
261
266
  float hl = 1. - .55*uLight;
@@ -280,7 +285,7 @@ void main(){
280
285
  plasma += rimCol * fres * .45 * hl * uRim;
281
286
  plasma += vec3(1.) * spec * .75 * hl * uSpec;
282
287
  // faint shimmer across the body; fades out as the tint becomes opaque
283
- plasma += pal(uTime*.04 + vp.y/900. + uEnergy*.3) * .05 * lift * (1.+uEnergy*2.) * hl * (1. - talpha);
288
+ plasma += pal(uTime*.04 + vp.y/900. + uEnergy*.3) * .05 * lift * (1.+uEnergy*2.) * hl * (1. - talpha) * uShim;
284
289
  vec3 hairCol = uRimMode > .5 ? mix(vec3(1.), rimCol / 1.4, .6) : vec3(.9,.95,1.);
285
290
  plasma += hairCol * (1.-smoothstep(0., 1.6, abs(sd - .7))) * .4 * hl * uHair;
286
291
 
@@ -290,7 +295,7 @@ void main(){
290
295
  }
291
296
  // Keyed on viewport position, so a frame drawn for a taller region has the
292
297
  // same grain in its viewport band as the live frame that replaces it.
293
- col += (hash(p - uView.xy + uTime) - .5) * .025 * grain;
298
+ col += (hash(p - uView.xy + uTime) - .5) * .025 * grain * uGrain;
294
299
  o = vec4(col, 1.);
295
300
  }`;
296
301
  return { maskFrag, tintFrag, blurFrag, bgFrag, compFrag };
@@ -361,7 +366,11 @@ var UNIFORMS = [
361
366
  "uRimColor",
362
367
  "uRimWidth",
363
368
  "uSpec",
364
- "uHair"
369
+ "uHair",
370
+ "uShim",
371
+ "uGlow",
372
+ "uWash",
373
+ "uGrain"
365
374
  ];
366
375
  var MASK_SCALE = 0.5;
367
376
  var MAX_PIXELS = 26e5;
@@ -423,6 +432,13 @@ var _PlasmaRenderer = class _PlasmaRenderer {
423
432
  this.floatOK = false;
424
433
  /** True between webglcontextlost and webglcontextrestored: draw nothing. */
425
434
  this.lost = false;
435
+ this.warnedOverflow = false;
436
+ /**
437
+ * Set by destroy(). The canvas and its context outlive this renderer, so an
438
+ * async callback that lands afterwards would happily allocate on a context
439
+ * nothing can free it from.
440
+ */
441
+ this.destroyed = false;
426
442
  /** Everything initGL() created, so destroy() can free it by hand. */
427
443
  this.owned = { tex: [], fb: [], prog: [], buf: [] };
428
444
  this.recs = /* @__PURE__ */ new Map();
@@ -471,8 +487,13 @@ var _PlasmaRenderer = class _PlasmaRenderer {
471
487
  this.onContextRestored = () => {
472
488
  this.lost = false;
473
489
  this.initGL();
474
- this.applyResize();
475
490
  this.last = 0;
491
+ if (this.frozen) {
492
+ clearTimeout(this.scrollIdle);
493
+ this.thaw();
494
+ return;
495
+ }
496
+ this.applyResize();
476
497
  if (!document.hidden && !this.raf) this.raf = requestAnimationFrame(this.frame);
477
498
  };
478
499
  /**
@@ -704,6 +725,10 @@ var _PlasmaRenderer = class _PlasmaRenderer {
704
725
  r.drawn = { l, t, w: rgt - l, h: btm - t };
705
726
  });
706
727
  this.drawnScroll = [scrollX, scrollY];
728
+ if (list.length > this.max && !this.warnedOverflow) {
729
+ this.warnedOverflow = true;
730
+ console.warn(`[plasma-ui] ${list.length} plasma surfaces are on screen but maxSurfaces is ${this.max}; the rest are not drawn. Raise maxSurfaces on <PlasmaProvider>.`);
731
+ }
707
732
  this.lastList = list.slice(0, this.max);
708
733
  this.draw(this.lastList);
709
734
  };
@@ -714,13 +739,7 @@ var _PlasmaRenderer = class _PlasmaRenderer {
714
739
  this.gl = gl;
715
740
  this.settings = settings;
716
741
  this.max = Math.max(1, Math.round(settings.maxSurfaces || DEFAULT_MAX_SHAPES));
717
- this.P = new Float32Array(this.max * 4);
718
- this.R = new Float32Array(this.max * 4);
719
- this.F = new Float32Array(this.max);
720
- this.T = new Float32Array(this.max * 4);
721
- this.FR = new Float32Array(this.max);
722
- this.EL = new Float32Array(this.max);
723
- this.SOLO = new Float32Array(this.max);
742
+ this.allocUniformArrays();
724
743
  this.initGL();
725
744
  this.mouse.x = this.mouse.tx = innerWidth / 2;
726
745
  this.mouse.y = this.mouse.ty = innerHeight / 2;
@@ -771,7 +790,7 @@ var _PlasmaRenderer = class _PlasmaRenderer {
771
790
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
772
791
  this.imgTex = tex;
773
792
  const up = (source, w, h) => {
774
- if (!w || !h) return;
793
+ if (this.destroyed || !w || !h) return;
775
794
  gl.bindTexture(gl.TEXTURE_2D, this.imgTex);
776
795
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
777
796
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, source);
@@ -786,7 +805,7 @@ var _PlasmaRenderer = class _PlasmaRenderer {
786
805
  const img2 = src;
787
806
  if (img2.complete && img2.naturalWidth) up(img2, img2.naturalWidth, img2.naturalHeight);
788
807
  else img2.addEventListener("load", () => {
789
- if (this.imgSrc === src) up(img2, img2.naturalWidth, img2.naturalHeight);
808
+ if (!this.destroyed && this.imgSrc === src) up(img2, img2.naturalWidth, img2.naturalHeight);
790
809
  }, { once: true });
791
810
  }
792
811
  return;
@@ -800,7 +819,7 @@ var _PlasmaRenderer = class _PlasmaRenderer {
800
819
  const img = new Image();
801
820
  img.crossOrigin = "anonymous";
802
821
  img.onload = () => {
803
- if (this.imgSrc !== src) return;
822
+ if (this.destroyed || this.imgSrc !== src) return;
804
823
  const tex = gl.createTexture();
805
824
  gl.bindTexture(gl.TEXTURE_2D, tex);
806
825
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
@@ -818,7 +837,12 @@ var _PlasmaRenderer = class _PlasmaRenderer {
818
837
  }
819
838
  configure(s, immediate = false) {
820
839
  const qualityChanged = s.quality !== this.settings.quality;
840
+ const max = Math.max(1, Math.round(s.maxSurfaces || DEFAULT_MAX_SHAPES));
821
841
  this.settings = s;
842
+ if (max !== this.max) {
843
+ this.setMax(max);
844
+ return;
845
+ }
822
846
  this.loadBackground(s.background ?? null);
823
847
  this.colorTarget = s.colors.map(hexToRgb);
824
848
  if (immediate) {
@@ -872,12 +896,17 @@ var _PlasmaRenderer = class _PlasmaRenderer {
872
896
  }
873
897
  };
874
898
  }
875
- /** Layout boxes of all shapes (lean removed; animating draggables report their destination). */
876
- layoutBoxes(excludeId, fusingOnly = false) {
899
+ /**
900
+ * Layout boxes of all shapes (lean removed; animating draggables report
901
+ * their destination). Pass `group` to see only the surfaces in that group;
902
+ * omit it - as any caller written before groups existed does - to see them all.
903
+ */
904
+ layoutBoxes(excludeId, fusingOnly = false, group) {
877
905
  const out = [];
878
906
  this.recs.forEach((r) => {
879
907
  if (r.id === excludeId) return;
880
908
  if (fusingOnly && r.fuse === false) return;
909
+ if (group !== void 0 && (r.group ?? null) !== group) return;
881
910
  out.push(this.layoutBoxOf(r));
882
911
  });
883
912
  return out;
@@ -903,6 +932,39 @@ var _PlasmaRenderer = class _PlasmaRenderer {
903
932
  bump(e) {
904
933
  this.energy = Math.max(this.energy, Math.min(e, 1));
905
934
  }
935
+ /** Rebuild the shaders and uniform arrays for a new surface budget. */
936
+ setMax(max) {
937
+ this.max = max;
938
+ this.allocUniformArrays();
939
+ this.releaseGL();
940
+ this.initGL();
941
+ this.applyResize();
942
+ this.warnedOverflow = false;
943
+ }
944
+ allocUniformArrays() {
945
+ this.P = new Float32Array(this.max * 4);
946
+ this.R = new Float32Array(this.max * 4);
947
+ this.F = new Float32Array(this.max);
948
+ this.T = new Float32Array(this.max * 4);
949
+ this.FR = new Float32Array(this.max);
950
+ this.EL = new Float32Array(this.max);
951
+ this.SOLO = new Float32Array(this.max);
952
+ }
953
+ /** Delete every GL object this renderer created, leaving the canvas usable. */
954
+ releaseGL() {
955
+ const gl = this.gl;
956
+ if (!gl.isContextLost()) {
957
+ this.owned.tex.forEach((t) => gl.deleteTexture(t));
958
+ this.owned.fb.forEach((f) => gl.deleteFramebuffer(f));
959
+ this.owned.prog.forEach((pr) => gl.deleteProgram(pr));
960
+ this.owned.buf.forEach((b) => gl.deleteBuffer(b));
961
+ if (this.imgTex) gl.deleteTexture(this.imgTex);
962
+ }
963
+ this.imgTex = null;
964
+ this.imgSrc = null;
965
+ this.srcEl = null;
966
+ this.owned = { tex: [], fb: [], prog: [], buf: [] };
967
+ }
906
968
  /**
907
969
  * Every GL object this renderer owns. A lost context invalidates all of
908
970
  * them, so creation lives here rather than in the constructor: the restore
@@ -911,6 +973,9 @@ var _PlasmaRenderer = class _PlasmaRenderer {
911
973
  initGL() {
912
974
  const gl = this.gl;
913
975
  this.owned = { tex: [], fb: [], prog: [], buf: [] };
976
+ this.imgTex = null;
977
+ this.imgSrc = null;
978
+ this.srcEl = null;
914
979
  this.floatOK = !!gl.getExtension("EXT_color_buffer_float");
915
980
  const sh = makeShaders(this.max);
916
981
  this.progs = { bg: this.program(sh.bgFrag), mask: this.program(sh.maskFrag), tint: this.program(sh.tintFrag), blur: this.program(sh.blurFrag), comp: this.program(sh.compFrag) };
@@ -934,6 +999,7 @@ var _PlasmaRenderer = class _PlasmaRenderer {
934
999
  this.canvas.width = this.canvas.height = 0;
935
1000
  }
936
1001
  destroy() {
1002
+ this.destroyed = true;
937
1003
  cancelAnimationFrame(this.raf);
938
1004
  clearTimeout(this.resizeSettle);
939
1005
  this.canvas.removeEventListener("webglcontextlost", this.onContextLost);
@@ -949,14 +1015,7 @@ var _PlasmaRenderer = class _PlasmaRenderer {
949
1015
  r.el.style.scale = "";
950
1016
  });
951
1017
  this.recs.clear();
952
- const gl = this.gl;
953
- if (!gl.isContextLost()) {
954
- this.owned.tex.forEach((t) => gl.deleteTexture(t));
955
- this.owned.fb.forEach((f) => gl.deleteFramebuffer(f));
956
- this.owned.prog.forEach((pr) => gl.deleteProgram(pr));
957
- this.owned.buf.forEach((b) => gl.deleteBuffer(b));
958
- }
959
- this.owned = { tex: [], fb: [], prog: [], buf: [] };
1018
+ this.releaseGL();
960
1019
  }
961
1020
  freeze() {
962
1021
  const c = this.canvas;
@@ -1074,6 +1133,18 @@ var _PlasmaRenderer = class _PlasmaRenderer {
1074
1133
  gl.useProgram(bl.pr);
1075
1134
  gl.uniform1i(bl.u.uTex, 0);
1076
1135
  gl.activeTexture(gl.TEXTURE0);
1136
+ const bgBlur = Math.min(40, Math.max(0, s.backgroundBlur || 0));
1137
+ if (bgBlur > 0) {
1138
+ const step = bgBlur * this.dpr * MASK_SCALE / 4;
1139
+ pass(this.rtBg, this.rtBgM, 0, 0);
1140
+ for (let i = 0; i < 3; i++) {
1141
+ pass(this.rtBgM, this.rtB, step, 0);
1142
+ pass(this.rtB, this.rtBgM, 0, step);
1143
+ }
1144
+ gl.viewport(0, 0, this.rtBg.w, this.rtBg.h);
1145
+ pass(this.rtBgM, this.rtBg, 0, 0);
1146
+ gl.viewport(0, 0, this.rtA.w, this.rtA.h);
1147
+ }
1077
1148
  const n = list.length;
1078
1149
  let hasFrost = false, hasTint = false, hasElev = false;
1079
1150
  for (let i = 0; i < n; i++) {
@@ -1140,6 +1211,10 @@ var _PlasmaRenderer = class _PlasmaRenderer {
1140
1211
  gl.uniform1f(c.u.uRimWidth, s.rimWidth);
1141
1212
  gl.uniform1f(c.u.uSpec, s.highlight);
1142
1213
  gl.uniform1f(c.u.uHair, s.edgeLine);
1214
+ gl.uniform1f(c.u.uShim, s.shimmer);
1215
+ gl.uniform1f(c.u.uGlow, s.glow);
1216
+ gl.uniform1f(c.u.uWash, s.wash);
1217
+ gl.uniform1f(c.u.uGrain, s.grain);
1143
1218
  gl.activeTexture(gl.TEXTURE0);
1144
1219
  gl.bindTexture(gl.TEXTURE_2D, this.rtC.tex);
1145
1220
  gl.activeTexture(gl.TEXTURE1);
@@ -1186,15 +1261,40 @@ var _PlasmaRenderer = class _PlasmaRenderer {
1186
1261
  const sh = gl.createShader(type);
1187
1262
  gl.shaderSource(sh, src);
1188
1263
  gl.compileShader(sh);
1189
- if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh) || "shader compile failed");
1264
+ if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
1265
+ const log = gl.getShaderInfoLog(sh);
1266
+ gl.deleteShader(sh);
1267
+ throw new Error(log || "shader compile failed");
1268
+ }
1190
1269
  return sh;
1191
1270
  };
1192
1271
  const pr = gl.createProgram();
1193
- gl.attachShader(pr, compile(gl.VERTEX_SHADER, vert));
1194
- gl.attachShader(pr, compile(gl.FRAGMENT_SHADER, fsrc));
1195
- gl.bindAttribLocation(pr, 0, "p");
1196
- gl.linkProgram(pr);
1197
- if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(pr) || "program link failed");
1272
+ const attached = [];
1273
+ try {
1274
+ const vs = compile(gl.VERTEX_SHADER, vert);
1275
+ let fs;
1276
+ try {
1277
+ fs = compile(gl.FRAGMENT_SHADER, fsrc);
1278
+ } catch (e) {
1279
+ gl.deleteShader(vs);
1280
+ throw e;
1281
+ }
1282
+ gl.attachShader(pr, vs);
1283
+ attached.push(vs);
1284
+ gl.attachShader(pr, fs);
1285
+ attached.push(fs);
1286
+ gl.bindAttribLocation(pr, 0, "p");
1287
+ gl.linkProgram(pr);
1288
+ if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(pr) || "program link failed");
1289
+ } catch (e) {
1290
+ gl.deleteProgram(pr);
1291
+ throw e;
1292
+ } finally {
1293
+ attached.forEach((sh) => {
1294
+ gl.detachShader(pr, sh);
1295
+ gl.deleteShader(sh);
1296
+ });
1297
+ }
1198
1298
  const u = {};
1199
1299
  UNIFORMS.forEach((n) => {
1200
1300
  u[n] = gl.getUniformLocation(pr, n);
@@ -1223,21 +1323,35 @@ var PlasmaRenderer = _PlasmaRenderer;
1223
1323
  import { jsx, jsxs } from "react/jsx-runtime";
1224
1324
  var noop = () => {
1225
1325
  };
1226
- var PlasmaContext = createContext({
1227
- renderer: null,
1326
+ var DEFAULT_RUNTIME = { renderer: null, supported: false, reducedMotion: false, pulse: noop, bump: noop };
1327
+ var DEFAULT_DEFAULTS = {
1228
1328
  tint: "#ffffff",
1229
1329
  opacity: 0,
1230
1330
  frost: 0,
1231
1331
  radius: 26,
1232
- supported: false,
1233
1332
  grid: 24,
1234
1333
  magnet: 40,
1235
- spring: { stiffness: 170, damping: 16 },
1236
- reducedMotion: false,
1237
- pulse: noop,
1238
- bump: noop
1239
- });
1240
- var usePlasma = () => useContext(PlasmaContext);
1334
+ spring: { stiffness: 170, damping: 16 }
1335
+ };
1336
+ var RuntimeContext = createContext(DEFAULT_RUNTIME);
1337
+ var DefaultsContext = createContext(DEFAULT_DEFAULTS);
1338
+ var AttachContext = createContext(null);
1339
+ var usePlasmaRuntime = () => useContext(RuntimeContext);
1340
+ var usePlasmaDefaults = () => useContext(DefaultsContext);
1341
+ function usePlasma() {
1342
+ const runtime = usePlasmaRuntime();
1343
+ const defaults = usePlasmaDefaults();
1344
+ return useMemo(() => ({ ...runtime, ...defaults }), [runtime, defaults]);
1345
+ }
1346
+ var DEV = typeof process !== "undefined" && true;
1347
+ var useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
1348
+ function useLatest(value) {
1349
+ const ref = useRef(value);
1350
+ useIsoLayoutEffect(() => {
1351
+ ref.current = value;
1352
+ });
1353
+ return ref;
1354
+ }
1241
1355
  var FALLBACK_CSS = `
1242
1356
  .plasma-panel{box-sizing:border-box}
1243
1357
  .plasma-fallback{
@@ -1260,6 +1374,31 @@ function useReducedMotion() {
1260
1374
  }, []);
1261
1375
  return reduced;
1262
1376
  }
1377
+ function PlasmaCanvas({ className, style, zIndex = -1 }) {
1378
+ const attach = useContext(AttachContext);
1379
+ const { supported } = usePlasmaRuntime();
1380
+ useEffect(() => {
1381
+ if (DEV && !attach) console.warn("[plasma-ui] <PlasmaCanvas> must be rendered inside a <PlasmaProvider>.");
1382
+ }, [attach]);
1383
+ return /* @__PURE__ */ jsx(
1384
+ "canvas",
1385
+ {
1386
+ ref: attach ?? void 0,
1387
+ "aria-hidden": "true",
1388
+ className,
1389
+ style: {
1390
+ position: "fixed",
1391
+ inset: 0,
1392
+ width: "100%",
1393
+ height: "100%",
1394
+ zIndex,
1395
+ pointerEvents: "none",
1396
+ display: supported ? "block" : "none",
1397
+ ...style
1398
+ }
1399
+ }
1400
+ );
1401
+ }
1263
1402
  function PlasmaProvider({
1264
1403
  children,
1265
1404
  mood = "tidal",
@@ -1282,6 +1421,11 @@ function PlasmaProvider({
1282
1421
  rimWidth = 1,
1283
1422
  highlight = 1,
1284
1423
  edgeLine = 1,
1424
+ shimmer = 1,
1425
+ glow = 1,
1426
+ wash = 1,
1427
+ grain = 1,
1428
+ backgroundBlur = 0,
1285
1429
  pointerDrop = true,
1286
1430
  ambientDrops = false,
1287
1431
  grid = 24,
@@ -1289,9 +1433,10 @@ function PlasmaProvider({
1289
1433
  quality = 1.25,
1290
1434
  maxSurfaces = 16,
1291
1435
  zIndex = -1,
1292
- freezeOnScroll = false
1436
+ freezeOnScroll = false,
1437
+ canvas = true
1293
1438
  }) {
1294
- const canvasRef = useRef(null);
1439
+ const [canvasEl, setCanvasEl] = useState(null);
1295
1440
  const [renderer, setRenderer] = useState(null);
1296
1441
  const [supported, setSupported] = useState(true);
1297
1442
  const reducedMotion = useReducedMotion();
@@ -1315,6 +1460,11 @@ function PlasmaProvider({
1315
1460
  rimWidth,
1316
1461
  highlight,
1317
1462
  edgeLine,
1463
+ shimmer,
1464
+ glow,
1465
+ wash,
1466
+ grain,
1467
+ backgroundBlur,
1318
1468
  viscosity,
1319
1469
  stretch,
1320
1470
  flow,
@@ -1323,91 +1473,55 @@ function PlasmaProvider({
1323
1473
  maxSurfaces,
1324
1474
  background: background ?? null
1325
1475
  };
1326
- const settingsRef = useRef(settings);
1327
- settingsRef.current = settings;
1328
- useLayoutEffect(() => {
1329
- const r = PlasmaRenderer.create(canvasRef.current, settingsRef.current);
1476
+ const settingsRef = useLatest(settings);
1477
+ useIsoLayoutEffect(() => {
1478
+ if (!canvasEl) return;
1479
+ const r = PlasmaRenderer.create(canvasEl, settingsRef.current);
1330
1480
  if (!r) {
1331
1481
  setSupported(false);
1332
1482
  return;
1333
1483
  }
1484
+ setSupported(true);
1334
1485
  setRenderer(r);
1335
1486
  return () => {
1336
1487
  r.destroy();
1337
1488
  setRenderer(null);
1338
1489
  };
1339
- }, []);
1490
+ }, [canvasEl]);
1491
+ useEffect(() => {
1492
+ if (!DEV || canvasEl) return;
1493
+ const t = setTimeout(() => console.warn("[plasma-ui] PlasmaProvider has no canvas. Either leave `canvas` on, or render a <PlasmaCanvas /> inside the provider."), 0);
1494
+ return () => clearTimeout(t);
1495
+ }, [canvasEl]);
1496
+ const settingsDeps = Object.values(settings).map((v) => Array.isArray(v) ? v.join() : v);
1340
1497
  useEffect(() => {
1341
1498
  renderer?.configure(settings);
1342
- }, [
1343
- renderer,
1344
- m.colors.join(),
1345
- settings.blend,
1346
- refraction,
1347
- dispersion,
1348
- rim,
1349
- smoothness,
1350
- settings.pointerDrop,
1351
- ambientDrops,
1352
- theme,
1353
- quality,
1354
- reducedMotion,
1355
- freezeOnScroll,
1356
- tint,
1357
- opacity,
1358
- rimColor,
1359
- rimWidth,
1360
- highlight,
1361
- edgeLine,
1362
- viscosity,
1363
- stretch,
1364
- flow,
1365
- frost,
1366
- elevation,
1367
- background
1368
- ]);
1499
+ }, [renderer, ...settingsDeps]);
1369
1500
  const vis = Math.min(Math.max(viscosity, 0), 1);
1370
1501
  const stiffK = vis < 0.5 ? 1.6 - 1.2 * vis : 1 - 1.1 * (vis - 0.5);
1371
1502
  const dampK = vis < 0.5 ? 0.6 + 0.8 * vis : 1 + 1.6 * (vis - 0.5);
1372
- const spring = { stiffness: m.spring.stiffness * stiffK, damping: m.spring.damping * dampK * Math.sqrt(stiffK) };
1373
- const value = useMemo(() => ({
1374
- renderer,
1375
- tint,
1376
- opacity,
1377
- frost,
1378
- radius,
1379
- supported,
1380
- grid,
1381
- magnet,
1382
- spring,
1383
- reducedMotion,
1384
- pulse: (x, y, s) => renderer?.pulse(x, y, s),
1385
- bump: (e) => renderer?.bump(e)
1386
- }), [renderer, tint, opacity, frost, radius, supported, grid, magnet, spring.stiffness, spring.damping, reducedMotion]);
1387
- return /* @__PURE__ */ jsxs(PlasmaContext.Provider, { value, children: [
1503
+ const stiffness = m.spring.stiffness * stiffK;
1504
+ const damping = m.spring.damping * dampK * Math.sqrt(stiffK);
1505
+ const rendererRef = useLatest(renderer);
1506
+ const pulse = useCallback((x, y, s) => rendererRef.current?.pulse(x, y, s), []);
1507
+ const bump = useCallback((e) => rendererRef.current?.bump(e), []);
1508
+ const runtime = useMemo(
1509
+ () => ({ renderer, supported, reducedMotion, pulse, bump }),
1510
+ [renderer, supported, reducedMotion, pulse, bump]
1511
+ );
1512
+ const defaults = useMemo(
1513
+ () => ({ tint, opacity, frost, radius, grid, magnet, spring: { stiffness, damping } }),
1514
+ [tint, opacity, frost, radius, grid, magnet, stiffness, damping]
1515
+ );
1516
+ return /* @__PURE__ */ jsx(RuntimeContext.Provider, { value: runtime, children: /* @__PURE__ */ jsx(DefaultsContext.Provider, { value: defaults, children: /* @__PURE__ */ jsxs(AttachContext.Provider, { value: setCanvasEl, children: [
1388
1517
  /* @__PURE__ */ jsx("style", { children: FALLBACK_CSS }),
1389
- /* @__PURE__ */ jsx(
1390
- "canvas",
1391
- {
1392
- ref: canvasRef,
1393
- "aria-hidden": "true",
1394
- style: {
1395
- position: "fixed",
1396
- inset: 0,
1397
- width: "100%",
1398
- height: "100%",
1399
- zIndex,
1400
- pointerEvents: "none",
1401
- display: supported ? "block" : "none"
1402
- }
1403
- }
1404
- ),
1518
+ canvas && /* @__PURE__ */ jsx(PlasmaCanvas, { zIndex }),
1405
1519
  children
1406
- ] });
1520
+ ] }) }) });
1407
1521
  }
1408
1522
 
1409
1523
  // src/Plasma.tsx
1410
- import { forwardRef, useCallback, useEffect as useEffect2, useLayoutEffect as useLayoutEffect2, useRef as useRef2, useState as useState2 } from "react";
1524
+ import { forwardRef, useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useState as useState2, useSyncExternalStore } from "react";
1411
1525
 
1412
1526
  // src/spring.ts
1413
1527
  var now = () => typeof performance !== "undefined" ? performance.now() : Date.now();
@@ -1518,6 +1632,25 @@ function boxGap(a, b) {
1518
1632
  // src/Plasma.tsx
1519
1633
  import { jsx as jsx2 } from "react/jsx-runtime";
1520
1634
  var NO_DRAG = "button,a,input,textarea,select,label,[contenteditable],[data-plasma-nodrag]";
1635
+ var NO_SIDES = { top: false, right: false, bottom: false, left: false };
1636
+ var noSides = () => NO_SIDES;
1637
+ function createSidesStore() {
1638
+ let value = NO_SIDES;
1639
+ const subs = /* @__PURE__ */ new Set();
1640
+ return {
1641
+ subscribe(fn) {
1642
+ subs.add(fn);
1643
+ return () => {
1644
+ subs.delete(fn);
1645
+ };
1646
+ },
1647
+ get: () => value,
1648
+ set(next) {
1649
+ value = next;
1650
+ subs.forEach((fn) => fn());
1651
+ }
1652
+ };
1653
+ }
1521
1654
  function fallbackTint(hex, a) {
1522
1655
  if (!(a > 0) || !/^#([0-9a-f]{6})$/i.test(hex)) return null;
1523
1656
  const n = parseInt(hex.slice(1), 16);
@@ -1527,7 +1660,12 @@ function assignRef(ref, v) {
1527
1660
  if (typeof ref === "function") ref(v);
1528
1661
  else if (ref) ref.current = v;
1529
1662
  }
1530
- var Plasma = forwardRef(function Plasma2({
1663
+ function useConst(make) {
1664
+ const ref = useRef2(null);
1665
+ if (ref.current === null) ref.current = make();
1666
+ return ref.current;
1667
+ }
1668
+ var PlasmaInner = forwardRef(function Plasma({
1531
1669
  as: Comp = "div",
1532
1670
  radius,
1533
1671
  lean = 10,
@@ -1539,6 +1677,7 @@ var Plasma = forwardRef(function Plasma2({
1539
1677
  padding,
1540
1678
  draggable = false,
1541
1679
  snap = true,
1680
+ group,
1542
1681
  bounds,
1543
1682
  offset,
1544
1683
  defaultOffset,
@@ -1553,29 +1692,45 @@ var Plasma = forwardRef(function Plasma2({
1553
1692
  tabIndex,
1554
1693
  ...rest
1555
1694
  }, ref) {
1556
- const plasma = usePlasma();
1557
- const r = radius ?? plasma.radius;
1695
+ const runtime = usePlasmaRuntime();
1696
+ const defaults = usePlasmaDefaults();
1697
+ const r = radius ?? defaults.radius;
1558
1698
  const el = useRef2(null);
1559
1699
  const handle = useRef2(null);
1560
- const [x] = useState2(() => springValue(offset?.x ?? defaultOffset?.x ?? 0));
1561
- const [y] = useState2(() => springValue(offset?.y ?? defaultOffset?.y ?? 0));
1700
+ const x = useConst(() => springValue(offset?.x ?? defaultOffset?.x ?? 0));
1701
+ const y = useConst(() => springValue(offset?.y ?? defaultOffset?.y ?? 0));
1562
1702
  const dest = useRef2({ x: x.get(), y: y.get() });
1563
1703
  const anims = useRef2([]);
1564
1704
  const [dragging, setDragging] = useState2(false);
1565
1705
  const positioned = draggable || !!offset || !!defaultOffset;
1566
- const joinCb = useRef2(onJoinChange);
1567
- joinCb.current = onJoinChange;
1568
- const [sides, setSides] = useState2({ top: false, right: false, bottom: false, left: false });
1569
- const plasmaRef = useRef2(plasma);
1570
- plasmaRef.current = plasma;
1571
- const setRef = useCallback((node) => {
1706
+ const joinCb = useLatest(onJoinChange);
1707
+ const sidesStore = useConst(createSidesStore);
1708
+ const sides = useSyncExternalStore(sidesStore.subscribe, sidesStore.get, noSides);
1709
+ const runtimeRef = useLatest(runtime);
1710
+ const defaultsRef = useLatest(defaults);
1711
+ const boundsRef = useLatest(bounds);
1712
+ const snapRef = useLatest(snap);
1713
+ const onDragEndRef = useLatest(onDragEnd);
1714
+ const onDragStartRef = useLatest(onDragStart);
1715
+ const opts = {
1716
+ radius: r,
1717
+ lean: lean || 0,
1718
+ tint: tint ?? null,
1719
+ opacity: opacity ?? null,
1720
+ frost: frost ?? null,
1721
+ elevation: elevation ?? null,
1722
+ fuse,
1723
+ group: group ?? null
1724
+ };
1725
+ const optsRef = useLatest(opts);
1726
+ const setRef = useCallback2((node) => {
1572
1727
  el.current = node;
1573
1728
  assignRef(ref, node);
1574
1729
  }, [ref]);
1575
- useLayoutEffect2(() => {
1576
- const node = el.current, ren = plasma.renderer;
1730
+ useIsoLayoutEffect(() => {
1731
+ const node = el.current, ren = runtime.renderer;
1577
1732
  if (!node || !ren) return;
1578
- const h = ren.register(node, { radius: r, lean: lean || 0, tint: tint ?? null, opacity: opacity ?? null, frost: frost ?? null, elevation: elevation ?? null, fuse }, (j) => joinCb.current?.(j), setSides);
1733
+ const h = ren.register(node, optsRef.current, (j) => joinCb.current?.(j), sidesStore.set);
1579
1734
  if (positioned) {
1580
1735
  h.setLayoutBox(() => {
1581
1736
  const rect = node.getBoundingClientRect();
@@ -1594,11 +1749,11 @@ var Plasma = forwardRef(function Plasma2({
1594
1749
  h.remove();
1595
1750
  handle.current = null;
1596
1751
  };
1597
- }, [plasma.renderer, positioned]);
1598
- useEffect2(() => {
1599
- handle.current?.update({ radius: r, lean: lean || 0, tint: tint ?? null, opacity: opacity ?? null, frost: frost ?? null, elevation: elevation ?? null, fuse });
1600
- }, [r, lean, tint, opacity, frost, elevation, fuse]);
1601
- useLayoutEffect2(() => {
1752
+ }, [runtime.renderer, positioned]);
1753
+ useIsoLayoutEffect(() => {
1754
+ handle.current?.update(opts);
1755
+ }, [r, lean, tint, opacity, frost, elevation, fuse, group]);
1756
+ useIsoLayoutEffect(() => {
1602
1757
  const node = el.current;
1603
1758
  if (!node || !positioned) return;
1604
1759
  const apply = () => {
@@ -1611,18 +1766,18 @@ var Plasma = forwardRef(function Plasma2({
1611
1766
  b();
1612
1767
  };
1613
1768
  }, [positioned]);
1614
- const springTo = useCallback((tx, ty, vx = 0, vy = 0) => {
1769
+ const springTo = useCallback2((tx, ty, vx = 0, vy = 0) => {
1615
1770
  dest.current = { x: tx, y: ty };
1616
1771
  anims.current.forEach((a) => a.stop());
1617
- const { spring, reducedMotion } = plasmaRef.current;
1618
- if (reducedMotion) {
1772
+ const { spring } = defaultsRef.current;
1773
+ if (runtimeRef.current.reducedMotion) {
1619
1774
  x.set(tx);
1620
1775
  y.set(ty);
1621
1776
  anims.current = [];
1622
1777
  return;
1623
1778
  }
1624
- const opts = { stiffness: spring.stiffness, damping: spring.damping };
1625
- anims.current = [animateSpring(x, tx, { ...opts, velocity: vx }), animateSpring(y, ty, { ...opts, velocity: vy })];
1779
+ const o = { stiffness: spring.stiffness, damping: spring.damping };
1780
+ anims.current = [animateSpring(x, tx, { ...o, velocity: vx }), animateSpring(y, ty, { ...o, velocity: vy })];
1626
1781
  }, []);
1627
1782
  useEffect2(() => {
1628
1783
  if (!offset) return;
@@ -1630,15 +1785,26 @@ var Plasma = forwardRef(function Plasma2({
1630
1785
  springTo(offset.x, offset.y);
1631
1786
  }, [offset?.x, offset?.y]);
1632
1787
  useEffect2(() => () => anims.current.forEach((a) => a.stop()), []);
1633
- const boundsBox = () => {
1634
- const b = bounds?.current?.getBoundingClientRect();
1788
+ useEffect2(() => {
1789
+ if (!DEV) return;
1790
+ if (!runtime.renderer && runtime.supported) {
1791
+ console.warn("[plasma-ui] <Plasma> rendered outside a <PlasmaProvider>: it will draw as the plain CSS fallback.");
1792
+ }
1793
+ const s = style;
1794
+ if (positioned && s && ("transform" in s || "translate" in s || "scale" in s)) {
1795
+ console.warn("[plasma-ui] <Plasma> owns transform, translate and scale on its element. A `style` that sets any of them will fight the drag and lean animations.");
1796
+ }
1797
+ }, [runtime.renderer, runtime.supported, positioned, style]);
1798
+ const boundsBox = useCallback2(() => {
1799
+ const b = boundsRef.current?.current?.getBoundingClientRect();
1635
1800
  return b ? { l: b.left, t: b.top, w: b.width, h: b.height } : { l: 0, t: 0, w: innerWidth, h: innerHeight };
1636
- };
1637
- const settle = (proposed, vx = 0, vy = 0) => {
1801
+ }, []);
1802
+ const settle = useCallback2((proposed, vx = 0, vy = 0) => {
1638
1803
  const node = el.current, h = handle.current;
1639
- const { grid, magnet, renderer } = plasmaRef.current;
1804
+ const { grid, magnet } = defaultsRef.current;
1805
+ const { renderer } = runtimeRef.current;
1640
1806
  let target = proposed;
1641
- if (snap && h && renderer) {
1807
+ if (snapRef.current && h && renderer) {
1642
1808
  const rect = node.getBoundingClientRect();
1643
1809
  const w = node.offsetWidth, hh = node.offsetHeight;
1644
1810
  const lo = h.leanOffset();
@@ -1646,15 +1812,15 @@ var Plasma = forwardRef(function Plasma2({
1646
1812
  const baseT = rect.top + (rect.height - hh) / 2 - lo.y - y.get();
1647
1813
  const s = snapBox(
1648
1814
  { l: baseL + proposed.x, t: baseT + proposed.y, w, h: hh },
1649
- renderer.layoutBoxes(h.id, true),
1650
- { grid, magnet, bounds: boundsBox(), inset: bounds ? 0 : 8 }
1815
+ renderer.layoutBoxes(h.id, true, optsRef.current.group ?? null),
1816
+ { grid, magnet, bounds: boundsBox(), inset: boundsRef.current ? 0 : 8 }
1651
1817
  );
1652
1818
  target = { x: s.l - baseL, y: s.t - baseT };
1653
1819
  }
1654
1820
  springTo(target.x, target.y, vx, vy);
1655
- onDragEnd?.(target);
1656
- };
1657
- const handlePointerDown = (e) => {
1821
+ onDragEndRef.current?.(target);
1822
+ }, []);
1823
+ const handlePointerDown = useCallback2((e) => {
1658
1824
  onPointerDown?.(e);
1659
1825
  if (!draggable || e.defaultPrevented || e.button !== 0) return;
1660
1826
  if (e.target.closest(NO_DRAG)) return;
@@ -1672,12 +1838,12 @@ var Plasma = forwardRef(function Plasma2({
1672
1838
  const clamp = (v, a, b) => Math.min(Math.max(v, a), Math.max(a, b));
1673
1839
  handle.current?.setDragging(true);
1674
1840
  setDragging(true);
1675
- onDragStart?.();
1841
+ onDragStartRef.current?.();
1676
1842
  const move = (ev) => {
1677
1843
  x.set(clamp(start.x + ev.clientX - start.px, minX - 40, maxX + 40));
1678
1844
  y.set(clamp(start.y + ev.clientY - start.py, minY - 40, maxY + 40));
1679
1845
  dest.current = { x: x.get(), y: y.get() };
1680
- plasmaRef.current.bump(Math.hypot(x.getVelocity(), y.getVelocity()) / 1800);
1846
+ runtimeRef.current.bump(Math.hypot(x.getVelocity(), y.getVelocity()) / 1800);
1681
1847
  };
1682
1848
  const up = () => {
1683
1849
  node.removeEventListener("pointermove", move);
@@ -1691,17 +1857,17 @@ var Plasma = forwardRef(function Plasma2({
1691
1857
  node.addEventListener("pointermove", move);
1692
1858
  node.addEventListener("pointerup", up);
1693
1859
  node.addEventListener("pointercancel", up);
1694
- };
1695
- const handleKeyDown = (e) => {
1860
+ }, [draggable, onPointerDown]);
1861
+ const handleKeyDown = useCallback2((e) => {
1696
1862
  onKeyDown?.(e);
1697
1863
  if (!draggable || e.defaultPrevented || e.target !== el.current) return;
1698
- const step = plasmaRef.current.grid || 24;
1864
+ const step = defaultsRef.current.grid || 24;
1699
1865
  const d = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key];
1700
1866
  if (!d) return;
1701
1867
  e.preventDefault();
1702
1868
  settle({ x: dest.current.x + d[0], y: dest.current.y + d[1] });
1703
- };
1704
- const classes = ["plasma-panel", plasma.supported ? "" : "plasma-fallback", className].filter(Boolean).join(" ");
1869
+ }, [draggable, onKeyDown]);
1870
+ const classes = ["plasma-panel", runtime.supported ? "" : "plasma-fallback", className].filter(Boolean).join(" ");
1705
1871
  return /* @__PURE__ */ jsx2(
1706
1872
  Comp,
1707
1873
  {
@@ -1713,7 +1879,7 @@ var Plasma = forwardRef(function Plasma2({
1713
1879
  padding: `${sides.top ? padding / 2 : padding}px ${sides.right ? padding / 2 : padding}px ${sides.bottom ? padding / 2 : padding}px ${sides.left ? padding / 2 : padding}px`,
1714
1880
  transition: "padding 250ms ease"
1715
1881
  },
1716
- ...plasma.supported ? null : fallbackTint(tint ?? plasma.tint, opacity ?? plasma.opacity),
1882
+ ...runtime.supported ? null : fallbackTint(tint ?? defaults.tint, opacity ?? defaults.opacity),
1717
1883
  ...style
1718
1884
  },
1719
1885
  "data-plasma-draggable": draggable || void 0,
@@ -1726,9 +1892,11 @@ var Plasma = forwardRef(function Plasma2({
1726
1892
  }
1727
1893
  );
1728
1894
  });
1895
+ var Plasma2 = PlasmaInner;
1729
1896
  export {
1730
1897
  DEFAULT_MAX_SHAPES,
1731
- Plasma,
1898
+ Plasma2 as Plasma,
1899
+ PlasmaCanvas,
1732
1900
  PlasmaProvider,
1733
1901
  PlasmaRenderer,
1734
1902
  boxGap,
@@ -1736,5 +1904,7 @@ export {
1736
1904
  moods,
1737
1905
  resolveMood,
1738
1906
  snapBox,
1739
- usePlasma
1907
+ usePlasma,
1908
+ usePlasmaDefaults,
1909
+ usePlasmaRuntime
1740
1910
  };