@glissade/scene 0.59.0-pre.1 → 0.60.0-pre.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/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-pre.1";
26
+ const RAW_VERSION = "0.60.0-pre.0";
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
@@ -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,43 @@ 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
+ interface CritiqueResult {
287
+ schemaVersion: typeof DIAGNOSTIC_SCHEMA_VERSION;
288
+ /** true iff any diagnostic has severity `error` (always a static error — the
289
+ * rendered pass emits only warnings/info). */
290
+ hasErrors: boolean;
291
+ /** FLAT merged, CANONICALLY-SORTED diagnostics (static + rendered). */
292
+ diagnostics: SceneDiagnostic[];
293
+ /** true when the rendered pass was skipped because static validation errored. */
294
+ renderedSkipped: boolean;
295
+ /** why the rendered pass was skipped (present iff `renderedSkipped`). */
296
+ renderedSkipReason?: string;
297
+ /** how many integer-frame grid samples the rendered pass took (0 if skipped). */
298
+ sampledFrames: number;
299
+ }
300
+ /**
301
+ * Rendered-geometric diagnostics for `(scene, timeline)`. Runs `validateScene`
302
+ * first; short-circuits the rendered pass on any static error. Otherwise binds the
303
+ * scene, samples a fixed integer-frame grid, and emits OFF_CANVAS / TEXT_OVERFLOW
304
+ * / OCCLUSION where a span check fires. PURE READ — never changes evaluate() or a
305
+ * render path; canonically-sorted (frame, then code, then node-id) output.
306
+ */
307
+ declare function critique(scene: Scene, timeline: Timeline, opts?: CritiqueOptions): CritiqueResult;
308
+ /**
309
+ * CANONICAL SORT — by frame (detail.frame, undefined last), then code, then
310
+ * node-id. An unordered diagnostic array is golden-unstable; this is the HARD emit
311
+ * requirement (assert sort-invariance: shuffle-then-sort ≡ emitted order). Stable
312
+ * for equal keys (the static-then-rendered concat order is deterministic).
313
+ */
314
+ declare function sortDiagnostics(diags: SceneDiagnostic[]): SceneDiagnostic[];
315
+ //#endregion
316
+ 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, 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,438 @@ 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 offCanvasIds = /* @__PURE__ */ new Set();
741
+ for (const [id, a] of agg) if (a.onStage > 0 && a.offCanvas === a.onStage) offCanvasIds.add(id);
742
+ const reportedOffCanvas = /* @__PURE__ */ new Set();
743
+ for (const id of offCanvasIds) {
744
+ if (hasFlaggedAncestor(scene, id, offCanvasIds)) continue;
745
+ reportedOffCanvas.add(id);
746
+ const a = agg.get(id);
747
+ rendered.push(offCanvasDiagnostic(scene, id, a, w, h));
748
+ }
749
+ for (const [id, a] of agg) {
750
+ if (a.lastTexts.length === 0) continue;
751
+ const node = scene.nodes.get(id);
752
+ if (!node || node.describeType !== "Text") continue;
753
+ const width = numberAt(scene, `${id}/width`, a.lastTextFrameT);
754
+ if (width === void 0 || width <= 0) continue;
755
+ let widest = 0;
756
+ for (const run of a.lastTexts) {
757
+ let mw = 0;
758
+ try {
759
+ mw = measurer.measureText(run.text, run.font).width;
760
+ } catch {
761
+ mw = 0;
762
+ }
763
+ if (mw > widest) widest = mw;
764
+ }
765
+ const over = widest - width;
766
+ if (over <= .5) continue;
767
+ rendered.push(textOverflowDiagnostic(id, widest, width, over, estimating));
768
+ }
769
+ for (const [id, a] of agg) {
770
+ if (a.onStage === 0 || a.occluded !== a.onStage) continue;
771
+ if (reportedOffCanvas.has(id) || offCanvasIds.has(id)) continue;
772
+ const node = scene.nodes.get(id);
773
+ if (!node || node instanceof Group) continue;
774
+ rendered.push(occlusionDiagnostic(scene, id, a));
775
+ }
776
+ return {
777
+ schemaVersion: 1,
778
+ hasErrors: false,
779
+ diagnostics: sortDiagnostics([...staticDiags, ...rendered]),
780
+ renderedSkipped: false,
781
+ sampledFrames
782
+ };
783
+ }
784
+ function offCanvasDiagnostic(scene, id, a, w, h) {
785
+ const b = a.lastBounds;
786
+ const bw = b.maxX - b.minX;
787
+ const bh = b.maxY - b.minY;
788
+ let dir = "off-frame";
789
+ let need = "";
790
+ const pos = vec2At(scene, `${id}/position`, a.lastT);
791
+ const posStr = pos ? ` position [${round(pos[0])}, ${round(pos[1])}]` : "";
792
+ if (b.maxX <= 0) {
793
+ dir = `off the LEFT by ${round(-b.maxX)}px`;
794
+ need = `; a center-anchored box needs x ≥ ~${round(bw / 2)} to sit on-frame`;
795
+ } else if (b.minX >= w) {
796
+ dir = `off the RIGHT by ${round(b.minX - w)}px`;
797
+ need = `; a center-anchored box needs x ≤ ~${round(w - bw / 2)} to sit on-frame`;
798
+ } else if (b.maxY <= 0) {
799
+ dir = `off the TOP by ${round(-b.maxY)}px`;
800
+ need = `; a center-anchored box needs y ≥ ~${round(bh / 2)} to sit on-frame`;
801
+ } else if (b.minY >= h) {
802
+ dir = `off the BOTTOM by ${round(b.minY - h)}px`;
803
+ need = `; a center-anchored box needs y ≤ ~${round(h - bh / 2)} to sit on-frame`;
804
+ }
805
+ return {
806
+ schemaVersion: 1,
807
+ code: "OFF_CANVAS",
808
+ severity: "warning",
809
+ source: "critique",
810
+ node: id,
811
+ 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}.`,
812
+ detail: {
813
+ frame: a.lastFrame,
814
+ bounds: {
815
+ minX: round(b.minX),
816
+ minY: round(b.minY),
817
+ maxX: round(b.maxX),
818
+ maxY: round(b.maxY)
819
+ },
820
+ size: {
821
+ w,
822
+ h
823
+ }
824
+ }
825
+ };
826
+ }
827
+ function textOverflowDiagnostic(id, measured, threshold, over, estimating) {
828
+ const base = `text of node '${id}' overflows its box 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)} }).`;
829
+ return {
830
+ schemaVersion: 1,
831
+ code: "TEXT_OVERFLOW",
832
+ severity: estimating ? "info" : "warning",
833
+ source: "critique",
834
+ node: id,
835
+ message: estimating ? `${base} (metrics ESTIMATED — no real text measurer injected; verify with the real backend measurer.)` : base,
836
+ detail: {
837
+ measured: round(measured),
838
+ threshold: round(threshold),
839
+ overflowPx: round(over),
840
+ estimated: estimating
841
+ }
842
+ };
843
+ }
844
+ function occlusionDiagnostic(scene, id, a) {
845
+ const occ = a.occluderId !== void 0 ? `node '${a.occluderId}'` : "an opaque layer painted above it";
846
+ const pos = vec2At(scene, `${id}/position`, a.lastT);
847
+ return {
848
+ schemaVersion: 1,
849
+ code: "OCCLUSION",
850
+ severity: "warning",
851
+ source: "critique",
852
+ node: id,
853
+ 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"}.`,
854
+ detail: {
855
+ frame: a.lastFrame,
856
+ ...a.occluderId !== void 0 ? { occluder: a.occluderId } : {},
857
+ ...a.occluderBounds ? { occluderBounds: {
858
+ minX: round(a.occluderBounds.minX),
859
+ minY: round(a.occluderBounds.minY),
860
+ maxX: round(a.occluderBounds.maxX),
861
+ maxY: round(a.occluderBounds.maxY)
862
+ } } : {}
863
+ }
864
+ };
865
+ }
866
+ /** Walk `id`'s ided ancestor chain; true if any ancestor is in `flagged`. */
867
+ function hasFlaggedAncestor(scene, id, flagged) {
868
+ let p = scene.nodes.get(id)?.parent ?? null;
869
+ while (p) {
870
+ if (p.id !== void 0 && flagged.has(p.id)) return true;
871
+ p = p.parent;
872
+ }
873
+ return false;
874
+ }
875
+ function numberAt(scene, target, t) {
876
+ const v = resolveAt(scene, target, t);
877
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
878
+ }
879
+ function vec2At(scene, target, t) {
880
+ const v = resolveAt(scene, target, t);
881
+ return Array.isArray(v) && v.length >= 2 && typeof v[0] === "number" && typeof v[1] === "number" ? [v[0], v[1]] : void 0;
882
+ }
883
+ function round(n) {
884
+ return Math.round(n * 100) / 100;
885
+ }
886
+ /**
887
+ * CANONICAL SORT — by frame (detail.frame, undefined last), then code, then
888
+ * node-id. An unordered diagnostic array is golden-unstable; this is the HARD emit
889
+ * requirement (assert sort-invariance: shuffle-then-sort ≡ emitted order). Stable
890
+ * for equal keys (the static-then-rendered concat order is deterministic).
891
+ */
892
+ function sortDiagnostics(diags) {
893
+ const frameOf = (d) => {
894
+ const f = d.detail?.frame;
895
+ return typeof f === "number" ? f : Number.POSITIVE_INFINITY;
896
+ };
897
+ return diags.map((d, i) => [d, i]).sort((A, B) => {
898
+ const [a, ai] = A;
899
+ const [b, bi] = B;
900
+ const fa = frameOf(a);
901
+ const fb = frameOf(b);
902
+ if (fa !== fb) return fa - fb;
903
+ if (a.code !== b.code) return a.code < b.code ? -1 : 1;
904
+ const na = a.node ?? "";
905
+ const nb = b.node ?? "";
906
+ if (na !== nb) return na < nb ? -1 : 1;
907
+ const ta = a.track ?? "";
908
+ const tb = b.track ?? "";
909
+ if (ta !== tb) return ta < tb ? -1 : 1;
910
+ return ai - bi;
911
+ }).map(([d]) => d);
912
+ }
913
+ //#endregion
914
+ 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-pre.1",
3
+ "version": "0.60.0-pre.0",
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-pre.1"
84
+ "@glissade/core": "0.60.0-pre.0"
85
85
  },
86
86
  "repository": {
87
87
  "type": "git",