@idle-screens/schema 3.6.0 → 3.6.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/FORMAT.md CHANGED
@@ -105,9 +105,11 @@ must exist, have `count: 1`, and not themselves orbit a layer.
105
105
 
106
106
  With `units: "viewport"` (the default) every dimensional value — sizes, radii,
107
107
  speeds, distances, wavelengths, stroke widths — is a fraction of
108
- `min(width, height)`, so specs scale to any display. Exception: text sprites
109
- with an explicit `px` size in their `font` string (e.g. `"bold 14px monospace"`)
110
- use that CSS font verbatim and do not scale with the viewport.
108
+ `min(width, height)`, so specs scale to any display. Text sprites with an
109
+ explicit `px` size in their `font` string (e.g. `"bold 14px monospace"`) are no
110
+ exception: the compiler rescales that number by
111
+ `min(width, height) / referenceViewport` so the text holds its apparent size,
112
+ and only `units: "px"` specs get it verbatim. See **Scale** below.
111
113
 
112
114
  `ghosting` paints each frame over a faded copy of the previous one instead of
113
115
  clearing: moving entities leave decaying after-images (Mystify smears, Matrix
@@ -169,7 +171,8 @@ with distance, removing pop-in at the cutoff.
169
171
  - `{ "kind": "emoji", "glyphs": ["🐟", "🐠"], "cycle": { "period": 800 } }` —
170
172
  glyph picked per entity (seeded); optional `cycle` rotates variants over time
171
173
  - `{ "kind": "text", "strings": ["HELLO"], "color": "#e6e8ef", "font": "bold monospace", "align": "center", "baseline": "middle", "maxWidth": 300, "cycle": ... }`
172
- — a `font` **with** a px size is used verbatim; a family/weight only
174
+ — a `font` **with** a px size keeps that shorthand, its number rescaled to the
175
+ viewport (verbatim only under `units: "px"`); a family/weight only
173
176
  (`"bold monospace"`) composes with the seeded per-entity `size`
174
177
  - `{ "kind": "circle", "radius": [0.001, 0.003], "color": "#ffffff", "soft": true }` —
175
178
  `soft` renders a radial-falloff glow orb instead of a hard disc
@@ -304,6 +307,134 @@ an event, where the same ring pulsing forever reads as wallpaper. Long
304
307
  drift a little, settle, fade" is one layer). `clock` is the other half: two
305
308
  layers on the same clock are a duet, not a coincidence. The shipped `pings`
306
309
  example is the whole idea in two layers.
310
+ ## Scale — what the numbers actually look like
311
+
312
+ Every dimensional value is a fraction of `min(width, height)`. On a 1920×1080
313
+ display that divisor is **1080**, so `0.01` is 10.8 px there and the same
314
+ fraction of the short side on a phone or a 4K wall. Nothing below is a rule —
315
+ they are measured landmarks, because the most common authoring failure is not
316
+ a bad idea, it is an idea rendered two orders of magnitude too small or too
317
+ large to see. The px conversions and coverage figures are measured
318
+ (`perceiveScene` at 1920×1080, 40 opaque white entities on black, unless a row
319
+ says otherwise); the "reads as" readings and the recipe advice are judgment.
320
+
321
+ ### `circle.radius` (a **radius**, so a dot is twice this wide)
322
+
323
+ | radius | px radius @1080p | px across | coverage (40) | reads as |
324
+ |---|---|---|---|---|
325
+ | `0.0005` | 0.5 | 1 | 0.9 % | star grain — texture, never the subject |
326
+ | `0.002` | 2.2 | 4 | 1.0 % | dust, embers, distant snow |
327
+ | `0.005` | 5.4 | 11 | 1.6 % | a distinct dot; graph node, firefly |
328
+ | `0.02` | 21.6 | 43 | 6.2 % | a clear disc — bokeh, a planet |
329
+ | `0.05` | 54 | 108 | 21 % | a focal object, or a soft glow field |
330
+ | `0.1` | 108 | 216 | 52 % | dominant form — sun, moon, one per scene |
331
+ | `0.2` | 216 | 432 | 88 % | 40 % of the short side; only as a soft glow plate |
332
+
333
+ **Read the coverage column with its floor in mind.** `perceiveScene` samples an
334
+ 80 × 48 grid, so a cell is ~24 × 22 px at 1080p and anything smaller registers as
335
+ one cell per entity. That is why the top two rungs both land near 1 % despite a
336
+ 16 × difference in area — below `radius ≈ 0.01` the column counts entities, not
337
+ pixels. It becomes an area measurement from `0.02` up.
338
+
339
+ The shipped examples put entity *fields* almost entirely in `0.0003 – 0.007`
340
+ and large forms in `0.03 – 0.185` (the biggest is a `ring` gauge in
341
+ `dev-dashboard`). The gap between is thinly populated
342
+ but not empty, and the exception is instructive: `aurora` runs 60–100-entity
343
+ layers at `radius [0.045, 0.11]`, because a soft additive orb that large is
344
+ atmosphere rather than an object. Within one scene, `lanterns`' three parallax
345
+ layers step by ~2.2 × and ~2.4 × per layer; steps much smaller than that read
346
+ as one layer with a wide size range rather than as depth.
347
+
348
+ The bottom two rungs are texture, not content, and `adviseSpec` says so: a
349
+ `0.002` field stops raising `sparse-scene` at ~70 entities, while a `0.0005`
350
+ field would need over a thousand — far past `LIMITS.maxPerLayer` (400) — so it
351
+ raises `sparse-scene` at every legal count and always needs a brighter layer
352
+ above it.
353
+
354
+ ### `size` (emoji), `fontSize` (textBlock), `size` (text sprites)
355
+
356
+ `0.02` ≈ 22 px, `0.035` ≈ 38 px, `0.05` ≈ 54 px, `0.1` ≈ 108 px at 1080p.
357
+ Emoji fields in the examples sit at `0.011 – 0.078`; `haiku`, the one textBlock
358
+ example, sets `fontSize: 0.05`.
359
+
360
+ An explicit px size inside a `font` string (`"bold 14px monospace"`) is **not**
361
+ absolute: under the default `viewport` units the compiler rescales it by
362
+ `min(w, h) / referenceViewport`, so it holds its apparent size across displays
363
+ (14 px at 1080p becomes 28 px at 4K and 5 px on a phone). Only `units: "px"`
364
+ specs get the number verbatim. Two consequences: a font pinned for a 4K wall
365
+ becomes illegibly small on a phone-sized viewport, and a spec that genuinely
366
+ wants fixed pixels must say `units: "px"`.
367
+
368
+ ### `speed` (viewport fractions per second)
369
+
370
+ | speed | px/s @1080p | crosses the short side | crosses a 16:9 width |
371
+ |---|---|---|---|
372
+ | `0.001` | 1.1 | 17 min | 30 min |
373
+ | `0.005` | 5.4 | 3.3 min | 5.9 min |
374
+ | `0.02` | 21.6 | 50 s | 89 s |
375
+ | `0.06` | 65 | 17 s | 30 s |
376
+ | `0.15` | 162 | 7 s | 12 s |
377
+
378
+ An ambient scene meant to live on a wall for hours wants its background layers
379
+ under `0.005` — anything faster resolves as traffic rather than atmosphere.
380
+ Above `0.06` a layer reads as an event (rain, warp, a passing comet). Speeds
381
+ are ranges and each entity draws its own, so a `[0.002, 0.008]` layer already
382
+ carries internal parallax.
383
+
384
+ ### `links.maxDist`
385
+
386
+ Link density is governed by `maxDist` relative to the mean spacing between
387
+ entities, `sqrt(w·h / count) / min(w, h)` — for 40 entities at 1920×1080 that
388
+ is `0.21` (228 px), for 100 it is `0.13` (144 px). Measured at `k: 3`, count 40,
389
+ averaged over 15 seeds:
390
+
391
+ | `maxDist` | edges drawn (of `k·count`) | graph |
392
+ |---|---|---|
393
+ | 0.5 × spacing | ~12 % | scattered pairs; ~45 % of nodes isolated |
394
+ | 1.0 × spacing | ~45 % | a constellation with a few loners |
395
+ | 1.5 × spacing | ~60 % | usually one connected component, rarely an isolate |
396
+ | 2.0 × spacing | ~62 % | saturated — `k` is now the binding cap |
397
+
398
+ Past ~2 × spacing, raising `maxDist` costs distance tests and buys nothing;
399
+ raise `k` instead. `describeScene` reports `linksDrawn` / `linksExpected` and
400
+ `isolatedNodes`, so connectivity is checkable per spec rather than guessed —
401
+ worth doing, since the seed moves these by several points either way.
402
+
403
+ ### Recipes
404
+
405
+ **Parallax depth.** Three or four layers where size and speed rise together —
406
+ scaling size alone reads as a size distribution, not as distance. `lanterns`'
407
+ ladder, which the numbers above are drawn from: `radius [0.0005, 0.0013]` /
408
+ `speed [0.0005, 0.002]` / `alpha [0.35, 1]` far, `radius [0.0014, 0.0028]` /
409
+ `speed [0.006, 0.013]` / `alpha [0.5, 0.9]` mid, `radius [0.0032, 0.006]` /
410
+ `speed [0.015, 0.026]` / `alpha [0.6, 1]` near. Note that alpha *narrows*
411
+ toward the front rather than simply rising — the far layer spans the whole
412
+ range because distance is what varies it; the near layer is uniformly present.
413
+
414
+ **Glow stacking.** `soft: true` + `blend: "lighter"` + `alpha` around 0.5–0.9,
415
+ with `pulse: { amp: 0.2, period: 3000 }` for breathing. Glow reads only against
416
+ a dark plate — over a pale background use `"screen"`. Do not assume a soft orb
417
+ covers what a hard disc of the same radius would: `perceiveScene` models the
418
+ halo as reaching 2.4 × the radius, so a large soft additive layer measures far
419
+ *more* coverage than its hard equivalent — ~2.5 × at `radius 0.05` — while a
420
+ small one measures *less*, ~0.5 × at `radius 0.005`, because the faint outer
421
+ halo falls under the per-cell visibility threshold. The crossover sits near
422
+ `radius 0.01`. Re-measure after switching `soft` or `blend` rather than
423
+ compensating by a rule of thumb.
424
+
425
+ **Graph web.** `count 40–100`, `radius [0.002, 0.005]`, a slow `drift`
426
+ (`speed [0.002, 0.007]`) so the topology keeps re-forming, and `links` with
427
+ `k: 3`, `maxDist` at 1.5 × mean spacing (`0.3` for 40 entities, `0.2` for 100),
428
+ `alpha: 0.15`, `width: 0.0005`. Link alpha wants to be much lower than the
429
+ nodes' — at 0.15 the web is a suggestion, at 0.5 it is a diagram. Add
430
+ `falloff: true` to fade edges toward the cutoff instead of popping them.
431
+
432
+ **Focal pin.** One `count: 1` layer with `motion: { type: "static" }` and an
433
+ explicit `position`, `radius` in the `0.05 – 0.15` band, over a field of
434
+ `0.002`-scale entities. The size ratio between the two is what makes it read as
435
+ a subject rather than the largest member of the crowd — an order of magnitude
436
+ or more, which is the gap the ladder above shows between the field rungs and
437
+ the focal ones.
307
438
 
308
439
  ## Determinism contract
309
440
 
package/dist/index.d.ts CHANGED
@@ -719,6 +719,8 @@ interface Entity {
719
719
  ordered?: true;
720
720
  /** Orbit parent layer key (motion.center = { layer }). Center resolved at render time. */
721
721
  orbitParent?: string;
722
+ /** Present only when the layer explicitly disables edge wrapping. */
723
+ wrapDisabled?: true;
722
724
  /** Harmonic drift params. Only set for wander motion. */
723
725
  wander?: {
724
726
  own: WanderOsc;
package/dist/index.js CHANGED
@@ -83,12 +83,13 @@ var LIMITS = {
83
83
 
84
84
  // src/steer.ts
85
85
  var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
86
+ var STEERABLE_ROOT_KEYS = /* @__PURE__ */ new Set(["ghosting", "referenceViewport"]);
86
87
  function resolveSpecPath(spec, path) {
87
88
  if (!spec || typeof spec !== "object" || !path) return null;
88
89
  const parts = path.split(".");
89
90
  if (parts.some((p) => UNSAFE_KEYS.has(p))) return null;
90
91
  const s = spec;
91
- if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
92
+ if (parts[0] !== "layers" && parts[0] !== "background" && !STEERABLE_ROOT_KEYS.has(parts[0]) && Array.isArray(s.layers)) {
92
93
  const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
93
94
  if (idx === -1) return null;
94
95
  parts.splice(0, 1, "layers", String(idx));
@@ -150,7 +151,7 @@ function lerpSpec(from, to, k) {
150
151
  }
151
152
  function steerablePaths(spec) {
152
153
  if (!spec || typeof spec !== "object") return [];
153
- const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "mode", "curve", "layer"]);
154
+ const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer"]);
154
155
  const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
155
156
  const out = [];
156
157
  const walk = (node, prefix, key) => {
@@ -181,6 +182,7 @@ function easeSmooth(k) {
181
182
  function structuralSignature(spec) {
182
183
  return JSON.stringify([
183
184
  spec.units,
185
+ spec.referenceViewport,
184
186
  spec.layers.map((l) => {
185
187
  const s = l.sprite;
186
188
  return [
@@ -660,6 +662,9 @@ function validateSprite(sprite, path, err, warn) {
660
662
  err(`${path}.strings`, "must be a non-empty array of strings");
661
663
  }
662
664
  if (sprite.color !== void 0) color(sprite.color, `${path}.color`, err);
665
+ if (sprite.font !== void 0 && !isStr(sprite.font)) {
666
+ err(`${path}.font`, "must be a string");
667
+ }
663
668
  if (sprite.align !== void 0 && !["left", "center", "right"].includes(sprite.align)) {
664
669
  err(`${path}.align`, "must be left | center | right");
665
670
  }
@@ -1255,7 +1260,8 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1255
1260
  Math.round(layer.count * countScale),
1256
1261
  layer.links ? LIMITS.maxLinkLayerCount : LIMITS.maxPerLayer
1257
1262
  ));
1258
- const [smin, smax] = layer.size ?? [20, 40];
1263
+ const defaultSize = scale === 1 ? [20, 40] : [20 / LIMITS.referenceViewport, 40 / LIMITS.referenceViewport];
1264
+ const [smin, smax] = layer.size ?? defaultSize;
1259
1265
  const variants = spriteVariants(layer.sprite);
1260
1266
  const sprite = layer.sprite;
1261
1267
  const spriteColors = isShapedSprite(sprite) ? sprite.colors : void 0;
@@ -1444,6 +1450,7 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
1444
1450
  ...sprite.kind === "bar" ? { barIndex: i } : {},
1445
1451
  ...ordered ? { ordered: true } : {},
1446
1452
  ...orbitParent ? { orbitParent } : {},
1453
+ ...layer.wrap === false ? { wrapDisabled: true } : {},
1447
1454
  ...m.type === "wander" ? {
1448
1455
  wander: {
1449
1456
  own: drawOsc(rng, meanderPx),
@@ -1635,9 +1642,10 @@ function positionAt(e, t, w, h) {
1635
1642
  };
1636
1643
  }
1637
1644
  if (e.motion === "rise") {
1645
+ const y2 = e.y0 + e.vy * dt;
1638
1646
  return {
1639
1647
  x: e.x0 + (e.bob ? e.bob * Math.sin(t / 700 + e.phase) : 0),
1640
- y: wrap(e.y0 + e.vy * dt, -m, h + m),
1648
+ y: e.wrapDisabled ? y2 : wrap(y2, -m, h + m),
1641
1649
  flip: false
1642
1650
  };
1643
1651
  }
@@ -1657,14 +1665,18 @@ function positionAt(e, t, w, h) {
1657
1665
  }
1658
1666
  }
1659
1667
  const margin = m + wp.margin;
1668
+ const x2 = e.x0 + e.vx * dt + hx;
1669
+ const y2 = e.y0 + e.vy * dt + hy;
1660
1670
  return {
1661
- x: wrap(e.x0 + e.vx * dt + hx, -margin, w + margin),
1662
- y: wrap(e.y0 + e.vy * dt + hy, -margin, h + margin),
1671
+ x: e.wrapDisabled ? x2 : wrap(x2, -margin, w + margin),
1672
+ y: e.wrapDisabled ? y2 : wrap(y2, -margin, h + margin),
1663
1673
  flip: e.headingLeft
1664
1674
  };
1665
1675
  }
1666
- const x = wrap(e.x0 + e.vx * dt, -m, w + m);
1667
- let y = e.vy !== 0 ? wrap(e.y0 + e.vy * dt, -m, h + m) : e.y0;
1676
+ const rawX = e.x0 + e.vx * dt;
1677
+ const x = e.wrapDisabled ? rawX : wrap(rawX, -m, w + m);
1678
+ const rawY = e.y0 + e.vy * dt;
1679
+ let y = e.vy !== 0 && !e.wrapDisabled ? wrap(rawY, -m, h + m) : rawY;
1668
1680
  if (e.bob) y += e.bob * Math.sin(t / 500 + e.phase);
1669
1681
  return { x, y, flip: e.headingLeft };
1670
1682
  }
@@ -2472,7 +2484,9 @@ var SpecInstance = class {
2472
2484
  const positions = built.entities.map((e) => this.entityPos(e, t, parentE));
2473
2485
  const maxDistPx = links.maxDist * (this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h));
2474
2486
  const edges = linkEdges(links, positions, maxDistPx, wrap2, this.w, this.h);
2475
- const lw = (links.width ?? 1) * (this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h));
2487
+ const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
2488
+ const defaultWidth = unitScale === 1 ? 1 : 1 / LIMITS.referenceViewport;
2489
+ const lw = (links.width ?? defaultWidth) * unitScale;
2476
2490
  ctx.lineWidth = lw;
2477
2491
  ctx.lineCap = "butt";
2478
2492
  for (const { i, j, dist } of edges) {
@@ -2758,7 +2772,6 @@ var SequenceInstance = class {
2758
2772
  child.hotSwapSpec(this.childScene(index));
2759
2773
  } else {
2760
2774
  child = this.ensureChild(index);
2761
- if (useMorphSeed) child.hotSwapSpec(this.childScene(index));
2762
2775
  }
2763
2776
  child.renderFrame(localT, effSeed);
2764
2777
  }
@@ -3222,7 +3235,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
3222
3235
  const positions = entities.map((e) => positionAt(e, 0, w, h));
3223
3236
  const maxDistPx = layer.links.maxDist * scale;
3224
3237
  const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false, w, h);
3225
- const lwPx = (layer.links.width ?? 1) * scale;
3238
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
3239
+ const lwPx = (layer.links.width ?? defaultWidth) * scale;
3226
3240
  const la = layer.links.alpha ?? 1;
3227
3241
  for (const edge of edges) totalCoverage += edge.dist * lwPx * la / (w * h);
3228
3242
  }
@@ -3826,7 +3840,8 @@ function luminanceGrid(spec, opts = {}) {
3826
3840
  const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
3827
3841
  const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
3828
3842
  const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
3829
- const wgt = Math.min(1, (layer.links.width ?? 1) * scale / cellH) * la * alphaScale;
3843
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
3844
+ const wgt = Math.min(1, (layer.links.width ?? defaultWidth) * scale / cellH) * la * alphaScale;
3830
3845
  for (const edge of edges) {
3831
3846
  const pi = positions[edge.i];
3832
3847
  const pj = positions[edge.j];
@@ -4034,7 +4049,8 @@ function dominanceRanking(spec, opts = {}) {
4034
4049
  const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
4035
4050
  const edges = linkEdges(layer.links, positions, layer.links.maxDist * scale, layer.wrap !== false && motionWraps, w, h);
4036
4051
  const la = (layer.links.alpha ?? 0.6) * lifeA;
4037
- const lw = (layer.links.width ?? 1) * scale;
4052
+ const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
4053
+ const lw = (layer.links.width ?? defaultWidth) * scale;
4038
4054
  for (const edge of edges) area += edge.dist * lw * la * LINE_SALIENCE;
4039
4055
  }
4040
4056
  const meanLuma = entities.length ? lumAcc / entities.length : 0;