@idle-screens/schema 2.0.0 → 2.2.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
@@ -8,8 +8,28 @@ var LIMITS = {
8
8
  // px/sec — bounds motion so nothing teleports
9
9
  maxPulseAmp: 0.5,
10
10
  // opacity breathing amplitude cap
11
- minPulsePeriod: 500
11
+ minPulsePeriod: 500,
12
12
  // ms — caps pulse at 2 Hz (WCAG flash threshold is 3 Hz)
13
+ maxSpin: 360,
14
+ // degrees/sec — one full revolution per second
15
+ maxGrowAmp: 0.8,
16
+ // size breathing amplitude cap (fraction of base size)
17
+ maxOrbitSpeed: 180,
18
+ // degrees/sec — half a revolution per second
19
+ maxLinksK: 8,
20
+ maxLinkLayerCount: 200,
21
+ minCyclePeriod: 500,
22
+ // ms — same flash-safety floor as pulse
23
+ referenceViewport: 1080,
24
+ // for validating viewport-unit dimensional caps
25
+ maxTrailLength: 5e3,
26
+ // ms — cap trail duration
27
+ maxTrailSamples: 24,
28
+ // dots per trail
29
+ minDriftPeriod: 1e4,
30
+ // ms — background drift floor (10 s)
31
+ maxDriftAmount: 0.3
32
+ // fraction of gradient stop shift
13
33
  };
14
34
 
15
35
  // src/validate.ts
@@ -18,10 +38,45 @@ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
18
38
  var isStr = (v) => typeof v === "string";
19
39
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
20
40
  var isRange = (v) => Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && v[0] <= v[1];
