@glissade/scene 0.59.0 → 0.60.0-pre.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.
@@ -153,8 +153,16 @@ interface DescribedHelper {
153
153
  interface SurfaceEntry {
154
154
  /** The export name — also the `window.glissade.<name>` global on the IIFE. */
155
155
  name: string;
156
- /** `'value'` = a runtime binding on the bundle (a class / function / object); `'type'` = a TS type-only name that erases at runtime (opaque, referenced by signatures). */
157
- kind: 'value' | 'type';
156
+ /**
157
+ * `'value'` = a runtime binding on the bundle (a class / function / object);
158
+ * `'type'` = a TS type-only name that erases at runtime (opaque, referenced by
159
+ * signatures); `'diagnostic'` = a runtime AUTHORING-DIAGNOSTIC function
160
+ * (`critique`/`validateScene`/`resolveAt`/`instanceProps`, 0.60) — a real
161
+ * `window.glissade.<name>` callable that is PERCEPTION/self-check tooling, not
162
+ * scene-building surface. An agent BUILDING a scene filters `kind !== 'diagnostic'`;
163
+ * an agent CRITIQUING a rendered scene filters `kind === 'diagnostic'`.
164
+ */
165
+ kind: 'value' | 'type' | 'diagnostic';
158
166
  /** `true` when it is reachable as `window.glissade.<name>` on the single-file IIFE bundle. */
159
167
  iife: boolean;
160
168
  /** How to consume it: `'constructor'` needs `new`, `'function'` is a plain call, `'object'` is a value namespace (e.g. `easings`), `'type'` is type-only. */
package/dist/describe.js CHANGED
@@ -23,7 +23,7 @@ import { easings, listValueTypes } from "@glissade/core";
23
23
  * never pulled onto the base embed path — a scene that never calls `describe()`
24
24
  * pays zero bytes for it.
25
25
  */
26
- const RAW_VERSION = "0.59.0";
26
+ const RAW_VERSION = "0.60.0-pre.1";
27
27
  const PACKAGE_VERSION = RAW_VERSION.includes("GLISSADE_".concat("VERSION")) ? "0.0.0-dev" : RAW_VERSION;
28
28
  /**
29
29
  * Parse the documented positional-arg count from a helper `usage` string — the
@@ -182,6 +182,34 @@ const SURFACE_EXTRA = [
182
182
  /** Value exports that are runtime OBJECTS (not callable): the easing registry. */
183
183
  const SURFACE_VALUE_OBJECTS = ["easings"];
184
184
  /**
185
+ * The 0.60 machine-readable AUTHORING-DIAGNOSTIC functions on `window.glissade`
186
+ * (the `@glissade/scene/diagnostics` subpath, IIFE-re-exported off the base embed):
187
+ * `critique` (rendered geometry), `validateScene` (static structure), `resolveAt`
188
+ * (the truthful read primitive), `instanceProps` (instance-bound state). Marked
189
+ * `kind: 'diagnostic'` so a consumer can PARTITION the surface — build-a-scene
190
+ * tooling filters them OUT, render-critique/perception tooling filters them IN —
191
+ * instead of them being invisible (previously exempt-internal, discoverable only by
192
+ * reading the bundle). `arity` = the documented required positional-arg count.
193
+ */
194
+ const SURFACE_DIAGNOSTICS = [
195
+ {
196
+ name: "critique",
197
+ arity: 2
198
+ },
199
+ {
200
+ name: "validateScene",
201
+ arity: 2
202
+ },
203
+ {
204
+ name: "resolveAt",
205
+ arity: 3
206
+ },
207
+ {
208
+ name: "instanceProps",
209
+ arity: 1
210
+ }
211
+ ];
212
+ /**
185
213
  * The opaque, type-ONLY names the API surface references (they erase at runtime —
186
214
  * `window.glissade.Paint` is `undefined`). `gs types --global` emits a best-effort
187
215
  * alias per name; `gs describe --lint` guards they stay type-only (a type surfaced
@@ -235,6 +263,13 @@ function buildSurface() {
235
263
  iife: true,
236
264
  form: "object"
237
265
  });
266
+ for (const d of SURFACE_DIAGNOSTICS) out.push({
267
+ name: d.name,
268
+ kind: "diagnostic",
269
+ iife: true,
270
+ form: "function",
271
+ arity: d.arity
272
+ });
238
273
  for (const name of SURFACE_TYPE_ONLY) out.push({
239
274
  name,
240
275
  kind: "type",
@@ -178,9 +178,17 @@ type DiagnosticSeverity = 'error' | 'warning' | 'info';
178
178
  * a built Scene structurally cannot contain a duplicate id, so validateScene
179
179
  * never reaches this case. Kept for the shared contract / `gs parity` surface.
180
180
  */
181
- type DiagnosticCode = 'UNKNOWN_TARGET' | 'ID_COLLISION' | 'OFF_CANVAS' | 'YOGA_CHILD_POSITION' | 'MEASURER_FALLBACK';
181
+ type DiagnosticCode = 'UNKNOWN_TARGET' | 'ID_COLLISION' | 'OFF_CANVAS' | 'YOGA_CHILD_POSITION' | 'MEASURER_FALLBACK' | 'TEXT_OVERFLOW' | 'OCCLUSION';
182
+ /**
183
+ * 0.60: which enforcement SURFACE produced a diagnostic — distinguishes a CERTAIN
184
+ * static fact (`validateScene`) from a HEURISTIC rendered judgment (`critique`,
185
+ * which CAN false-positive) from a perceptual parity result (`parity`). Additive,
186
+ * optional (a pre-0.60 consumer ignores it). Certification reads it to keep "zero
187
+ * static errors" and "zero critique false-positives" as distinct claims.
188
+ */
189
+ type DiagnosticSource = 'validateScene' | 'critique' | 'parity';
182
190
  /** One diagnostic. The `{schemaVersion, code, severity, message, node?, track?}`
183
- * core shape is PINNED; future fields are ADDITIVE only. */
191
+ * core shape is PINNED; future fields (`source`/`detail`, 0.60) are ADDITIVE only. */
184
192
  interface SceneDiagnostic {
185
193
  schemaVersion: typeof DIAGNOSTIC_SCHEMA_VERSION;
186
194
  code: DiagnosticCode;
@@ -190,6 +198,11 @@ interface SceneDiagnostic {
190
198
  node?: string;
191
199
  /** The track target string the diagnostic concerns, when applicable. */
192
200
  track?: string;
201
+ /** 0.60: the enforcement surface that produced this diagnostic. */
202
+ source?: DiagnosticSource;
203
+ /** 0.60: structured evidence (e.g. `{ measured, threshold }` for a critique
204
+ * code), so a consumer digs into the numbers without parsing the message. */
205
+ detail?: Record<string, unknown>;
193
206
  }
194
207
  /** `validateScene` result — the CLI-lint `{ hasErrors, diagnostics }` shape,
195
208
  * plus a top-level `schemaVersion`. */
@@ -261,4 +274,57 @@ interface InstancePropState {
261
274
  */
262
275
  declare function instanceProps(node: Node): InstancePropState[];
263
276
  //#endregion
264
- export { type CacheColdResult, type CommandDelta, DIAGNOSTIC_SCHEMA_VERSION, DL_SNAPSHOT_VERSION, type DiagnosticCode, type DiagnosticSeverity, type DisplayDiff, type DlSnapshot, DlSnapshotError, type FieldChange, type FontByteLoader, type InstancePropState, type SceneDiagnostic, type ValidateSceneFontsOptions, type ValidateSceneResult, auditCacheCold, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, diffDisplayLists, formatDisplayDiff, instanceProps, levenshtein, nearestId, parseDisplaySnapshot, resolveAt, serializeDisplayList, validateScene, validateSceneFonts };
277
+ //#region src/critique.d.ts
278
+ interface CritiqueOptions {
279
+ /**
280
+ * frames-per-second for the sampling grid. Default: the timeline's own fps,
281
+ * else 60 (aligning critique verdicts with what `gs render` produces). Kept an
282
+ * override for tooling; a fixed INTEGER-frame grid is the determinism contract.
283
+ */
284
+ fps?: number;
285
+ /**
286
+ * Author-declared INTENTIONALLY off-stage node ids — the OFF_CANVAS opt-out.
287
+ * A node is exempt from OFF_CANVAS iff its id is in this list OR ANY of its
288
+ * ancestors' ids is (SUBTREE match): list the parked GROUP id
289
+ * (`'sd1-drawer'`) and its whole subtree — current children AND any it later
290
+ * gains — is suppressed, while sibling groups stay fully checked. This lets an
291
+ * author silence the true-positive-but-intentional off-stage art (wing-parked
292
+ * drawers, hidden placeholder cards) without muting OFF_CANVAS wholesale. A
293
+ * PURE emission filter — determinism-neutral (it never changes the sampled
294
+ * geometry, only which off-frame nodes are reported). Same param-seam shape as
295
+ * a future `safeAreas`; a per-node `offstage:true` marker is a planned
296
+ * fast-follow, not this mechanism.
297
+ */
298
+ offstage?: readonly string[];
299
+ }
300
+ interface CritiqueResult {
301
+ schemaVersion: typeof DIAGNOSTIC_SCHEMA_VERSION;
302
+ /** true iff any diagnostic has severity `error` (always a static error — the
303
+ * rendered pass emits only warnings/info). */
304
+ hasErrors: boolean;
305
+ /** FLAT merged, CANONICALLY-SORTED diagnostics (static + rendered). */
306
+ diagnostics: SceneDiagnostic[];
307
+ /** true when the rendered pass was skipped because static validation errored. */
308
+ renderedSkipped: boolean;
309
+ /** why the rendered pass was skipped (present iff `renderedSkipped`). */
310
+ renderedSkipReason?: string;
311
+ /** how many integer-frame grid samples the rendered pass took (0 if skipped). */
312
+ sampledFrames: number;
313
+ }
314
+ /**
315
+ * Rendered-geometric diagnostics for `(scene, timeline)`. Runs `validateScene`
316
+ * first; short-circuits the rendered pass on any static error. Otherwise binds the
317
+ * scene, samples a fixed integer-frame grid, and emits OFF_CANVAS / TEXT_OVERFLOW
318
+ * / OCCLUSION where a span check fires. PURE READ — never changes evaluate() or a
319
+ * render path; canonically-sorted (frame, then code, then node-id) output.
320
+ */
321
+ declare function critique(scene: Scene, timeline: Timeline, opts?: CritiqueOptions): CritiqueResult;
322
+ /**
323
+ * CANONICAL SORT — by frame (detail.frame, undefined last), then code, then
324
+ * node-id. An unordered diagnostic array is golden-unstable; this is the HARD emit
325
+ * requirement (assert sort-invariance: shuffle-then-sort ≡ emitted order). Stable
326
+ * for equal keys (the static-then-rendered concat order is deterministic).
327
+ */
328
+ declare function sortDiagnostics(diags: SceneDiagnostic[]): SceneDiagnostic[];
329
+ //#endregion
330
+ export { type CacheColdResult, type CommandDelta, type CritiqueOptions, type CritiqueResult, DIAGNOSTIC_SCHEMA_VERSION, DL_SNAPSHOT_VERSION, type DiagnosticCode, type DiagnosticSeverity, type DiagnosticSource, type DisplayDiff, type DlSnapshot, DlSnapshotError, type FieldChange, type FontByteLoader, type InstancePropState, type SceneDiagnostic, type ValidateSceneFontsOptions, type ValidateSceneResult, auditCacheCold, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, critique, diffDisplayLists, formatDisplayDiff, instanceProps, levenshtein, nearestId, parseDisplaySnapshot, resolveAt, serializeDisplayList, sortDiagnostics, validateScene, validateSceneFonts };
@@ -1,6 +1,7 @@
1
- import { I as isEstimatingMeasurer, J as collapseReplacer, P as estimatingMeasurer, W as createDisplayListBuilder, r as Group, s as Text } from "./nodes.js";
2
- import { a as evaluate } from "./scene.js";
3
- import { buildFontRegistry, evaluateAt, parseCmap, untracked, validateFonts } from "@glissade/core";
1
+ import { I as isEstimatingMeasurer, J as collapseReplacer, P as estimatingMeasurer, R as quantize, W as createDisplayListBuilder, Y as IDENTITY, et as multiply, r as Group, s as Text } from "./nodes.js";
2
+ import { a as evaluate, r as bindScene } from "./scene.js";
3
+ import { emitWithIds } from "./identity.js";
4
+ import { buildFontRegistry, compileTimeline, evaluateAt, parseCmap, parseColor, untracked, validateFonts } from "@glissade/core";
4
5
  //#region src/displayDiff.ts
5
6
  /** A flat, stable JSON value for one command with its resource ids INLINED to content. */
6
7
  function commandView(cmd, resources) {
@@ -383,6 +384,7 @@ function validateScene(scene, doc) {
383
384
  code,
384
385
  severity,
385
386
  message,
387
+ source: "validateScene",
386
388
  ...extra?.node !== void 0 ? { node: extra.node } : {},
387
389
  ...extra?.track !== void 0 ? { track: extra.track } : {}
388
390
  });
@@ -475,4 +477,448 @@ function instanceProps(node) {
475
477
  });
476
478
  }
477
479
  //#endregion
478
- export { DIAGNOSTIC_SCHEMA_VERSION, DL_SNAPSHOT_VERSION, DlSnapshotError, auditCacheCold, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, diffDisplayLists, formatDisplayDiff, instanceProps, levenshtein, nearestId, parseDisplaySnapshot, resolveAt, serializeDisplayList, validateScene, validateSceneFonts };
480
+ //#region src/critique.ts
481
+ function growBounds(b, x, y) {
482
+ if (!b) return {
483
+ minX: x,
484
+ minY: y,
485
+ maxX: x,
486
+ maxY: y
487
+ };
488
+ if (x < b.minX) b.minX = x;
489
+ if (y < b.minY) b.minY = y;
490
+ if (x > b.maxX) b.maxX = x;
491
+ if (y > b.maxY) b.maxY = y;
492
+ return b;
493
+ }
494
+ /** Local-space rect → device-space box under `m`, growing `into`. */
495
+ function accumulateRect(into, m, x0, y0, x1, y1) {
496
+ let b = into;
497
+ for (const [x, y] of [
498
+ [x0, y0],
499
+ [x1, y0],
500
+ [x0, y1],
501
+ [x1, y1]
502
+ ]) b = growBounds(b, m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]);
503
+ return b;
504
+ }
505
+ /** Control-point bounding box of a path — curves/rotated ellipses stay inside. */
506
+ function segsBounds(segs) {
507
+ let b = null;
508
+ const pt = (x, y) => {
509
+ b = growBounds(b, x, y);
510
+ };
511
+ for (const seg of segs) switch (seg[0]) {
512
+ case "M":
513
+ case "L":
514
+ pt(seg[1], seg[2]);
515
+ break;
516
+ case "C":
517
+ pt(seg[1], seg[2]);
518
+ pt(seg[3], seg[4]);
519
+ pt(seg[5], seg[6]);
520
+ break;
521
+ case "Q":
522
+ pt(seg[1], seg[2]);
523
+ pt(seg[3], seg[4]);
524
+ break;
525
+ case "E": {
526
+ const r = Math.max(seg[3], seg[4]);
527
+ pt(seg[1] - r, seg[2] - r);
528
+ pt(seg[1] + r, seg[2] + r);
529
+ break;
530
+ }
531
+ }
532
+ return b;
533
+ }
534
+ function pathResourceBounds(resources, id) {
535
+ const res = resources[id];
536
+ return res && res.kind === "path" ? segsBounds(res.segs) : null;
537
+ }
538
+ /** True when two boxes overlap the open frame [0,0,w,h] (bbox ∩ frame ≠ ∅). */
539
+ function intersectsFrame(b, w, h) {
540
+ return b.maxX > 0 && b.minX < w && b.maxY > 0 && b.minY < h;
541
+ }
542
+ /** True when `b` is FULLY outside the frame (off one edge entirely). */
543
+ function fullyOutsideFrame(b, w, h) {
544
+ return b.maxX <= 0 || b.minX >= w || b.maxY <= 0 || b.minY >= h;
545
+ }
546
+ /** True when `outer` fully contains `inner` (bbox containment). */
547
+ function contains(outer, inner) {
548
+ return outer.minX <= inner.minX && outer.minY <= inner.minY && outer.maxX >= inner.maxX && outer.maxY >= inner.maxY;
549
+ }
550
+ /**
551
+ * Walk one DisplayList (+ its id stream) maintaining a transform stack and a group
552
+ * stack, unioning each command's device-space bbox per node id. Solid-opaque fills
553
+ * under a fully-opaque group stack are recorded as occluders. COPIES the golden-
554
+ * tested save/restore/transform/pushGroup/popGroup discipline from raster2d.
555
+ */
556
+ function walkFrame(list, ids, measurer) {
557
+ const nodes = /* @__PURE__ */ new Map();
558
+ const occluders = [];
559
+ let mat = IDENTITY;
560
+ const matStack = [];
561
+ let nonOpaqueGroupDepth = 0;
562
+ const groupOpaque = [];
563
+ const attribute = (id, box, order) => {
564
+ if (id === void 0 || box === null) return;
565
+ const existing = nodes.get(id);
566
+ if (existing) {
567
+ existing.bounds = accumulateRect(existing.bounds, IDENTITY, box.minX, box.minY, box.maxX, box.maxY);
568
+ if (order > existing.maxOrder) existing.maxOrder = order;
569
+ } else nodes.set(id, {
570
+ bounds: { ...box },
571
+ maxOrder: order,
572
+ texts: []
573
+ });
574
+ };
575
+ const commands = list.commands;
576
+ for (let ci = 0; ci < commands.length; ci++) {
577
+ const cmd = commands[ci];
578
+ const id = ids[ci];
579
+ switch (cmd.op) {
580
+ case "save":
581
+ matStack.push(mat);
582
+ break;
583
+ case "restore":
584
+ mat = matStack.pop() ?? mat;
585
+ break;
586
+ case "transform":
587
+ mat = multiply(mat, cmd.m);
588
+ break;
589
+ case "clip": break;
590
+ case "fillPath": {
591
+ const pb = pathResourceBounds(list.resources, cmd.path);
592
+ if (!pb) break;
593
+ const box = accumulateRect(null, mat, pb.minX, pb.minY, pb.maxX, pb.maxY);
594
+ attribute(id, box, ci);
595
+ if (box && nonOpaqueGroupDepth === 0 && isOpaqueSolid(cmd.paint)) occluders.push({
596
+ bounds: box,
597
+ order: ci,
598
+ id
599
+ });
600
+ break;
601
+ }
602
+ case "strokePath": {
603
+ const pb = pathResourceBounds(list.resources, cmd.path);
604
+ if (!pb) break;
605
+ const o = cmd.stroke.width * ((cmd.stroke.join ?? "miter") === "miter" ? 5 : 1);
606
+ attribute(id, accumulateRect(null, mat, pb.minX - o, pb.minY - o, pb.maxX + o, pb.maxY + o), ci);
607
+ break;
608
+ }
609
+ case "fillText": {
610
+ let width = 0;
611
+ try {
612
+ width = measurer.measureText(cmd.text, cmd.font).width;
613
+ } catch {
614
+ width = cmd.text.length * cmd.font.size * .6;
615
+ }
616
+ const align = cmd.align ?? "left";
617
+ const x0 = align === "center" ? cmd.x - width / 2 : align === "right" ? cmd.x - width : cmd.x;
618
+ const m = cmd.font.size;
619
+ attribute(id, accumulateRect(null, mat, x0 - m, cmd.y - 1.5 * m, x0 + width + m, cmd.y + .75 * m), ci);
620
+ if (id !== void 0) nodes.get(id).texts.push({
621
+ text: cmd.text,
622
+ font: cmd.font
623
+ });
624
+ break;
625
+ }
626
+ case "drawImage": {
627
+ const { x, y, w: dw, h: dh } = cmd.dst;
628
+ attribute(id, accumulateRect(null, mat, x, y, x + dw, y + dh), ci);
629
+ break;
630
+ }
631
+ case "pushGroup": {
632
+ const opaque = cmd.opacity >= 1 && cmd.blend === "source-over" && cmd.filters.length === 0 && cmd.matte === void 0;
633
+ groupOpaque.push(opaque);
634
+ if (!opaque) nonOpaqueGroupDepth++;
635
+ break;
636
+ }
637
+ case "popGroup":
638
+ if (groupOpaque.pop() === false) nonOpaqueGroupDepth--;
639
+ break;
640
+ }
641
+ }
642
+ return {
643
+ nodes,
644
+ occluders
645
+ };
646
+ }
647
+ /** Is a paint a SOLID opaque color (alpha ≥ ~0.98)? Conservative: gradient/mesh
648
+ * and any un-parseable color count as NOT opaque (false-negatives are fine, a
649
+ * false cover is not). */
650
+ function isOpaqueSolid(paint) {
651
+ if (!paint || paint.kind !== "color") return false;
652
+ try {
653
+ return parseColor(paint.color).a >= .98;
654
+ } catch {
655
+ return false;
656
+ }
657
+ }
658
+ /**
659
+ * Rendered-geometric diagnostics for `(scene, timeline)`. Runs `validateScene`
660
+ * first; short-circuits the rendered pass on any static error. Otherwise binds the
661
+ * scene, samples a fixed integer-frame grid, and emits OFF_CANVAS / TEXT_OVERFLOW
662
+ * / OCCLUSION where a span check fires. PURE READ — never changes evaluate() or a
663
+ * render path; canonically-sorted (frame, then code, then node-id) output.
664
+ */
665
+ function critique(scene, timeline, opts = {}) {
666
+ const staticRes = validateScene(scene, timeline);
667
+ const staticDiags = staticRes.diagnostics;
668
+ if (staticRes.hasErrors) return {
669
+ schemaVersion: 1,
670
+ hasErrors: true,
671
+ diagnostics: sortDiagnostics(staticDiags.slice()),
672
+ renderedSkipped: true,
673
+ renderedSkipReason: "fix static errors first — the scene can’t bind, so rendered geometry would be garbage.",
674
+ sampledFrames: 0
675
+ };
676
+ bindScene(scene, timeline);
677
+ const { w, h } = scene.size;
678
+ const measurer = scene.textMeasurer;
679
+ const estimating = isEstimatingMeasurer(measurer);
680
+ const fps = opts.fps ?? timeline.fps ?? 60;
681
+ const duration = compileTimeline(timeline).duration;
682
+ const lastFrame = Math.max(0, Math.floor(duration * fps));
683
+ const agg = /* @__PURE__ */ new Map();
684
+ const ensure = (id) => {
685
+ let a = agg.get(id);
686
+ if (!a) {
687
+ a = {
688
+ onStage: 0,
689
+ offCanvas: 0,
690
+ occluded: 0,
691
+ lastFrame: -1,
692
+ lastT: 0,
693
+ lastBounds: {
694
+ minX: 0,
695
+ minY: 0,
696
+ maxX: 0,
697
+ maxY: 0
698
+ },
699
+ occluderId: void 0,
700
+ occluderBounds: null,
701
+ lastTextFrameT: 0,
702
+ lastTexts: []
703
+ };
704
+ agg.set(id, a);
705
+ }
706
+ return a;
707
+ };
708
+ let sampledFrames = 0;
709
+ for (let i = 0; i <= lastFrame; i++) {
710
+ const t = i / fps;
711
+ const { displayList, ids } = emitWithIds(scene, timeline, t);
712
+ const frame = walkFrame(displayList, ids, measurer);
713
+ sampledFrames++;
714
+ for (const [id, fn] of frame.nodes) {
715
+ const a = ensure(id);
716
+ a.onStage++;
717
+ a.lastFrame = i;
718
+ a.lastT = t;
719
+ a.lastBounds = fn.bounds;
720
+ if (fn.texts.length > 0) {
721
+ a.lastTextFrameT = t;
722
+ a.lastTexts = fn.texts;
723
+ }
724
+ if (fullyOutsideFrame(fn.bounds, w, h)) a.offCanvas++;
725
+ if (intersectsFrame(fn.bounds, w, h)) {
726
+ let cover;
727
+ for (const occ of frame.occluders) if (occ.order > fn.maxOrder && contains(occ.bounds, fn.bounds)) {
728
+ cover = occ;
729
+ break;
730
+ }
731
+ if (cover) {
732
+ a.occluded++;
733
+ a.occluderId = cover.id;
734
+ a.occluderBounds = cover.bounds;
735
+ }
736
+ }
737
+ }
738
+ }
739
+ const rendered = [];
740
+ const offstageSet = new Set(opts.offstage ?? []);
741
+ const isOffstage = (id) => offstageSet.size > 0 && (offstageSet.has(id) || hasFlaggedAncestor(scene, id, offstageSet));
742
+ const offCanvasIds = /* @__PURE__ */ new Set();
743
+ for (const [id, a] of agg) if (a.onStage > 0 && a.offCanvas === a.onStage && !isOffstage(id)) offCanvasIds.add(id);
744
+ const reportedOffCanvas = /* @__PURE__ */ new Set();
745
+ for (const id of offCanvasIds) {
746
+ if (hasFlaggedAncestor(scene, id, offCanvasIds)) continue;
747
+ reportedOffCanvas.add(id);
748
+ const a = agg.get(id);
749
+ rendered.push(offCanvasDiagnostic(scene, id, a, w, h));
750
+ }
751
+ for (const [id, a] of agg) {
752
+ if (a.lastTexts.length === 0) continue;
753
+ const node = scene.nodes.get(id);
754
+ if (!(node instanceof Text)) continue;
755
+ const width = numberAt(scene, `${id}/width`, a.lastTextFrameT);
756
+ if (width !== void 0 && width > 0) {
757
+ let widest = 0;
758
+ for (const run of a.lastTexts) {
759
+ let mw = 0;
760
+ try {
761
+ mw = measurer.measureText(run.text, run.font).width;
762
+ } catch {
763
+ mw = 0;
764
+ }
765
+ if (mw > widest) widest = mw;
766
+ }
767
+ const over = widest - width;
768
+ if (over > .5) rendered.push(textOverflowDiagnostic(id, "width", widest, width, over, estimating));
769
+ }
770
+ const boxH = node.box?.h;
771
+ if (boxH !== void 0 && boxH > 0) {
772
+ const fontSize = a.lastTexts[0].font.size;
773
+ const blockH = quantize(fontSize * node.lineHeight) * a.lastTexts.length;
774
+ const overH = blockH - boxH;
775
+ if (overH > .5) rendered.push(textOverflowDiagnostic(id, "height", blockH, boxH, overH, estimating));
776
+ }
777
+ }
778
+ for (const [id, a] of agg) {
779
+ if (a.onStage === 0 || a.occluded !== a.onStage) continue;
780
+ if (reportedOffCanvas.has(id) || offCanvasIds.has(id)) continue;
781
+ const node = scene.nodes.get(id);
782
+ if (!node || node instanceof Group) continue;
783
+ rendered.push(occlusionDiagnostic(scene, id, a));
784
+ }
785
+ return {
786
+ schemaVersion: 1,
787
+ hasErrors: false,
788
+ diagnostics: sortDiagnostics([...staticDiags, ...rendered]),
789
+ renderedSkipped: false,
790
+ sampledFrames
791
+ };
792
+ }
793
+ function offCanvasDiagnostic(scene, id, a, w, h) {
794
+ const b = a.lastBounds;
795
+ const bw = b.maxX - b.minX;
796
+ const bh = b.maxY - b.minY;
797
+ let dir = "off-frame";
798
+ let need = "";
799
+ const pos = vec2At(scene, `${id}/position`, a.lastT);
800
+ const posStr = pos ? ` position [${round(pos[0])}, ${round(pos[1])}]` : "";
801
+ if (b.maxX <= 0) {
802
+ dir = `off the LEFT by ${round(-b.maxX)}px`;
803
+ need = `; a center-anchored box needs x ≥ ~${round(bw / 2)} to sit on-frame`;
804
+ } else if (b.minX >= w) {
805
+ dir = `off the RIGHT by ${round(b.minX - w)}px`;
806
+ need = `; a center-anchored box needs x ≤ ~${round(w - bw / 2)} to sit on-frame`;
807
+ } else if (b.maxY <= 0) {
808
+ dir = `off the TOP by ${round(-b.maxY)}px`;
809
+ need = `; a center-anchored box needs y ≥ ~${round(bh / 2)} to sit on-frame`;
810
+ } else if (b.minY >= h) {
811
+ dir = `off the BOTTOM by ${round(b.minY - h)}px`;
812
+ need = `; a center-anchored box needs y ≤ ~${round(h - bh / 2)} to sit on-frame`;
813
+ }
814
+ return {
815
+ schemaVersion: 1,
816
+ code: "OFF_CANVAS",
817
+ severity: "warning",
818
+ source: "critique",
819
+ node: id,
820
+ message: `node '${id}' renders fully outside the ${w}×${h} frame (bbox x:[${round(b.minX)},${round(b.maxX)}] y:[${round(b.minY)},${round(b.maxY)}], ${dir}) for its whole on-stage lifetime.${posStr}. Adjust its position/anchor to bring the box on-frame${need}.`,
821
+ detail: {
822
+ frame: a.lastFrame,
823
+ bounds: {
824
+ minX: round(b.minX),
825
+ minY: round(b.minY),
826
+ maxX: round(b.maxX),
827
+ maxY: round(b.maxY)
828
+ },
829
+ size: {
830
+ w,
831
+ h
832
+ }
833
+ }
834
+ };
835
+ }
836
+ function textOverflowDiagnostic(id, dimension, measured, threshold, over, estimating) {
837
+ const base = dimension === "width" ? `text of node '${id}' overflows its box WIDTH by ${round(over)}px (needs ${round(measured)}px, box width ${round(threshold)}px). Reduce fontSize, widen width, or wrap it with fitText({ maxW: ${round(threshold)} }).` : `text of node '${id}' overflows its box HEIGHT by ${round(over)}px (wrapped block ${round(measured)}px tall, box height ${round(threshold)}px). Reduce fontSize, increase the box height, or shorten the text.`;
838
+ return {
839
+ schemaVersion: 1,
840
+ code: "TEXT_OVERFLOW",
841
+ severity: estimating ? "info" : "warning",
842
+ source: "critique",
843
+ node: id,
844
+ message: estimating ? `${base} (metrics ESTIMATED — no real text measurer injected; verify with the real backend measurer.)` : base,
845
+ detail: {
846
+ dimension,
847
+ measured: round(measured),
848
+ threshold: round(threshold),
849
+ overflowPx: round(over),
850
+ estimated: estimating
851
+ }
852
+ };
853
+ }
854
+ function occlusionDiagnostic(scene, id, a) {
855
+ const occ = a.occluderId !== void 0 ? `node '${a.occluderId}'` : "an opaque layer painted above it";
856
+ const pos = vec2At(scene, `${id}/position`, a.lastT);
857
+ return {
858
+ schemaVersion: 1,
859
+ code: "OCCLUSION",
860
+ severity: "warning",
861
+ source: "critique",
862
+ node: id,
863
+ message: `node '${id}'${pos ? ` (position [${round(pos[0])}, ${round(pos[1])}])` : ""} is fully covered by ${occ} (0 visible px) for its whole on-stage lifetime. ${a.occluderId !== void 0 ? `raise '${id}' above '${a.occluderId}' (higher zIndex / paint order) OR move it outside '${a.occluderId}'’s bounds` : "raise its zIndex above the covering layer OR move it out from under the cover"}.`,
864
+ detail: {
865
+ frame: a.lastFrame,
866
+ ...a.occluderId !== void 0 ? { occluder: a.occluderId } : {},
867
+ ...a.occluderBounds ? { occluderBounds: {
868
+ minX: round(a.occluderBounds.minX),
869
+ minY: round(a.occluderBounds.minY),
870
+ maxX: round(a.occluderBounds.maxX),
871
+ maxY: round(a.occluderBounds.maxY)
872
+ } } : {}
873
+ }
874
+ };
875
+ }
876
+ /** Walk `id`'s ided ancestor chain; true if any ancestor is in `flagged`. */
877
+ function hasFlaggedAncestor(scene, id, flagged) {
878
+ let p = scene.nodes.get(id)?.parent ?? null;
879
+ while (p) {
880
+ if (p.id !== void 0 && flagged.has(p.id)) return true;
881
+ p = p.parent;
882
+ }
883
+ return false;
884
+ }
885
+ function numberAt(scene, target, t) {
886
+ const v = resolveAt(scene, target, t);
887
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
888
+ }
889
+ function vec2At(scene, target, t) {
890
+ const v = resolveAt(scene, target, t);
891
+ return Array.isArray(v) && v.length >= 2 && typeof v[0] === "number" && typeof v[1] === "number" ? [v[0], v[1]] : void 0;
892
+ }
893
+ function round(n) {
894
+ return Math.round(n * 100) / 100;
895
+ }
896
+ /**
897
+ * CANONICAL SORT — by frame (detail.frame, undefined last), then code, then
898
+ * node-id. An unordered diagnostic array is golden-unstable; this is the HARD emit
899
+ * requirement (assert sort-invariance: shuffle-then-sort ≡ emitted order). Stable
900
+ * for equal keys (the static-then-rendered concat order is deterministic).
901
+ */
902
+ function sortDiagnostics(diags) {
903
+ const frameOf = (d) => {
904
+ const f = d.detail?.frame;
905
+ return typeof f === "number" ? f : Number.POSITIVE_INFINITY;
906
+ };
907
+ return diags.map((d, i) => [d, i]).sort((A, B) => {
908
+ const [a, ai] = A;
909
+ const [b, bi] = B;
910
+ const fa = frameOf(a);
911
+ const fb = frameOf(b);
912
+ if (fa !== fb) return fa - fb;
913
+ if (a.code !== b.code) return a.code < b.code ? -1 : 1;
914
+ const na = a.node ?? "";
915
+ const nb = b.node ?? "";
916
+ if (na !== nb) return na < nb ? -1 : 1;
917
+ const ta = a.track ?? "";
918
+ const tb = b.track ?? "";
919
+ if (ta !== tb) return ta < tb ? -1 : 1;
920
+ return ai - bi;
921
+ }).map(([d]) => d);
922
+ }
923
+ //#endregion
924
+ export { DIAGNOSTIC_SCHEMA_VERSION, DL_SNAPSHOT_VERSION, DlSnapshotError, auditCacheCold, collapseReplacer, collectLocalizedTextUsages, collectTextUsages, critique, diffDisplayLists, formatDisplayDiff, instanceProps, levenshtein, nearestId, parseDisplaySnapshot, resolveAt, serializeDisplayList, sortDiagnostics, validateScene, validateSceneFonts };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/scene",
3
- "version": "0.59.0",
3
+ "version": "0.60.0-pre.1",
4
4
  "description": "glissade scene graph: nodes, transforms, DisplayList emission. Renderer-agnostic; zero DOM/Node dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -81,7 +81,7 @@
81
81
  ],
82
82
  "dependencies": {
83
83
  "yoga-layout": "^3.2.1",
84
- "@glissade/core": "0.59.0"
84
+ "@glissade/core": "0.60.0-pre.1"
85
85
  },
86
86
  "repository": {
87
87
  "type": "git",