41
+ var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "units", "background", "layers"]);
42
+ var KNOWN_LAYER = /* @__PURE__ */ new Set([
43
+ "count",
44
+ "sprite",
45
+ "motion",
46
+ "size",
47
+ "wrap",
48
+ "flip",
49
+ "alpha",
50
+ "blend",
51
+ "region",
52
+ "pulse",
53
+ "spin",
54
+ "grow",
55
+ "key",
56
+ "position",
57
+ "trail",
58
+ "links"
59
+ ]);
60
+ var KNOWN_CIRCLE = /* @__PURE__ */ new Set(["kind", "radius", "color", "soft", "colors"]);
61
+ var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
62
+ var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle"]);
63
+ var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob"]);
64
+ var KNOWN_RISE = /* @__PURE__ */ new Set(["type", "speed", "sway"]);
65
+ var KNOWN_BOUNCE = /* @__PURE__ */ new Set(["type", "speed"]);
66
+ var KNOWN_STATIC = /* @__PURE__ */ new Set(["type"]);
67
+ var KNOWN_ORBIT = /* @__PURE__ */ new Set(["type", "speed", "radius", "center"]);
68
+ var KNOWN_BG_SOLID = /* @__PURE__ */ new Set(["type", "color"]);
69
+ var KNOWN_BG_GRADIENT = /* @__PURE__ */ new Set(["type", "stops", "band", "drift"]);
70
+ var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key"]);
71
+ function unknownKeys(obj, known) {
72
+ return Object.keys(obj).filter((k) => !known.has(k));
73
+ }
21
74
  function validateSpec(spec) {
22
75
  const errors = [];
76
+ const warnings = [];
23
77
  const err = (path, message) => void errors.push({ path, message });
24
- if (!isObj(spec)) return { valid: false, errors: [{ path: "", message: "spec must be an object" }] };
78
+ const warn = (path, code, message) => void warnings.push({ path, code, message });
79
+ if (!isObj(spec)) return { valid: false, errors: [{ path: "", message: "spec must be an object" }], warnings: [] };
25
80
  if (spec.schemaVersion !== SCHEMA_VERSION) err("schemaVersion", `must be ${SCHEMA_VERSION}`);
26
81
  if (!isStr(spec.id) || spec.id.trim() === "") err("id", "must be a non-empty string");
27
82
  if (!isStr(spec.label) || spec.label.trim() === "") err("label", "must be a non-empty string");
@@ -29,7 +84,13 @@ function validateSpec(spec) {
29
84
  if (spec.motionIntensity !== void 0 && !["calm", "moderate", "energetic"].includes(spec.motionIntensity)) {
30
85
  err("motionIntensity", "must be calm | moderate | energetic");
31
86
  }
32
- if (spec.background !== void 0) validateBackground(spec.background, err);
87
+ if (spec.units !== void 0 && spec.units !== "px" && spec.units !== "viewport") {
88
+ err("units", "must be 'px' | 'viewport'");
89
+ }
90
+ for (const k of unknownKeys(spec, KNOWN_TOP)) {
91
+ warn(k, "unknown-property", `unknown top-level property '${k}' \u2014 will be ignored`);
92
+ }
93
+ if (spec.background !== void 0) validateBackground(spec.background, err, warn);
33
94
  if (!Array.isArray(spec.layers) || spec.layers.length === 0) {
34
95
  err("layers", "must be a non-empty array");
35
96
  } else {
@@ -37,19 +98,22 @@ function validateSpec(spec) {
37
98
  let total = 0;
38
99
  spec.layers.forEach((layer, i) => {
39
100
  total += isObj(layer) && isNum(layer.count) ? layer.count : 0;
40
- validateLayer(layer, `layers[${i}]`, err);
101
+ validateLayer(layer, `layers[${i}]`, err, warn, spec);
41
102
  });
42
103
  if (total > LIMITS.maxTotal) err("layers", `total entities ${total} exceeds cap ${LIMITS.maxTotal}`);
43
104
  }
44
- return { valid: errors.length === 0, errors };
105
+ return { valid: errors.length === 0, errors, warnings };
45
106
  }
46
107
  function color(v, path, err) {
47
108
  if (!isStr(v) || !HEX.test(v)) err(path, "must be a hex colour like #0a3a52");
48
109
  }
49
- function validateBackground(bg, err) {
110
+ function validateBackground(bg, err, warn) {
50
111
  if (!isObj(bg)) return err("background", "must be an object");
51
112
  if (bg.type === "solid") {
52
113
  color(bg.color, "background.color", err);
114
+ for (const k of unknownKeys(bg, KNOWN_BG_SOLID)) {
115
+ warn(`background.${k}`, "unknown-property", `unknown background property '${k}' \u2014 will be ignored`);
116
+ }
53
117
  } else if (bg.type === "gradient") {
54
118
  if (!Array.isArray(bg.stops) || bg.stops.length < 2) {
55
119
  err("background.stops", "gradient needs at least 2 stops");
@@ -59,6 +123,17 @@ function validateBackground(bg, err) {
59
123
  color(isObj(s) ? s.color : void 0, `background.stops[${i}].color`, err);
60
124
  });
61
125
  }
126
+ if (bg.drift !== void 0) {
127
+ if (!isObj(bg.drift)) err("background.drift", "must be an object");
128
+ else {
129
+ if (!isNum(bg.drift.period) || bg.drift.period < LIMITS.minDriftPeriod) {
130
+ err("background.drift.period", `must be >= ${LIMITS.minDriftPeriod} ms`);
131
+ }
132
+ if (bg.drift.amount !== void 0 && (!isNum(bg.drift.amount) || bg.drift.amount <= 0 || bg.drift.amount > LIMITS.maxDriftAmount)) {
133
+ err("background.drift.amount", `must be > 0 and <= ${LIMITS.maxDriftAmount}`);
134
+ }
135
+ }
136
+ }
62
137
  if (bg.band !== void 0) {
63
138
  if (!isObj(bg.band)) err("background.band", "must be an object");
64
139
  else {
@@ -66,12 +141,24 @@ function validateBackground(bg, err) {
66
141
  if (!isNum(bg.band.height) || bg.band.height <= 0) err("background.band.height", "must be > 0");
67
142
  }
68
143
  }
144
+ for (const k of unknownKeys(bg, KNOWN_BG_GRADIENT)) {
145
+ warn(`background.${k}`, "unknown-property", `unknown background property '${k}' \u2014 will be ignored`);
146
+ }
69
147
  } else {
70
148
  err("background.type", "must be solid | gradient");
71
149
  }
72
150
  }
73
- function validateLayer(layer, path, err) {
151
+ function validateLayer(layer, path, err, warn, spec) {
74
152
  if (!isObj(layer)) return err(path, "must be an object");
153
+ for (const k of unknownKeys(layer, KNOWN_LAYER)) {
154
+ if (k === "id") {
155
+ warn(`${path}.id`, "misplaced-property", `'id' is not a layer property \u2014 did you mean 'key'? Use 'key' to name a layer`);
156
+ } else if (k === "depth") {
157
+ warn(`${path}.depth`, "unknown-property", `'depth' is not yet supported \u2014 fake parallax with size/speed/alpha across layers instead`);
158
+ } else {
159
+ warn(`${path}.${k}`, "unknown-property", `unknown layer property '${k}' \u2014 will be ignored`);
160
+ }
161
+ }
75
162
  if (!isNum(layer.count) || !Number.isInteger(layer.count) || layer.count < 1) {
76
163
  err(`${path}.count`, "must be a positive integer");
77
164
  } else if (layer.count > LIMITS.maxPerLayer) {
@@ -120,16 +207,67 @@ function validateLayer(layer, path, err) {
120
207
  }
121
208
  }
122
209
  }
123
- validateSprite(layer.sprite, `${path}.sprite`, err);
124
- validateMotion(layer.motion, `${path}.motion`, err);
210
+ if (layer.spin !== void 0) {
211
+ if (!isNum(layer.spin)) err(`${path}.spin`, "must be a number (degrees/sec)");
212
+ else if (Math.abs(layer.spin) > LIMITS.maxSpin) err(`${path}.spin`, `must be within \xB1${LIMITS.maxSpin} deg/sec`);
213
+ }
214
+ if (layer.grow !== void 0) {
215
+ if (!isObj(layer.grow)) err(`${path}.grow`, "must be an object");
216
+ else {
217
+ if (!isNum(layer.grow.amp) || layer.grow.amp <= 0 || layer.grow.amp > LIMITS.maxGrowAmp) {
218
+ err(`${path}.grow.amp`, `must be within 0..${LIMITS.maxGrowAmp}`);
219
+ }
220
+ if (!isNum(layer.grow.period) || layer.grow.period < LIMITS.minPulsePeriod) {
221
+ err(`${path}.grow.period`, `must be >= ${LIMITS.minPulsePeriod} ms (flash-safety floor)`);
222
+ }
223
+ }
224
+ }
225
+ if (layer.links !== void 0) {
226
+ if (!isObj(layer.links)) err(`${path}.links`, "must be an object");
227
+ else {
228
+ if (!isNum(layer.links.k) || !Number.isInteger(layer.links.k) || layer.links.k < 1 || layer.links.k > LIMITS.maxLinksK) {
229
+ err(`${path}.links.k`, `must be an integer 1..${LIMITS.maxLinksK}`);
230
+ }
231
+ if (!isNum(layer.links.maxDist) || layer.links.maxDist <= 0) {
232
+ err(`${path}.links.maxDist`, "must be > 0");
233
+ }
234
+ if (layer.links.color !== void 0) color(layer.links.color, `${path}.links.color`, err);
235
+ if (layer.links.alpha !== void 0 && (!isNum(layer.links.alpha) || layer.links.alpha < 0 || layer.links.alpha > 1)) {
236
+ err(`${path}.links.alpha`, "must be 0..1");
237
+ }
238
+ if (layer.links.width !== void 0 && (!isNum(layer.links.width) || layer.links.width <= 0)) {
239
+ err(`${path}.links.width`, "must be > 0");
240
+ }
241
+ if (isNum(layer.count) && layer.count > LIMITS.maxLinkLayerCount) {
242
+ err(`${path}.links`, `layer count must be <= ${LIMITS.maxLinkLayerCount} when links is set`);
243
+ }
244
+ }
245
+ }
246
+ if (layer.trail !== void 0) {
247
+ if (!isObj(layer.trail)) err(`${path}.trail`, "must be an object");
248
+ else {
249
+ if (!isNum(layer.trail.length) || layer.trail.length <= 0 || layer.trail.length > LIMITS.maxTrailLength) {
250
+ err(`${path}.trail.length`, `must be > 0 and <= ${LIMITS.maxTrailLength} ms`);
251
+ }
252
+ if (layer.trail.fade !== void 0 && (!isNum(layer.trail.fade) || layer.trail.fade < 0 || layer.trail.fade > 1)) {
253
+ err(`${path}.trail.fade`, "must be 0..1");
254
+ }
255
+ }
256
+ }
257
+ validateSprite(layer.sprite, `${path}.sprite`, err, warn);
258
+ validateMotion(layer.motion, `${path}.motion`, err, warn, spec);
125
259
  }
126
- function validateSprite(sprite, path, err) {
260
+ function validateSprite(sprite, path, err, warn) {
127
261
  if (!isObj(sprite)) return err(path, "must be an object");
262
+ let knownSet;
128
263
  if (sprite.kind === "emoji") {
264
+ knownSet = KNOWN_EMOJI;
129
265
  if (!Array.isArray(sprite.glyphs) || sprite.glyphs.length === 0 || !sprite.glyphs.every(isStr)) {
130
266
  err(`${path}.glyphs`, "must be a non-empty array of strings");
131
267
  }
268
+ validateCycle(sprite, path, err);
132
269
  } else if (sprite.kind === "text") {
270
+ knownSet = KNOWN_TEXT;
133
271
  if (!Array.isArray(sprite.strings) || sprite.strings.length === 0 || !sprite.strings.every(isStr)) {
134
272
  err(`${path}.strings`, "must be a non-empty array of strings");
135
273
  }
@@ -143,33 +281,95 @@ function validateSprite(sprite, path, err) {
143
281
  if (sprite.maxWidth !== void 0 && (!isNum(sprite.maxWidth) || sprite.maxWidth <= 0)) {
144
282
  err(`${path}.maxWidth`, "must be a positive number");
145
283
  }
284
+ validateCycle(sprite, path, err);
146
285
  } else if (sprite.kind === "circle") {
286
+ knownSet = KNOWN_CIRCLE;
147
287
  if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
148
288
  color(sprite.color, `${path}.color`, err);
149
289
  if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
290
+ if (sprite.colors !== void 0) {
291
+ if (!Array.isArray(sprite.colors) || sprite.colors.length === 0) {
292
+ err(`${path}.colors`, "must be a non-empty array of hex colours");
293
+ } else {
294
+ sprite.colors.forEach((c, ci) => color(c, `${path}.colors[${ci}]`, err));
295
+ }
296
+ }
150
297
  } else {
151
298
  err(`${path}.kind`, "must be emoji | text | circle");
299
+ return;
300
+ }
301
+ for (const k of unknownKeys(sprite, knownSet)) {
302
+ if (LAYER_PROPS_ON_SPRITE.has(k)) {
303
+ warn(`${path}.${k}`, "misplaced-property", `'${k}' belongs on the layer, not inside sprite \u2014 move it up one level`);
304
+ } else {
305
+ warn(`${path}.${k}`, "unknown-property", `unknown sprite property '${k}' \u2014 will be ignored`);
306
+ }
307
+ }
308
+ }
309
+ function validateCycle(sprite, path, err) {
310
+ if (sprite.cycle === void 0) return;
311
+ if (!isObj(sprite.cycle)) return err(`${path}.cycle`, "must be an object");
312
+ if (!isNum(sprite.cycle.period) || sprite.cycle.period < LIMITS.minCyclePeriod) {
313
+ err(`${path}.cycle.period`, `must be >= ${LIMITS.minCyclePeriod} ms (flash-safety floor)`);
152
314
  }
153
315
  }
154
- function validateMotion(motion, path, err) {
316
+ function validateMotion(motion, path, err, warn, spec) {
155
317
  if (!isObj(motion)) return err(path, "must be an object");
318
+ const isViewport = isObj(spec) && spec.units === "viewport";
319
+ const speedCap = isViewport ? LIMITS.maxSpeed / LIMITS.referenceViewport : LIMITS.maxSpeed;
156
320
  const speedOk = (v, p) => {
157
321
  if (!isRange(v)) err(p, "must be a [min,max] range");
158
- else if (v[1] > LIMITS.maxSpeed) err(p, `speed exceeds cap ${LIMITS.maxSpeed} px/sec`);
322
+ else if (v[1] > speedCap) err(p, `speed exceeds cap ${isViewport ? speedCap.toFixed(2) + " viewport-units/sec" : LIMITS.maxSpeed + " px/sec"}`);
159
323
  };
324
+ let knownSet;
160
325
  if (motion.type === "drift") {
326
+ knownSet = KNOWN_DRIFT;
161
327
  speedOk(motion.speed, `${path}.speed`);
162
328
  if (motion.angle !== void 0 && !isNum(motion.angle)) err(`${path}.angle`, "must be a number (degrees)");
163
329
  if (motion.bidirectional !== void 0 && typeof motion.bidirectional !== "boolean") err(`${path}.bidirectional`, "must be a boolean");
164
330
  if (motion.bob !== void 0 && !isNum(motion.bob)) err(`${path}.bob`, "must be a number");
331
+ if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
332
+ warn(`${path}.speed`, "near-zero-speed", `max speed is ${motion.speed[1]} px/sec \u2014 entities will appear frozen. Typical range: 10\u2013200 px/sec`);
333
+ }
165
334
  } else if (motion.type === "rise") {
335
+ knownSet = KNOWN_RISE;
166
336
  speedOk(motion.speed, `${path}.speed`);
167
337
  if (motion.sway !== void 0 && !isNum(motion.sway)) err(`${path}.sway`, "must be a number");
338
+ if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
339
+ warn(`${path}.speed`, "near-zero-speed", `max speed is ${motion.speed[1]} px/sec \u2014 entities will appear frozen. Typical range: 5\u201380 px/sec`);
340
+ }
168
341
  } else if (motion.type === "bounce") {
342
+ knownSet = KNOWN_BOUNCE;
169
343
  speedOk(motion.speed, `${path}.speed`);
344
+ if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
345
+ warn(`${path}.speed`, "near-zero-speed", `max speed is ${motion.speed[1]} px/sec \u2014 entities will appear frozen. Typical range: 20\u2013150 px/sec`);
346
+ }
170
347
  } else if (motion.type === "static") {
348
+ knownSet = KNOWN_STATIC;
349
+ } else if (motion.type === "orbit") {
350
+ knownSet = KNOWN_ORBIT;
351
+ if (!isRange(motion.speed)) err(`${path}.speed`, "must be a [min,max] range");
352
+ else {
353
+ if (Math.abs(motion.speed[0]) > LIMITS.maxOrbitSpeed || Math.abs(motion.speed[1]) > LIMITS.maxOrbitSpeed) err(`${path}.speed`, `orbit speed exceeds cap ${LIMITS.maxOrbitSpeed} deg/sec`);
354
+ if (Math.max(Math.abs(motion.speed[0]), Math.abs(motion.speed[1])) < 1) {
355
+ warn(`${path}.speed`, "near-zero-speed", `orbit speed is near zero \u2014 entities will appear frozen. Typical range: 5\u201360 deg/sec`);
356
+ }
357
+ }
358
+ if (!isRange(motion.radius) || motion.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
359
+ if (motion.center !== void 0) {
360
+ if (!isObj(motion.center) || !isNum(motion.center.x) || !isNum(motion.center.y)) {
361
+ err(`${path}.center`, "must be {x, y} with numbers 0..1");
362
+ } else {
363
+ if (motion.center.x < 0 || motion.center.x > 1) err(`${path}.center.x`, "must be 0..1");
364
+ if (motion.center.y < 0 || motion.center.y > 1) err(`${path}.center.y`, "must be 0..1");
365
+ }
366
+ }
171
367
  } else {
172
- err(`${path}.type`, "must be drift | rise | bounce | static");
368
+ err(`${path}.type`, "must be drift | rise | bounce | static | orbit");
369
+ return;
370
+ }
371
+ for (const k of unknownKeys(motion, knownSet)) {
372
+ warn(`${path}.${k}`, "unknown-property", `unknown motion property '${k}' \u2014 will be ignored`);
173
373
  }
174
374
  }
175
375
  function assertValidSpec(spec) {
@@ -187,12 +387,18 @@ function spriteVariants(sprite) {
187
387
  if (sprite.kind === "text") return sprite.strings.length;
188
388
  return 1;
189
389
  }
190
- function buildEntities(layer, rng, w, h) {
390
+ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
391
+ const effectiveCount = countScale === 1 ? layer.count : Math.max(1, Math.min(
392
+ Math.round(layer.count * countScale),
393
+ layer.links ? LIMITS.maxLinkLayerCount : LIMITS.maxPerLayer
394
+ ));
191
395
  const [smin, smax] = layer.size ?? [20, 40];
192
396
  const variants = spriteVariants(layer.sprite);
397
+ const colorsLen = layer.sprite.kind === "circle" && layer.sprite.colors ? layer.sprite.colors.length : 0;
398
+ const cycle = layer.sprite.kind === "emoji" || layer.sprite.kind === "text" ? layer.sprite.cycle : void 0;
193
399
  const out = [];
194
- for (let i = 0; i < layer.count; i++) {
195
- const size = layer.sprite.kind === "circle" ? rng.range(layer.sprite.radius[0], layer.sprite.radius[1]) * 2 : rng.range(smin, smax);
400
+ for (let i = 0; i < effectiveCount; i++) {
401
+ const size = layer.sprite.kind === "circle" ? rng.range(layer.sprite.radius[0], layer.sprite.radius[1]) * 2 * scale : rng.range(smin, smax) * scale;
196
402
  let vx = 0;
197
403
  let vy = 0;
198
404
  let bob = 0;
@@ -201,22 +407,25 @@ function buildEntities(layer, rng, w, h) {
201
407
  if (m.type === "static") {
202
408
  motion = "static";
203
409
  } else if (m.type === "drift") {
204
- const s = rng.range(m.speed[0], m.speed[1]);
410
+ const s = rng.range(m.speed[0], m.speed[1]) * scale;
205
411
  let angle = (m.angle ?? 0) * Math.PI / 180;
206
412
  if (m.bidirectional && rng.next() < 0.5) angle = Math.PI - angle;
207
413
  vx = s * Math.cos(angle);
208
414
  vy = s * Math.sin(angle);
209
- bob = m.bob ?? 0;
415
+ bob = (m.bob ?? 0) * scale;
210
416
  } else if (m.type === "rise") {
211
417
  motion = "rise";
212
- vy = -rng.range(m.speed[0], m.speed[1]);
213
- bob = m.sway ?? 0;
214
- } else {
418
+ vy = -rng.range(m.speed[0], m.speed[1]) * scale;
419
+ bob = (m.sway ?? 0) * scale;
420
+ } else if (m.type === "bounce") {
215
421
  motion = "bounce";
216
- const s = rng.range(m.speed[0], m.speed[1]);
422
+ const s = rng.range(m.speed[0], m.speed[1]) * scale;
217
423
  const a = rng.range(0, Math.PI * 2);
218
424
  vx = s * Math.cos(a);
219
425
  vy = s * Math.sin(a);
426
+ } else if (m.type === "orbit") {
427
+ motion = "orbit";
428
+ vx = rng.range(m.speed[0], m.speed[1]);
220
429
  }
221
430
  let x0;
222
431
  let y0;
@@ -243,7 +452,20 @@ function buildEntities(layer, rng, w, h) {
243
452
  alpha: layer.alpha ? rng.range(layer.alpha[0], layer.alpha[1]) : 1,
244
453
  pulseAmp: layer.pulse?.amp ?? 0,
245
454
  pulsePeriod: layer.pulse?.period ?? 1e3,
246
- pulsePhase: layer.pulse ? rng.range(0, Math.PI * 2) : 0
455
+ pulsePhase: layer.pulse ? rng.range(0, Math.PI * 2) : 0,
456
+ // New draws AFTER pulsePhase — guarded so existing specs keep identical streams.
457
+ spinSpeed: layer.spin ? layer.spin * Math.PI / 180 : 0,
458
+ // deg/sec → rad/sec
459
+ spinPhase: layer.spin ? rng.range(0, Math.PI * 2) : 0,
460
+ growAmp: layer.grow?.amp ?? 0,
461
+ growPeriod: layer.grow?.period ?? 1e3,
462
+ growPhase: layer.grow ? rng.range(0, Math.PI * 2) : 0,
463
+ orbitR: m.type === "orbit" ? rng.range(m.radius[0], m.radius[1]) * scale : 0,
464
+ orbitCx: m.type === "orbit" ? (m.center?.x ?? 0.5) * w : 0,
465
+ orbitCy: m.type === "orbit" ? (m.center?.y ?? 0.5) * h : 0,
466
+ // New draws AFTER orbit — guarded so existing specs keep identical streams.
467
+ colorIndex: colorsLen > 0 ? rng.int(0, colorsLen - 1) : -1,
468
+ cyclePeriod: cycle ? cycle.period : 0
247
469
  });
248
470
  }
249
471
  return out;
@@ -253,6 +475,15 @@ function alphaAt(e, t) {
253
475
  const a = e.alpha + e.pulseAmp * Math.sin(t * 2 * Math.PI / e.pulsePeriod + e.pulsePhase);
254
476
  return a < 0 ? 0 : a > 1 ? 1 : a;
255
477
  }
478
+ function sizeAt(e, t) {
479
+ if (!e.growAmp) return e.size;
480
+ const s = e.size * (1 + e.growAmp * Math.sin(t * 2 * Math.PI / e.growPeriod + e.growPhase));
481
+ return s > 0 ? s : 0.1;
482
+ }
483
+ function rotationAt(e, t) {
484
+ if (!e.spinSpeed) return 0;
485
+ return e.spinPhase + e.spinSpeed * (t / 1e3);
486
+ }
256
487
  function wrap(v, min, max) {
257
488
  const range = max - min;
258
489
  return ((v - min) % range + range) % range + min;
@@ -266,6 +497,14 @@ function reflect(v, min, max) {
266
497
  }
267
498
  function positionAt(e, t, w, h) {
268
499
  if (e.motion === "static") return { x: e.x0, y: e.y0, flip: false };
500
+ if (e.motion === "orbit") {
501
+ const angle = e.phase + e.vx * Math.PI / 180 * (t / 1e3);
502
+ return {
503
+ x: e.orbitCx + e.orbitR * Math.cos(angle),
504
+ y: e.orbitCy + e.orbitR * Math.sin(angle),
505
+ flip: false
506
+ };
507
+ }
269
508
  const dt = t / 1e3;
270
509
  const m = e.size;
271
510
  if (e.motion === "bounce") {
@@ -287,6 +526,43 @@ function positionAt(e, t, w, h) {
287
526
  if (e.bob) y += e.bob * Math.sin(t / 500 + e.phase);
288
527
  return { x, y, flip: e.headingLeft };
289
528
  }
529
+ function spriteIndexAt(e, t, variants) {
530
+ if (!e.cyclePeriod || variants <= 1) return e.spriteIndex;
531
+ return Math.floor(t / e.cyclePeriod + e.phase / (2 * Math.PI)) % variants;
532
+ }
533
+ function linkPairs(positions, k, maxDist, doWrap, w, h) {
534
+ const n = positions.length;
535
+ const edges = /* @__PURE__ */ new Set();
536
+ const out = [];
537
+ for (let i = 0; i < n; i++) {
538
+ const pi = positions[i];
539
+ const neighbors = [];
540
+ for (let j = 0; j < n; j++) {
541
+ if (i === j) continue;
542
+ const pj = positions[j];
543
+ let dx = pj.x - pi.x;
544
+ let dy = pj.y - pi.y;
545
+ if (doWrap) {
546
+ if (Math.abs(dx) > w / 2) dx = dx > 0 ? dx - w : dx + w;
547
+ if (Math.abs(dy) > h / 2) dy = dy > 0 ? dy - h : dy + h;
548
+ }
549
+ const dist = Math.sqrt(dx * dx + dy * dy);
550
+ if (dist <= maxDist) neighbors.push({ dist, j });
551
+ }
552
+ neighbors.sort((a, b) => a.dist - b.dist || a.j - b.j);
553
+ for (let ni = 0; ni < Math.min(k, neighbors.length); ni++) {
554
+ const j = neighbors[ni].j;
555
+ const lo = Math.min(i, j);
556
+ const hi = Math.max(i, j);
557
+ const key = `${lo}:${hi}`;
558
+ if (!edges.has(key)) {
559
+ edges.add(key);
560
+ out.push([lo, hi]);
561
+ }
562
+ }
563
+ }
564
+ return out;
565
+ }
290
566
 
291
567
  // src/compile.ts
292
568
  import {
@@ -360,12 +636,39 @@ function lerpSpec(from, to, k) {
360
636
  };
361
637
  return walk(from, to);
362
638
  }
639
+ function steerablePaths(spec) {
640
+ if (!spec || typeof spec !== "object") return [];
641
+ const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units"]);
642
+ const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
643
+ const out = [];
644
+ const walk = (node, prefix, key) => {
645
+ if (Array.isArray(node) && INDEXED.has(key)) {
646
+ node.forEach((child, i) => walk(child, `${prefix}.${i}`, ""));
647
+ return;
648
+ }
649
+ if (Array.isArray(node)) {
650
+ if (prefix) out.push(prefix);
651
+ return;
652
+ }
653
+ if (node && typeof node === "object") {
654
+ for (const [k, v] of Object.entries(node)) {
655
+ if (SKIP.has(k)) continue;
656
+ walk(v, prefix ? `${prefix}.${k}` : k, k);
657
+ }
658
+ return;
659
+ }
660
+ if (prefix) out.push(prefix);
661
+ };
662
+ walk(spec, "", "");
663
+ return out;
664
+ }
363
665
  function easeSmooth(k) {
364
666
  const c = Math.max(0, Math.min(1, k));
365
667
  return c * c * (3 - 2 * c);
366
668
  }
367
669
  function structuralSignature(spec) {
368
- return JSON.stringify(
670
+ return JSON.stringify([
671
+ spec.units,
369
672
  spec.layers.map((l) => [
370
673
  l.count,
371
674
  l.size,
@@ -376,11 +679,14 @@ function structuralSignature(spec) {
376
679
  l.flip,
377
680
  l.alpha,
378
681
  l.pulse,
682
+ l.spin,
683
+ l.grow,
379
684
  l.sprite.kind,
380
685
  l.sprite.kind === "circle" ? l.sprite.radius : void 0,
381
- l.sprite.kind === "emoji" ? l.sprite.glyphs.length : l.sprite.kind === "text" ? l.sprite.strings.length : void 0
686
+ l.sprite.kind === "circle" ? l.sprite.colors?.length : void 0,
687
+ l.sprite.kind === "emoji" ? [l.sprite.glyphs.length, l.sprite.cycle?.period] : l.sprite.kind === "text" ? [l.sprite.strings.length, l.sprite.cycle?.period] : void 0
382
688
  ])
383
- );
689
+ ]);
384
690
  }
385
691
 
386
692
  // src/compile.ts
@@ -460,10 +766,16 @@ var SpecInstance = class {
460
766
  this.canvas.height = Math.max(1, Math.round(this.h * dpr));
461
767
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
462
768
  }
463
- /** (Re)seed and place all entities — deterministic for the seed + size. */
769
+ /** (Re)seed and place all entities — deterministic for the seed + viewport. */
464
770
  rebuild() {
465
771
  const rng = createRng(this.seed);
466
- this.layers = this.effSpec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, this.w, this.h) }));
772
+ const scale = this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1;
773
+ let countScale = scale > 1 ? Math.min(this.w, this.h) / LIMITS.referenceViewport : 1;
774
+ if (countScale > 1) {
775
+ const rawTotal = this.effSpec.layers.reduce((s, l) => s + Math.round(l.count * countScale), 0);
776
+ if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
777
+ }
778
+ this.layers = this.effSpec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, this.w, this.h, scale, countScale) }));
467
779
  this.lastStructural = structuralSignature(this.effSpec);
468
780
  }
469
781
  start() {
@@ -484,7 +796,7 @@ var SpecInstance = class {
484
796
  this.lastT = now - this.startT + this.baseT;
485
797
  this.renderFrame(this.lastT, this.seed);
486
798
  }
487
- drawBackground() {
799
+ drawBackground(t) {
488
800
  const { ctx, w, h } = this;
489
801
  const bg = this.effSpec.background;
490
802
  if (!bg || bg.type === "solid") {
@@ -493,50 +805,121 @@ var SpecInstance = class {
493
805
  return;
494
806
  }
495
807
  const g = ctx.createLinearGradient(0, 0, 0, h);
496
- for (const s of bg.stops) g.addColorStop(Math.max(0, Math.min(1, s.at)), s.color);
808
+ const drift = bg.drift;
809
+ for (let i = 0; i < bg.stops.length; i++) {
810
+ const s = bg.stops[i];
811
+ let at = s.at;
812
+ if (drift) {
813
+ const amount = drift.amount ?? 0.15;
814
+ const phase = i / bg.stops.length * Math.PI * 2;
815
+ at = Math.max(0, Math.min(1, at + amount * Math.sin(t * 2 * Math.PI / drift.period + phase)));
816
+ }
817
+ g.addColorStop(at, s.color);
818
+ }
497
819
  ctx.fillStyle = g;
498
820
  ctx.fillRect(0, 0, w, h);
499
821
  if (bg.band) {
500
822
  ctx.fillStyle = bg.band.color;
501
- ctx.fillRect(0, h - bg.band.height, w, bg.band.height);
823
+ const bh = bg.band.height * (this.effSpec.units === "viewport" ? Math.min(w, h) : 1);
824
+ ctx.fillRect(0, h - bh, w, bh);
825
+ }
826
+ }
827
+ drawTrail(built, e, t) {
828
+ const trail = built.layer.trail;
829
+ if (!trail) return;
830
+ const { ctx, w, h } = this;
831
+ const fade = trail.fade ?? 1;
832
+ const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
833
+ const headAlpha = alphaAt(e, t);
834
+ const headSize = sizeAt(e, t);
835
+ const sprite = built.layer.sprite;
836
+ const resolvedColor = sprite.kind === "circle" ? sprite.colors?.[e.colorIndex] ?? sprite.color : sprite.kind === "text" ? sprite.color ?? "#e6e8ef" : "#e6e8ef";
837
+ const isSoft = sprite.kind === "circle" && sprite.soft;
838
+ const wrap2 = built.layer.wrap !== false;
839
+ const head = positionAt(e, t, w, h);
840
+ let prevX = head.x;
841
+ let prevY = head.y;
842
+ for (let s = 1; s <= n; s++) {
843
+ const k = s / n;
844
+ const pastT = t - k * trail.length;
845
+ if (pastT < 0) break;
846
+ const pos = positionAt(e, pastT, w, h);
847
+ if (wrap2) {
848
+ const dx = pos.x - prevX;
849
+ const dy = pos.y - prevY;
850
+ if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) break;
851
+ }
852
+ prevX = pos.x;
853
+ prevY = pos.y;
854
+ const a = headAlpha * (1 - k * fade);
855
+ if (a <= 0) break;
856
+ const r = headSize / 2 * (1 - k * 0.7);
857
+ if (r < 0.2) break;
858
+ ctx.globalAlpha = a;
859
+ if (isSoft) {
860
+ const g = ctx.createRadialGradient(pos.x, pos.y, 0, pos.x, pos.y, r);
861
+ g.addColorStop(0, resolvedColor);
862
+ g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
863
+ g.addColorStop(1, hexToRgba(resolvedColor, 0));
864
+ ctx.fillStyle = g;
865
+ } else {
866
+ ctx.fillStyle = resolvedColor;
867
+ }
868
+ ctx.beginPath();
869
+ ctx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
870
+ ctx.fill();
502
871
  }
503
872
  }
504
873
  drawEntity(built, e, t) {
505
874
  const { ctx } = this;
506
875
  const p = positionAt(e, t, this.w, this.h);
507
876
  const sprite = built.layer.sprite;
877
+ const sz = sizeAt(e, t);
878
+ const rot = rotationAt(e, t);
508
879
  ctx.globalAlpha = alphaAt(e, t);
509
880
  if (sprite.kind === "circle") {
510
- const r = e.size / 2;
881
+ const r = sz / 2;
882
+ const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
883
+ ctx.save();
884
+ if (rot) {
885
+ ctx.translate(p.x, p.y);
886
+ ctx.rotate(rot);
887
+ ctx.translate(-p.x, -p.y);
888
+ }
511
889
  if (sprite.soft) {
512
890
  const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r);
513
- g.addColorStop(0, sprite.color);
514
- g.addColorStop(0.35, hexToRgba(sprite.color, 0.75));
515
- g.addColorStop(1, hexToRgba(sprite.color, 0));
891
+ g.addColorStop(0, resolvedColor);
892
+ g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
893
+ g.addColorStop(1, hexToRgba(resolvedColor, 0));
516
894
  ctx.fillStyle = g;
517
895
  } else {
518
- ctx.fillStyle = sprite.color;
896
+ ctx.fillStyle = resolvedColor;
519
897
  }
520
898
  ctx.beginPath();
521
899
  ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
522
900
  ctx.fill();
901
+ ctx.restore();
523
902
  return;
524
903
  }
525
904
  ctx.save();
526
905
  ctx.translate(p.x, p.y);
906
+ if (rot) ctx.rotate(rot);
527
907
  if (p.flip && built.layer.flip) ctx.scale(-1, 1);
528
908
  ctx.textAlign = "center";
529
909
  ctx.textBaseline = "middle";
530
910
  if (sprite.kind === "emoji") {
531
- ctx.font = `${e.size}px serif`;
532
- ctx.fillText(sprite.glyphs[e.spriteIndex] ?? sprite.glyphs[0], 0, 0);
911
+ const idx = spriteIndexAt(e, t, sprite.glyphs.length);
912
+ ctx.font = `${sz}px serif`;
913
+ ctx.fillText(sprite.glyphs[idx] ?? sprite.glyphs[0], 0, 0);
533
914
  } else {
534
915
  ctx.textAlign = sprite.align ?? "center";
535
916
  ctx.textBaseline = sprite.baseline ?? "middle";
536
- ctx.font = sprite.font ?? `${e.size}px system-ui, sans-serif`;
917
+ ctx.font = sprite.font ?? `${sz}px system-ui, sans-serif`;
537
918
  ctx.fillStyle = sprite.color ?? "#e6e8ef";
538
- const text = sprite.strings[e.spriteIndex] ?? sprite.strings[0];
539
- if (sprite.maxWidth) ctx.fillText(text, 0, 0, sprite.maxWidth);
919
+ const idx = spriteIndexAt(e, t, sprite.strings.length);
920
+ const text = sprite.strings[idx] ?? sprite.strings[0];
921
+ const mw = sprite.maxWidth ? sprite.maxWidth * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1) : void 0;
922
+ if (mw) ctx.fillText(text, 0, 0, mw);
540
923
  else ctx.fillText(text, 0, 0);
541
924
  }
542
925
  ctx.restore();
@@ -579,16 +962,53 @@ var SpecInstance = class {
579
962
  this.renderFrame(this.lastT, this.seed);
580
963
  }
581
964
  }
965
+ drawLinks(built, t) {
966
+ const { links } = built.layer;
967
+ if (!links) return;
968
+ const { ctx } = this;
969
+ const wrap2 = built.layer.wrap !== false;
970
+ const positions = built.entities.map((e) => positionAt(e, t, this.w, this.h));
971
+ const pairs = linkPairs(positions, links.k, links.maxDist * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1), wrap2, this.w, this.h);
972
+ const lw = (links.width ?? 1) * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1);
973
+ ctx.lineWidth = lw;
974
+ for (const [i, j] of pairs) {
975
+ const pi = positions[i];
976
+ const pj = positions[j];
977
+ const ei = built.entities[i];
978
+ let resolvedColor = links.color;
979
+ if (!resolvedColor) {
980
+ const sprite = built.layer.sprite;
981
+ if (sprite.kind === "circle") resolvedColor = sprite.colors?.[ei.colorIndex] ?? sprite.color;
982
+ else resolvedColor = "#e6e8ef";
983
+ }
984
+ ctx.globalAlpha = links.alpha ?? alphaAt(ei, t);
985
+ ctx.strokeStyle = resolvedColor;
986
+ let dx = pj.x - pi.x;
987
+ let dy = pj.y - pi.y;
988
+ if (wrap2) {
989
+ if (Math.abs(dx) > this.w / 2) dx = dx > 0 ? dx - this.w : dx + this.w;
990
+ if (Math.abs(dy) > this.h / 2) dy = dy > 0 ? dy - this.h : dy + this.h;
991
+ }
992
+ ctx.beginPath();
993
+ ctx.moveTo(pi.x, pi.y);
994
+ ctx.lineTo(pi.x + dx, pi.y + dy);
995
+ ctx.stroke();
996
+ }
997
+ }
582
998
  /** Deterministic, frame-addressable render (shared by the rAF loop). */
583
999
  renderFrame(t, _seed) {
584
1000
  this.stepTransition(t);
585
1001
  const { ctx } = this;
586
1002
  ctx.globalAlpha = 1;
587
1003
  ctx.globalCompositeOperation = "source-over";
588
- this.drawBackground();
1004
+ this.drawBackground(t);
589
1005
  for (const built of this.layers) {
590
1006
  ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
591
- for (const e of built.entities) this.drawEntity(built, e, t);
1007
+ this.drawLinks(built, t);
1008
+ for (const e of built.entities) {
1009
+ this.drawTrail(built, e, t);
1010
+ this.drawEntity(built, e, t);
1011
+ }
592
1012
  }
593
1013
  ctx.globalAlpha = 1;
594
1014
  ctx.globalCompositeOperation = "source-over";
@@ -623,6 +1043,222 @@ function compileSaver(spec) {
623
1043
  };
624
1044
  }
625
1045
 
1046
+ // src/advise.ts
1047
+ import { createRng as createRng2 } from "@idle-screens/core";
1048
+ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
1049
+ const warnings = [];
1050
+ const w = viewport.width;
1051
+ const h = viewport.height;
1052
+ const scale = spec.units === "viewport" ? Math.min(w, h) : 1;
1053
+ const countScale = scale > 1 ? Math.min(w, h) / LIMITS.referenceViewport : 1;
1054
+ const rng = createRng2(spec.seed ?? 42);
1055
+ const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale, countScale));
1056
+ let totalEntities = 0;
1057
+ let textLayerCount = 0;
1058
+ let motionLayerCount = 0;
1059
+ for (let li = 0; li < spec.layers.length; li++) {
1060
+ const layer = spec.layers[li];
1061
+ const entities = allEntities[li];
1062
+ totalEntities += entities.length;
1063
+ const isText = layer.sprite.kind === "text";
1064
+ const isStaticText = isText && layer.motion.type === "static";
1065
+ if (isStaticText) textLayerCount++;
1066
+ if (layer.motion.type !== "static") motionLayerCount++;
1067
+ if (layer.trail && layer.motion.type === "static") {
1068
+ warnings.push({
1069
+ path: `layers[${li}].trail`,
1070
+ code: "trail-on-static",
1071
+ message: "trail has no effect on static entities \u2014 they have no past positions to draw"
1072
+ });
1073
+ }
1074
+ if (layer.sprite.kind === "circle") {
1075
+ const maxR = entities.reduce((m, e) => Math.max(m, e.size / 2), 0);
1076
+ const maxAlpha = entities.reduce((m, e) => Math.max(m, e.alpha), 0);
1077
+ if (maxR * maxAlpha < 0.05) {
1078
+ warnings.push({
1079
+ path: `layers[${li}]`,
1080
+ code: "invisible-layer",
1081
+ message: `layer's max visible radius (${maxR.toFixed(1)}px \xD7 ${maxAlpha.toFixed(2)} alpha) is below perceptual floor`
1082
+ });
1083
+ }
1084
+ }
1085
+ }
1086
+ let totalCoverage = 0;
1087
+ for (let li = 0; li < spec.layers.length; li++) {
1088
+ const layer = spec.layers[li];
1089
+ const entities = allEntities[li];
1090
+ for (const e of entities) {
1091
+ const r = e.size / 2;
1092
+ const pixArea = layer.sprite.kind === "circle" ? Math.PI * r * r : e.size * e.size;
1093
+ totalCoverage += pixArea * e.alpha / (w * h);
1094
+ }
1095
+ }
1096
+ if (totalCoverage < 5e-4 && spec.layers.length > 0) {
1097
+ warnings.push({
1098
+ path: "layers",
1099
+ code: "sparse-scene",
1100
+ message: `alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 scene will look empty on most displays`
1101
+ });
1102
+ }
1103
+ if (totalEntities > 500) {
1104
+ warnings.push({
1105
+ path: "layers",
1106
+ code: "dense-scene",
1107
+ message: `${totalEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
1108
+ });
1109
+ }
1110
+ if (spec.layers.length > 0 && textLayerCount > 0 && motionLayerCount === 0) {
1111
+ const textRatio = textLayerCount / spec.layers.length;
1112
+ if (textRatio >= 0.8) {
1113
+ warnings.push({
1114
+ path: "layers",
1115
+ code: "text-heavy",
1116
+ message: "most layers are static text \u2014 reads as a document, not a screensaver"
1117
+ });
1118
+ }
1119
+ }
1120
+ for (let li = 0; li < spec.layers.length; li++) {
1121
+ const layer = spec.layers[li];
1122
+ if (!layer.links) continue;
1123
+ const entities = allEntities[li];
1124
+ const positions = entities.map((e) => positionAt(e, 0, w, h));
1125
+ const maxDist = layer.links.maxDist * scale;
1126
+ const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
1127
+ const maxPossible = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
1128
+ if (maxPossible > 0 && pairs.length / maxPossible < 0.1) {
1129
+ warnings.push({
1130
+ path: `layers[${li}].links`,
1131
+ code: "link-starvation",
1132
+ message: `only ${pairs.length}/${maxPossible} possible edges formed \u2014 raise count or widen links.maxDist`
1133
+ });
1134
+ }
1135
+ }
1136
+ for (let li = 0; li < spec.layers.length; li++) {
1137
+ const layer = spec.layers[li];
1138
+ if (layer.motion.type === "static") continue;
1139
+ const entities = allEntities[li];
1140
+ if (entities.length < 3) continue;
1141
+ const speeds = entities.map((e) => Math.sqrt(e.vx * e.vx + e.vy * e.vy));
1142
+ const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length;
1143
+ if (mean === 0) continue;
1144
+ const maxDev = Math.max(...speeds.map((s) => Math.abs(s - mean)));
1145
+ if (maxDev / mean < 0.05) {
1146
+ warnings.push({
1147
+ path: `layers[${li}].motion`,
1148
+ code: "uniform-motion",
1149
+ message: "all entities move at nearly identical speed \u2014 widen the speed range for visual variety"
1150
+ });
1151
+ }
1152
+ }
1153
+ if (totalEntities >= 8) {
1154
+ const positions = allEntities.flatMap((ents) => ents.map((e) => positionAt(e, 0, w, h)));
1155
+ const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
1156
+ const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
1157
+ const offX = Math.abs(cx / w - 0.5);
1158
+ const offY = Math.abs(cy / h - 0.5);
1159
+ if (offX > 0.2 || offY > 0.2) {
1160
+ warnings.push({
1161
+ path: "layers",
1162
+ code: "off-center",
1163
+ message: `centroid at (${(cx / w).toFixed(2)}, ${(cy / h).toFixed(2)}) \u2014 composition is heavily offset from center`
1164
+ });
1165
+ }
1166
+ }
1167
+ return warnings;
1168
+ }
1169
+
1170
+ // src/describe.ts
1171
+ import { createRng as createRng3 } from "@idle-screens/core";
1172
+ function describeScene(spec, options = {}) {
1173
+ const { width: w, height: h } = options.viewport ?? { width: 1920, height: 1080 };
1174
+ const seed = options.seed ?? spec.seed ?? 42;
1175
+ const times = options.times ?? [0, 5e3, 15e3];
1176
+ const scale = spec.units === "viewport" ? Math.min(w, h) : 1;
1177
+ const countScale = scale > 1 ? Math.min(w, h) / LIMITS.referenceViewport : 1;
1178
+ const rng = createRng3(seed);
1179
+ const builtLayers = spec.layers.map((layer) => ({
1180
+ layer,
1181
+ entities: buildEntities(layer, rng, w, h, scale, countScale)
1182
+ }));
1183
+ const snapshots = times.map((t) => {
1184
+ const layerSnapshots = builtLayers.map(({ layer, entities }) => {
1185
+ const positions = entities.map((e) => positionAt(e, t, w, h));
1186
+ const cx = positions.length > 0 ? positions.reduce((s, p) => s + p.x, 0) / positions.length : 0;
1187
+ const cy = positions.length > 0 ? positions.reduce((s, p) => s + p.y, 0) / positions.length : 0;
1188
+ let pixArea = 0;
1189
+ for (const e of entities) {
1190
+ const r = e.size / 2;
1191
+ pixArea += layer.sprite.kind === "circle" ? Math.PI * r * r : e.size * e.size;
1192
+ }
1193
+ const coverage = pixArea / (w * h);
1194
+ const meanAlpha = entities.length > 0 ? entities.reduce((s, e) => s + e.alpha, 0) / entities.length : 0;
1195
+ let linksDrawn = null;
1196
+ let linksExpected = null;
1197
+ let connectedComponents = null;
1198
+ let isolatedNodes = null;
1199
+ if (layer.links) {
1200
+ const maxDist = layer.links.maxDist * scale;
1201
+ const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
1202
+ linksDrawn = pairs.length;
1203
+ linksExpected = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
1204
+ const parent = Array.from({ length: entities.length }, (_, i) => i);
1205
+ const find = (x) => {
1206
+ while (parent[x] !== x) {
1207
+ parent[x] = parent[parent[x]];
1208
+ x = parent[x];
1209
+ }
1210
+ return x;
1211
+ };
1212
+ for (const [a, b] of pairs) {
1213
+ const ra = find(a);
1214
+ const rb = find(b);
1215
+ if (ra !== rb) parent[ra] = rb;
1216
+ }
1217
+ const roots = new Set(entities.map((_, i) => find(i)));
1218
+ connectedComponents = roots.size;
1219
+ const degree = new Uint16Array(entities.length);
1220
+ for (const [a, b] of pairs) {
1221
+ degree[a]++;
1222
+ degree[b]++;
1223
+ }
1224
+ isolatedNodes = degree.filter((d) => d === 0).length;
1225
+ }
1226
+ return {
1227
+ key: layer.key,
1228
+ count: entities.length,
1229
+ centroid: { x: +(cx / w).toFixed(3), y: +(cy / h).toFixed(3) },
1230
+ coverage: +coverage.toFixed(4),
1231
+ meanAlpha: +meanAlpha.toFixed(3),
1232
+ linksDrawn,
1233
+ linksExpected,
1234
+ connectedComponents,
1235
+ isolatedNodes
1236
+ };
1237
+ });
1238
+ const warnings = [];
1239
+ for (const ls of layerSnapshots) {
1240
+ const label = ls.key ?? `layer`;
1241
+ if (ls.coverage * ls.meanAlpha < 5e-4) {
1242
+ warnings.push(`${label}: effectively invisible (coverage\xD7alpha=${(ls.coverage * ls.meanAlpha).toFixed(5)})`);
1243
+ }
1244
+ if (ls.linksDrawn !== null && ls.linksExpected !== null && ls.linksExpected > 0) {
1245
+ const ratio = ls.linksDrawn / ls.linksExpected;
1246
+ if (ratio < 0.1) {
1247
+ warnings.push(`${label}: link starvation \u2014 ${ls.linksDrawn}/${ls.linksExpected} edges formed`);
1248
+ }
1249
+ }
1250
+ if (ls.isolatedNodes !== null && ls.count > 0 && ls.isolatedNodes / ls.count > 0.5) {
1251
+ warnings.push(`${label}: ${ls.isolatedNodes}/${ls.count} nodes isolated \u2014 graph is disconnected`);
1252
+ }
1253
+ }
1254
+ return { t, viewport: { w, h }, layers: layerSnapshots, warnings };
1255
+ });
1256
+ return {
1257
+ spec: { id: spec.id, label: spec.label, units: spec.units ?? "px" },
1258
+ snapshots
1259
+ };
1260
+ }
1261
+
626
1262
  // src/examples/aquarium.ts
627
1263
  var AQUARIUM_SPEC = {
628
1264
  schemaVersion: 1,
@@ -655,6 +1291,98 @@ var AQUARIUM_SPEC = {
655
1291
  ]
656
1292
  };
657
1293
 
1294
+ // src/examples/comets.ts
1295
+ var COMETS_SPEC = {
1296
+ schemaVersion: 1,
1297
+ id: "comets",
1298
+ label: "Comet Shower",
1299
+ seed: 42,
1300
+ motionIntensity: "moderate",
1301
+ background: {
1302
+ type: "gradient",
1303
+ stops: [
1304
+ { at: 0, color: "#0a0015" },
1305
+ { at: 0.5, color: "#0d1b2a" },
1306
+ { at: 1, color: "#1b0a2e" }
1307
+ ],
1308
+ drift: { period: 25e3, amount: 0.12 }
1309
+ },
1310
+ layers: [
1311
+ {
1312
+ key: "stars",
1313
+ count: 120,
1314
+ sprite: { kind: "circle", radius: [0.5, 2], color: "#ffffff", soft: true },
1315
+ motion: { type: "static" },
1316
+ alpha: [0.3, 0.7],
1317
+ pulse: { amp: 0.15, period: 3e3 }
1318
+ },
1319
+ {
1320
+ key: "comets",
1321
+ count: 8,
1322
+ sprite: { kind: "circle", radius: [3, 8], color: "#88ccff", soft: true },
1323
+ motion: { type: "drift", speed: [80, 200], angle: 225 },
1324
+ alpha: [0.7, 1],
1325
+ blend: "lighter",
1326
+ trail: { length: 1500, fade: 1 }
1327
+ },
1328
+ {
1329
+ key: "fireflies",
1330
+ count: 20,
1331
+ sprite: { kind: "circle", radius: [2, 5], color: "#ffdd44", soft: true },
1332
+ motion: { type: "drift", speed: [10, 30], bidirectional: true, bob: 20 },
1333
+ alpha: [0.4, 0.8],
1334
+ pulse: { amp: 0.3, period: 2e3 },
1335
+ blend: "lighter",
1336
+ trail: { length: 800 }
1337
+ }
1338
+ ]
1339
+ };
1340
+
1341
+ // src/examples/constellation.ts
1342
+ var CONSTELLATION_SPEC = {
1343
+ schemaVersion: 1,
1344
+ id: "constellation",
1345
+ label: "Constellation",
1346
+ seed: 73,
1347
+ motionIntensity: "calm",
1348
+ background: {
1349
+ type: "gradient",
1350
+ stops: [
1351
+ { at: 0, color: "#020212" },
1352
+ { at: 0.5, color: "#050520" },
1353
+ { at: 1, color: "#0a0830" }
1354
+ ]
1355
+ },
1356
+ layers: [
1357
+ // Distant static stars (no links, just atmosphere)
1358
+ {
1359
+ count: 100,
1360
+ sprite: { kind: "circle", radius: [1, 3], color: "#667799", colors: ["#8899cc", "#99aadd", "#aabbee", "#7788aa"] },
1361
+ alpha: [0.2, 0.7],
1362
+ pulse: { amp: 0.2, period: 3e3 },
1363
+ motion: { type: "static" }
1364
+ },
1365
+ // Connected constellation nodes — slow drift, linked
1366
+ {
1367
+ key: "nodes",
1368
+ count: 40,
1369
+ sprite: { kind: "circle", radius: [2, 5], color: "#88aaff", colors: ["#88aaff", "#aaccff", "#ffcc88", "#ff8866"], soft: true },
1370
+ alpha: [0.5, 0.9],
1371
+ blend: "lighter",
1372
+ motion: { type: "drift", speed: [2, 8], bidirectional: true, bob: 3 },
1373
+ links: { k: 3, maxDist: 200, alpha: 0.15, width: 0.5 }
1374
+ },
1375
+ // Cycling asterism markers
1376
+ {
1377
+ count: 6,
1378
+ sprite: { kind: "emoji", glyphs: ["\u2726", "\u2727", "\u22C6", "\u2217"], cycle: { period: 2e3 } },
1379
+ size: [14, 22],
1380
+ alpha: [0.4, 0.7],
1381
+ motion: { type: "drift", speed: [1, 4], bidirectional: true }
1382
+ }
1383
+ ]
1384
+ };
1385
+
658
1386
  // src/examples/dev-dashboard.ts
659
1387
  var DASHBOARD_SPEC = {
660
1388
  schemaVersion: 1,
@@ -1219,6 +1947,90 @@ var LANTERNS_SPEC = {
1219
1947
  ]
1220
1948
  };
1221
1949
 
1950
+ // src/examples/orrery.ts
1951
+ var ORRERY_SPEC = {
1952
+ schemaVersion: 1,
1953
+ id: "orrery",
1954
+ label: "Orrery",
1955
+ seed: 7,
1956
+ motionIntensity: "calm",
1957
+ background: {
1958
+ type: "gradient",
1959
+ stops: [
1960
+ { at: 0, color: "#020210" },
1961
+ { at: 0.5, color: "#06051a" },
1962
+ { at: 1, color: "#0a0820" }
1963
+ ]
1964
+ },
1965
+ layers: [
1966
+ // Distant stars
1967
+ {
1968
+ count: 80,
1969
+ sprite: { kind: "circle", radius: [0.3, 1.2], color: "#8899cc" },
1970
+ alpha: [0.2, 0.8],
1971
+ pulse: { amp: 0.3, period: 3e3 },
1972
+ motion: { type: "static" }
1973
+ },
1974
+ // Sun corona (soft glow)
1975
+ {
1976
+ key: "corona",
1977
+ count: 1,
1978
+ sprite: { kind: "circle", radius: [60, 60], color: "#ffaa22", soft: true },
1979
+ blend: "lighter",
1980
+ alpha: [0.6, 0.6],
1981
+ grow: { amp: 0.15, period: 4e3 },
1982
+ motion: { type: "static" },
1983
+ position: { x: 0.5, y: 0.5 }
1984
+ },
1985
+ // Sun body
1986
+ {
1987
+ key: "sun",
1988
+ count: 1,
1989
+ sprite: { kind: "emoji", glyphs: ["\u2600\uFE0F"] },
1990
+ size: [48, 48],
1991
+ grow: { amp: 0.08, period: 4e3 },
1992
+ motion: { type: "static" },
1993
+ position: { x: 0.5, y: 0.5 }
1994
+ },
1995
+ // Inner ring — small fast planets
1996
+ {
1997
+ key: "inner",
1998
+ count: 3,
1999
+ sprite: { kind: "emoji", glyphs: ["\u{1FAA8}", "\u{1F534}", "\u{1F7E0}"] },
2000
+ size: [16, 22],
2001
+ spin: 30,
2002
+ motion: { type: "orbit", speed: [28, 42], radius: [80, 120] }
2003
+ },
2004
+ // Middle ring — gas giants
2005
+ {
2006
+ key: "middle",
2007
+ count: 2,
2008
+ sprite: { kind: "emoji", glyphs: ["\u{1F7E4}", "\u{1FA90}"] },
2009
+ size: [28, 38],
2010
+ spin: -15,
2011
+ motion: { type: "orbit", speed: [12, 20], radius: [160, 200] }
2012
+ },
2013
+ // Outer ring — ice giants
2014
+ {
2015
+ key: "outer",
2016
+ count: 2,
2017
+ sprite: { kind: "emoji", glyphs: ["\u{1F535}", "\u{1F48E}"] },
2018
+ size: [22, 30],
2019
+ spin: 10,
2020
+ motion: { type: "orbit", speed: [5, 10], radius: [240, 300] }
2021
+ },
2022
+ // Orbital dust ring (soft glow particles orbiting slowly)
2023
+ {
2024
+ count: 24,
2025
+ sprite: { kind: "circle", radius: [1, 3], color: "#ccaa66", soft: true },
2026
+ alpha: [0.15, 0.4],
2027
+ blend: "lighter",
2028
+ pulse: { amp: 0.2, period: 2500 },
2029
+ motion: { type: "orbit", speed: [2, 8], radius: [100, 320] }
2030
+ }
2031
+ ]
2032
+ };
2033
+
1222
2034
  // src/examples/rain.ts
1223
2035
  var RAIN_SPEC = {
1224
2036
  schemaVersion: 1,
@@ -1338,7 +2150,10 @@ var SCHEMA_EXAMPLES = [
1338
2150
  { id: "snowfall", label: "Snowfall", spec: SNOWFALL_SPEC },
1339
2151
  { id: "lanterns", label: "Night Lanterns", spec: LANTERNS_SPEC },
1340
2152
  { id: "sakura", label: "Sakura Drift", spec: SAKURA_SPEC },
1341
- { id: "dev-dashboard", label: "Dev Dashboard", spec: DASHBOARD_SPEC }
2153
+ { id: "dev-dashboard", label: "Dev Dashboard", spec: DASHBOARD_SPEC },
2154
+ { id: "orrery", label: "Orrery", spec: ORRERY_SPEC },
2155
+ { id: "constellation", label: "Constellation", spec: CONSTELLATION_SPEC },
2156
+ { id: "comets", label: "Comet Shower", spec: COMETS_SPEC }
1342
2157
  ];
1343
2158
  var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
1344
2159
  var EXAMPLE_BY_ID = Object.fromEntries(
@@ -1346,27 +2161,37 @@ var EXAMPLE_BY_ID = Object.fromEntries(
1346
2161
  );
1347
2162
  export {
1348
2163
  AQUARIUM_SPEC,
2164
+ COMETS_SPEC,
2165
+ CONSTELLATION_SPEC,
1349
2166
  DASHBOARD_SPEC,
1350
2167
  EXAMPLE_BY_ID,
1351
2168
  EXAMPLE_SPECS,
1352
2169
  LANTERNS_SPEC,
1353
2170
  LIMITS,
2171
+ ORRERY_SPEC,
1354
2172
  RAIN_SPEC,
1355
2173
  SAKURA_SPEC,
1356
2174
  SCHEMA_EXAMPLES,
1357
2175
  SCHEMA_VERSION,
1358
2176
  SNOWFALL_SPEC,
2177
+ adviseSpec,
1359
2178
  alphaAt,
1360
2179
  applyDeltasToSpec,
1361
2180
  assertValidSpec,
1362
2181
  buildEntities,
1363
2182
  compileSaver,
2183
+ describeScene,
1364
2184
  easeSmooth,
1365
2185
  lerpSpec,
2186
+ linkPairs,
1366
2187
  manifestFor,
1367
2188
  positionAt,
1368
2189
  resolveSpecPath,
2190
+ rotationAt,
2191
+ sizeAt,
2192
+ spriteIndexAt,
1369
2193
  spriteVariants,
2194
+ steerablePaths,
1370
2195
  structuralSignature,
1371
2196
  validateSpec
1372
2197
  };