uicore-ts 1.1.310 → 1.1.315

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.
@@ -0,0 +1,3252 @@
1
+ /// #if DEV
2
+
3
+ /**
4
+ * UILayoutDebugger
5
+ *
6
+ * A development-only utility for visualizing and debugging the UIView layout
7
+ * system. Disabled entirely at runtime unless explicitly enabled.
8
+ *
9
+ * FEATURES
10
+ * ─────────
11
+ *
12
+ * 1. Record-and-replay step debugger
13
+ * Every layout pass is recorded as an ordered sequence of steps. Each step
14
+ * captures the view that was laid out, its frame before and after, and the
15
+ * frames assigned to each of its subviews. After the pass completes you can
16
+ * scrub through the steps one at a time in the overlay UI, seeing exactly
17
+ * which view was processed at each point and how the frames changed.
18
+ *
19
+ * 2. Live breakpoint step-through
20
+ * When breakpoint mode is enabled (UILayoutDebugger.enableBreakpoints()), a
21
+ * special sentinel line is executed before each view's layoutIfNeeded() call.
22
+ * Put a browser debugger breakpoint on that line and the JS debugger will
23
+ * pause before every layout step — the full live JS stack and all object
24
+ * state are available at that point, exactly as with any other breakpoint.
25
+ *
26
+ * The sentinel line is:
27
+ * const breakpointOnThisLine = "Add a breakpoint on this line to step through layout."
28
+ * Search for that string in the Sources panel to find it quickly.
29
+ *
30
+ * 3. View-tree heat-map overlay
31
+ * A floating panel renders the full view hierarchy as an indented tree.
32
+ * Each node is coloured by how many times it was laid out in the most recent
33
+ * pass: untouched (grey), once (green), twice (orange), three-or-more (red).
34
+ * The node currently active in the step scrubber is highlighted in blue.
35
+ * Hovering a node shows its class, elementID, and frame.
36
+ *
37
+ * INTEGRATION POINTS IN UIView.ts
38
+ * ────────────────────────────────
39
+ * Add the following calls alongside the existing UILayoutCycleTracer calls:
40
+ *
41
+ * layoutViewsIfNeeded():
42
+ * window.UILayoutDebugger?.willBeginLayoutPass(UIView._viewsToLayout) // before the while loop
43
+ * window.UILayoutDebugger?.willBeginIteration(iteration) // inside the while loop, top
44
+ * window.UILayoutDebugger?.willLayoutView(view) // before view.layoutIfNeeded()
45
+ * [breakpoint sentinel — see below]
46
+ * view.layoutIfNeeded()
47
+ * window.UILayoutDebugger?.didLayoutView(view) // after view.layoutIfNeeded()
48
+ * window.UILayoutDebugger?.didFinishLayoutPass(iteration) // after the while loop
49
+ *
50
+ * layoutSubviews():
51
+ * window.UILayoutDebugger?.willSetSubviewFrames(this) // before the subview loop
52
+ * [existing subview loop]
53
+ * window.UILayoutDebugger?.didSetSubviewFrames(this) // after the subview loop
54
+ *
55
+ * The breakpoint sentinel block (inside layoutViewsIfNeeded, before
56
+ * view.layoutIfNeeded()):
57
+ *
58
+ * if (window.UILayoutDebugger?._shouldHitBreakpoint(view)) {
59
+ * const breakpointOnThisLine = "Add a breakpoint on this line to step through layout."
60
+ * }
61
+ *
62
+ * USAGE
63
+ * ─────
64
+ * UILayoutDebugger.enable() — record traces and show the overlay
65
+ * UILayoutDebugger.disable() — hide overlay and stop recording
66
+ * UILayoutDebugger.enableBreakpoints() — also pause at each layout step
67
+ * UILayoutDebugger.stepForward() — advance the replay scrubber by one
68
+ * UILayoutDebugger.stepBack() — retreat the replay scrubber by one
69
+ * UILayoutDebugger.goToStep(n) — jump to step n (0-based)
70
+ */
71
+
72
+
73
+ // ─── Data model ─────────────────────────────────────────────────────────────
74
+
75
+ interface UILayoutDebugFrame {
76
+ top: number
77
+ left: number
78
+ width: number
79
+ height: number
80
+ }
81
+
82
+ /** A snapshot of a view's intrinsic size cache at a point in time. */
83
+ interface UILayoutDebugCacheSnapshot {
84
+ entryCount: number
85
+ entries: Record<string, { width: number; height: number }>
86
+ isShared: boolean
87
+ sharedKey?: string
88
+ /** Whether _frameCache was populated at snapshot time. */
89
+ hasFrameCache: boolean
90
+ /** Snapshot of _frameCache if populated; null if absent. */
91
+ frameCache: UILayoutDebugFrame | null
92
+ /** Whether _frameCacheForVirtualLayouting was populated at snapshot time. */
93
+ hasVirtualFrameCache: boolean
94
+ /** Snapshot of _frameCacheForVirtualLayouting if populated; null if absent. */
95
+ virtualFrameCache: UILayoutDebugFrame | null
96
+ }
97
+
98
+ /**
99
+ * Global UITextMeasurement cache sizes at a snapshot instant.
100
+ * These are not per-view — they're attached to the snapshot as a whole.
101
+ */
102
+ interface UILayoutDebugTextMeasurementSnapshot {
103
+ preparedCacheSize: number
104
+ styleCacheSize: number
105
+ }
106
+
107
+ interface UILayoutDebugSubviewRecord {
108
+ viewIndex: number // _UIViewIndex of the subview
109
+ className: string
110
+ elementID: string
111
+ frameBefore: UILayoutDebugFrame | null
112
+ frameAfter: UILayoutDebugFrame | null
113
+ }
114
+
115
+ /** What caused a view to enter the layout queue. */
116
+ interface UILayoutDebugTrigger {
117
+ callerFunction: string // first application frame, e.g. "MyView.layoutSubviews"
118
+ cleanStack: string // full cleaned stack string
119
+ }
120
+
121
+ /** One step = one call to layoutIfNeeded() on one view. */
122
+ interface UILayoutDebugStep {
123
+ stepIndex: number
124
+ iteration: number
125
+ viewIndex: number // _UIViewIndex of the laid-out view
126
+ className: string
127
+ elementID: string
128
+ frameBefore: UILayoutDebugFrame | null
129
+ frameAfter: UILayoutDebugFrame | null
130
+ cacheBefore: UILayoutDebugCacheSnapshot | null
131
+ cacheAfter: UILayoutDebugCacheSnapshot | null
132
+ subviewRecords: UILayoutDebugSubviewRecord[]
133
+ trigger: UILayoutDebugTrigger | null // what called setNeedsLayout on this view
134
+ }
135
+
136
+ interface UILayoutDebugTreeNode {
137
+ viewIndex: number
138
+ className: string
139
+ elementID: string
140
+ depth: number
141
+ frame: UILayoutDebugFrame | null
142
+ layoutCount: number // times laid out in the recorded pass
143
+ cacheAfterPass: UILayoutDebugCacheSnapshot | null // intrinsic cache state after the pass
144
+ children: UILayoutDebugTreeNode[]
145
+ }
146
+
147
+ interface UILayoutDebugTrace {
148
+ passIndex: number
149
+ steps: UILayoutDebugStep[]
150
+ roots: UILayoutDebugTreeNode[]
151
+ totalIterations: number
152
+ cacheChanges: UILayoutDebugCacheChangeEvent[]
153
+ }
154
+
155
+ /** A flat snapshot of every view's frame and intrinsic cache at a point in time. */
156
+ interface UILayoutDebugStateSnapshot {
157
+ label: string
158
+ takenAt: number // Date.now()
159
+ views: Map<number, UILayoutDebugViewState>
160
+ /** Global UITextMeasurement cache sizes at the instant this snapshot was taken. */
161
+ textMeasurement: UILayoutDebugTextMeasurementSnapshot
162
+ }
163
+
164
+ interface UILayoutDebugViewState {
165
+ viewIndex: number
166
+ className: string
167
+ elementID: string
168
+ frame: UILayoutDebugFrame | null
169
+ cache: UILayoutDebugCacheSnapshot | null
170
+ }
171
+
172
+ type UILayoutDebugDiffKind = "appeared" | "disappeared" | "frame" | "cache" | "both" | "unchanged"
173
+
174
+ interface UILayoutDebugViewDiff {
175
+ kind: UILayoutDebugDiffKind
176
+ viewIndex: number
177
+ className: string
178
+ elementID: string
179
+ baselineFrame: UILayoutDebugFrame | null
180
+ currentFrame: UILayoutDebugFrame | null
181
+ baselineCache: UILayoutDebugCacheSnapshot | null
182
+ currentCache: UILayoutDebugCacheSnapshot | null
183
+ }
184
+
185
+ /**
186
+ * Fired when _getCachedIntrinsicSize returns a value that differs from the
187
+ * last value we observed for that view+cacheKey combination.
188
+ */
189
+ interface UILayoutDebugCacheChangeEvent {
190
+ eventIndex: number
191
+ stepIndex: number // which step was active when the write occurred (-1 = between steps)
192
+ iteration: number
193
+ viewIndex: number
194
+ className: string
195
+ elementID: string
196
+ cacheKey: string // raw key, e.g. "h_0__w_500"
197
+ newValue: { width: number; height: number }
198
+ callerFunction: string // first app-code frame at point of write
199
+ cleanStack: string
200
+ }
201
+
202
+
203
+ // ─── Main class ──────────────────────────────────────────────────────────────
204
+
205
+ export class UILayoutDebugger {
206
+
207
+ // ── Runtime guard ────────────────────────────────────────────────────────
208
+ // The #if DEV preprocessor comment may not be present in every build.
209
+ // This flag is the authoritative runtime gate. All hook methods check it
210
+ // first and are no-ops unless _isEnabled is true.
211
+
212
+ static _isEnabled: boolean = false
213
+ static _breakpointsEnabled: boolean = false
214
+
215
+ static get isEnabled(): boolean { return UILayoutDebugger._isEnabled }
216
+ static get breakpointsEnabled(): boolean { return UILayoutDebugger._breakpointsEnabled }
217
+
218
+
219
+ // ── Recording state ──────────────────────────────────────────────────────
220
+
221
+ static _passIndex: number = 0
222
+ static _currentTrace: UILayoutDebugTrace | null = null
223
+ static _currentIteration: number = 0
224
+
225
+ // Pending step being built as a view is being laid out
226
+ static _pendingStep: UILayoutDebugStep | null = null
227
+
228
+ // Subview frames captured during layoutSubviews() of the pending step's view
229
+ static _pendingSubviewsBefore: Map<number, UILayoutDebugFrame | null> = new Map()
230
+
231
+ // Per-view layout counts for the current pass (used for tree colouring)
232
+ static _layoutCountsThisPass: Map<number, number> = new Map()
233
+
234
+ // Live view object references keyed by _UIViewIndex, populated during the
235
+ // pass and used to build the subtree forest in didFinishLayoutPass.
236
+ static _liveViewRegistry: Map<number, any> = new Map()
237
+
238
+ // First setNeedsLayout trigger per view per pass. Only the first enqueue
239
+ // is recorded — subsequent redundant calls on the same view are ignored.
240
+ static _triggerMap: Map<number, UILayoutDebugTrigger> = new Map()
241
+
242
+ // Stack frames belonging to framework internals that should be stripped
243
+ // from the top of a captured stack so the first visible frame is always
244
+ // application code.
245
+ static _noiseFramePrefixes: string[] = [
246
+ "UILayoutDebugger",
247
+ "UIView.setNeedsLayout",
248
+ "setNeedsLayout",
249
+ "UIView.didLayoutSubviews",
250
+ "didLayoutSubviews",
251
+ "UIView.layoutSubviews",
252
+ "UIView.layoutIfNeeded",
253
+ "layoutIfNeeded",
254
+ "UIView.layoutViewsIfNeeded",
255
+ "layoutViewsIfNeeded",
256
+ "UIView._setCachedIntrinsicSize",
257
+ "_setCachedIntrinsicSize",
258
+ ]
259
+
260
+ // All completed traces, newest first
261
+ static _traces: UILayoutDebugTrace[] = []
262
+ static readonly maxStoredTraces = 20
263
+
264
+
265
+ // ── Replay state ─────────────────────────────────────────────────────────
266
+
267
+ static _replayTraceIndex: number = 0 // which trace is shown in left/single pane
268
+ static _replayStepIndex: number = -1 // -1 = before any step
269
+
270
+ // ── Compare mode state ────────────────────────────────────────────────────
271
+
272
+ static _compareMode: boolean = false
273
+ static _frameFilter: "all" | "changed" | "unchanged" = "all"
274
+
275
+ /**
276
+ * When true, frame comparisons ignore origin (x/y) and only consider size
277
+ * (width/height) — i.e. they diff bounds rather than frames.
278
+ * A position-only move does not trigger a layout recompute of content, so
279
+ * bounds mode surfaces only the changes that actually matter for sizing.
280
+ */
281
+ static _boundsBasedDiff: boolean = false
282
+ static _compareTraceIndex: number = 1 // which trace is shown in right pane
283
+ static _compareStepIndex: number = -1
284
+
285
+ // Shared expand/collapse state for the tree in compare mode, keyed by
286
+ // viewIndex. When both trees render from the same map, toggling one node
287
+ // collapses/expands the same node in both panes simultaneously.
288
+ static _sharedExpandState: Map<number, boolean> = new Map()
289
+
290
+ // Expand state for the single-column pass inspector. Kept persistent so
291
+ // the live inspector can sync from it.
292
+ static _singleExpandState: Map<number, boolean> = new Map()
293
+
294
+ // Expand state for the live inspector panel.
295
+ static _liveExpandState: Map<number, boolean> = new Map()
296
+
297
+
298
+ // ── Public API ───────────────────────────────────────────────────────────
299
+
300
+ static enable() {
301
+ UILayoutDebugger._isEnabled = true
302
+ UILayoutDebugger._ensureOverlay()
303
+ UILayoutDebugger._renderOverlay()
304
+ console.log(
305
+ "%c[UILayoutDebugger] ENABLED — recording layout traces and showing overlay.",
306
+ "color: #4CAF50; font-weight: bold"
307
+ )
308
+ }
309
+
310
+ static disable() {
311
+ UILayoutDebugger._isEnabled = false
312
+ UILayoutDebugger._breakpointsEnabled = false
313
+ UILayoutDebugger._removeOverlay()
314
+ console.log(
315
+ "%c[UILayoutDebugger] DISABLED.",
316
+ "color: #9E9E9E; font-weight: bold"
317
+ )
318
+ }
319
+
320
+ /**
321
+ * Enable the breakpoint sentinel. Once enabled, _shouldHitBreakpoint()
322
+ * returns true before every layoutIfNeeded() call so the browser debugger
323
+ * can pause on the sentinel line in UIView.ts.
324
+ */
325
+ static enableBreakpoints() {
326
+ if (!UILayoutDebugger._isEnabled) {
327
+ UILayoutDebugger.enable()
328
+ }
329
+ UILayoutDebugger._breakpointsEnabled = true
330
+ console.log(
331
+ "%c[UILayoutDebugger] Breakpoint mode ON. " +
332
+ "Search for 'breakpointOnThisLine' in Sources to set your breakpoint.",
333
+ "color: #FF9800; font-weight: bold"
334
+ )
335
+ }
336
+
337
+ static disableBreakpoints() {
338
+ UILayoutDebugger._breakpointsEnabled = false
339
+ console.log(
340
+ "%c[UILayoutDebugger] Breakpoint mode OFF.",
341
+ "color: #9E9E9E"
342
+ )
343
+ }
344
+
345
+ // ── Replay controls ──────────────────────────────────────────────────────
346
+
347
+ static stepForward() {
348
+ if (!UILayoutDebugger._isEnabled) { return }
349
+ const trace = UILayoutDebugger._currentReplayTrace
350
+ if (!trace) { return }
351
+ const next = UILayoutDebugger._replayStepIndex + 1
352
+ UILayoutDebugger.goToStep(Math.min(next, trace.steps.length - 1))
353
+ }
354
+
355
+ static stepBack() {
356
+ if (!UILayoutDebugger._isEnabled) { return }
357
+ UILayoutDebugger.goToStep(Math.max(UILayoutDebugger._replayStepIndex - 1, -1))
358
+ }
359
+
360
+ static goToStep(stepIndex: number) {
361
+ if (!UILayoutDebugger._isEnabled) { return }
362
+ UILayoutDebugger._replayStepIndex = stepIndex
363
+ UILayoutDebugger._renderOverlay()
364
+ }
365
+
366
+ static goToCompareStep(stepIndex: number) {
367
+ if (!UILayoutDebugger._isEnabled) { return }
368
+ UILayoutDebugger._compareStepIndex = stepIndex
369
+ UILayoutDebugger._renderOverlay()
370
+ }
371
+
372
+ static showTrace(traceIndex: number) {
373
+ if (!UILayoutDebugger._isEnabled) { return }
374
+ const clamped = Math.max(0, Math.min(traceIndex, UILayoutDebugger._traces.length - 1))
375
+ UILayoutDebugger._replayTraceIndex = clamped
376
+ UILayoutDebugger._replayStepIndex = -1
377
+ UILayoutDebugger._singleExpandState = new Map()
378
+ UILayoutDebugger._renderOverlay()
379
+ }
380
+
381
+ static showCompareTrace(traceIndex: number) {
382
+ if (!UILayoutDebugger._isEnabled) { return }
383
+ const clamped = Math.max(0, Math.min(traceIndex, UILayoutDebugger._traces.length - 1))
384
+ UILayoutDebugger._compareTraceIndex = clamped
385
+ UILayoutDebugger._compareStepIndex = -1
386
+ UILayoutDebugger._renderOverlay()
387
+ }
388
+
389
+ static get _currentReplayTrace(): UILayoutDebugTrace | null {
390
+ return UILayoutDebugger._traces[UILayoutDebugger._replayTraceIndex] ?? null
391
+ }
392
+
393
+ static get _currentCompareTrace(): UILayoutDebugTrace | null {
394
+ return UILayoutDebugger._traces[UILayoutDebugger._compareTraceIndex] ?? null
395
+ }
396
+
397
+
398
+ // ── Hook: called from layoutViewsIfNeeded() ──────────────────────────────
399
+
400
+ static willBeginLayoutPass(viewsToLayout: any[]) {
401
+ if (!UILayoutDebugger._isEnabled) { return }
402
+
403
+ UILayoutDebugger._currentTrace = {
404
+ passIndex: UILayoutDebugger._passIndex++,
405
+ steps: [],
406
+ roots: [],
407
+ cacheChanges: [],
408
+ totalIterations: 0,
409
+ }
410
+ UILayoutDebugger._currentIteration = 0
411
+ UILayoutDebugger._layoutCountsThisPass = new Map()
412
+ UILayoutDebugger._liveViewRegistry = new Map()
413
+ UILayoutDebugger._pendingStep = null
414
+ UILayoutDebugger._pendingSubviewsBefore = new Map()
415
+ }
416
+
417
+ static willBeginIteration(iteration: number) {
418
+ if (!UILayoutDebugger._isEnabled) { return }
419
+ UILayoutDebugger._currentIteration = iteration
420
+ }
421
+
422
+ /**
423
+ * Called from setNeedsLayout() each time a view is enqueued.
424
+ * Only the *first* enqueue per view per pass is recorded — that is the
425
+ * call that actually caused the view to enter the queue. Subsequent calls
426
+ * on the same view within the same pass are redundant and ignored.
427
+ */
428
+ static viewDidCallSetNeedsLayout(view: any) {
429
+ if (!UILayoutDebugger._isEnabled) { return }
430
+
431
+ const viewIdx: number = view?._UIViewIndex ?? -1
432
+ if (viewIdx < 0) { return }
433
+
434
+ // Only record the first enqueue per view per pass.
435
+ if (UILayoutDebugger._triggerMap.has(viewIdx)) { return }
436
+
437
+ const rawStack = new Error().stack ?? ""
438
+ const cleanStack = UILayoutDebugger._cleanStack(rawStack)
439
+ const callerFunction = UILayoutDebugger._extractCallerFunctionName(cleanStack)
440
+
441
+ UILayoutDebugger._triggerMap.set(viewIdx, { callerFunction, cleanStack })
442
+ }
443
+
444
+ /**
445
+ * Called from _setCachedIntrinsicSize() after the value is written.
446
+ * Every write is a change by definition, so no history comparison is needed.
447
+ *
448
+ * Call site in UIView.ts, at the end of _setCachedIntrinsicSize():
449
+ *
450
+ * window.UILayoutDebugger?.didSetCachedIntrinsicSize(this, cacheKey, size)
451
+ */
452
+ static didSetCachedIntrinsicSize(view: any, cacheKey: string, value: any) {
453
+ if (!UILayoutDebugger._isEnabled) { return }
454
+
455
+ const trace = UILayoutDebugger._currentTrace
456
+ if (!trace) { return }
457
+
458
+ const viewIdx: number = view?._UIViewIndex ?? -1
459
+ if (viewIdx < 0) { return }
460
+
461
+ const rawStack = new Error().stack ?? ""
462
+ const cleanStack = UILayoutDebugger._cleanStack(rawStack)
463
+ const callerFunction = UILayoutDebugger._extractCallerFunctionName(cleanStack)
464
+
465
+ const event: UILayoutDebugCacheChangeEvent = {
466
+ eventIndex: trace.cacheChanges.length,
467
+ stepIndex: UILayoutDebugger._pendingStep?.stepIndex ?? -1,
468
+ iteration: UILayoutDebugger._currentIteration,
469
+ viewIndex: viewIdx,
470
+ className: view?.constructor?.name ?? "UnknownView",
471
+ elementID: view?.elementID ?? String(viewIdx),
472
+ cacheKey,
473
+ newValue: { width: value?.width ?? 0, height: value?.height ?? 0 },
474
+ callerFunction,
475
+ cleanStack,
476
+ }
477
+ trace.cacheChanges.push(event)
478
+ }
479
+ static willLayoutView(view: any) {
480
+ if (!UILayoutDebugger._isEnabled) { return }
481
+
482
+ const stepIndex = UILayoutDebugger._currentTrace?.steps.length ?? 0
483
+ const viewIdx: number = view?._UIViewIndex ?? -1
484
+
485
+ // Keep a live reference so didFinishLayoutPass can reach .rootView.
486
+ if (viewIdx >= 0) {
487
+ UILayoutDebugger._liveViewRegistry.set(viewIdx, view)
488
+ }
489
+
490
+ UILayoutDebugger._pendingStep = {
491
+ stepIndex,
492
+ iteration: UILayoutDebugger._currentIteration,
493
+ viewIndex: viewIdx,
494
+ className: view?.constructor?.name ?? "UnknownView",
495
+ elementID: view?.elementID ?? String(viewIdx),
496
+ frameBefore: UILayoutDebugger._captureFrame(view),
497
+ frameAfter: null,
498
+ cacheBefore: UILayoutDebugger._captureCache(view),
499
+ cacheAfter: null,
500
+ subviewRecords: [],
501
+ trigger: UILayoutDebugger._triggerMap.get(viewIdx) ?? null,
502
+ }
503
+ // Consume the trigger so it doesn't linger across future passes.
504
+ UILayoutDebugger._triggerMap.delete(viewIdx)
505
+
506
+ // Capture subview frames *before* layout. The post-layout capture
507
+ // happens in didSetSubviewFrames() which is called from layoutSubviews().
508
+ UILayoutDebugger._pendingSubviewsBefore = new Map()
509
+ const subviews: any[] = view?.subviews ?? []
510
+ for (let i = 0; i < subviews.length; i++) {
511
+ const sv = subviews[i]
512
+ const idx: number = sv?._UIViewIndex ?? -i
513
+ UILayoutDebugger._pendingSubviewsBefore.set(idx, UILayoutDebugger._captureFrame(sv))
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Called immediately after view.layoutIfNeeded(). Closes the pending step
519
+ * with the post-layout frame.
520
+ */
521
+ static didLayoutView(view: any) {
522
+ if (!UILayoutDebugger._isEnabled) { return }
523
+
524
+ const step = UILayoutDebugger._pendingStep
525
+ if (!step) { return }
526
+
527
+ step.frameAfter = UILayoutDebugger._captureFrame(view)
528
+ step.cacheAfter = UILayoutDebugger._captureCache(view)
529
+
530
+ const viewIdx: number = view?._UIViewIndex ?? -1
531
+ const prev = UILayoutDebugger._layoutCountsThisPass.get(viewIdx) ?? 0
532
+ UILayoutDebugger._layoutCountsThisPass.set(viewIdx, prev + 1)
533
+
534
+ UILayoutDebugger._currentTrace?.steps.push(step)
535
+ UILayoutDebugger._pendingStep = null
536
+ }
537
+
538
+ static didFinishLayoutPass(iterationCount: number) {
539
+ if (!UILayoutDebugger._isEnabled) { return }
540
+
541
+ const trace = UILayoutDebugger._currentTrace
542
+ if (!trace) { return }
543
+
544
+ trace.totalIterations = iterationCount
545
+
546
+ // Build a single full-tree snapshot by starting from the global root.
547
+ // Any laid-out view has a .rootView property that walks to the top of
548
+ // the hierarchy, giving full context around the affected views.
549
+ const anyView = UILayoutDebugger._liveViewRegistry.values().next().value
550
+ const rootView = anyView?.rootView
551
+ if (rootView) {
552
+ UILayoutDebugger._lastKnownRootView = rootView
553
+ const visited = new Set<number>()
554
+ const rootIdx: number = rootView._UIViewIndex ?? -1
555
+ if (rootIdx >= 0) { visited.add(rootIdx) }
556
+ trace.roots = [UILayoutDebugger._buildTreeSnapshot(rootView, 0, visited)]
557
+ }
558
+
559
+ // Prepend and evict old traces.
560
+ UILayoutDebugger._traces.unshift(trace)
561
+ if (UILayoutDebugger._traces.length > UILayoutDebugger.maxStoredTraces) {
562
+ UILayoutDebugger._traces.length = UILayoutDebugger.maxStoredTraces
563
+ }
564
+
565
+ UILayoutDebugger._replayTraceIndex = 0
566
+ UILayoutDebugger._replayStepIndex = -1
567
+ UILayoutDebugger._currentTrace = null
568
+ UILayoutDebugger._liveViewRegistry.clear()
569
+
570
+ UILayoutDebugger._renderOverlay()
571
+ }
572
+
573
+ /** Discard all recorded traces and reset the replay state. */
574
+ static clearTraces() {
575
+ UILayoutDebugger._traces = []
576
+ UILayoutDebugger._passIndex = 0
577
+ UILayoutDebugger._replayTraceIndex = 0
578
+ UILayoutDebugger._compareTraceIndex = 1
579
+ UILayoutDebugger._replayStepIndex = -1
580
+ UILayoutDebugger._compareStepIndex = -1
581
+ UILayoutDebugger._renderOverlay()
582
+ }
583
+
584
+ // ── Baseline / diff API ──────────────────────────────────────────────────
585
+
586
+ /** Capture the current view tree state as the baseline for future diffs. */
587
+ static captureBaseline() {
588
+ if (!UILayoutDebugger._isEnabled) { return }
589
+ const snap = UILayoutDebugger._captureStateSnapshot("Baseline")
590
+ if (!snap) {
591
+ console.warn("[UILayoutDebugger] captureBaseline: no root view found yet — trigger a layout pass first.")
592
+ return
593
+ }
594
+ UILayoutDebugger._baseline = snap
595
+ UILayoutDebugger._diffSnapshot = null
596
+ UILayoutDebugger._diffMode = false
597
+ UILayoutDebugger._renderOverlay()
598
+ console.log(
599
+ `%c[UILayoutDebugger] Baseline captured — ${snap.views.size} views.`,
600
+ "color: #88ddff; font-weight: bold"
601
+ )
602
+ }
603
+
604
+ /** Capture the current state and diff it against the baseline. */
605
+ static captureAndDiff() {
606
+ if (!UILayoutDebugger._isEnabled) { return }
607
+ if (!UILayoutDebugger._baseline) {
608
+ console.warn("[UILayoutDebugger] captureAndDiff: no baseline set. Call captureBaseline() first.")
609
+ return
610
+ }
611
+ const snap = UILayoutDebugger._captureStateSnapshot("Current")
612
+ if (!snap) {
613
+ console.warn("[UILayoutDebugger] captureAndDiff: could not find root view.")
614
+ return
615
+ }
616
+ UILayoutDebugger._diffSnapshot = snap
617
+ UILayoutDebugger._diffMode = true
618
+ UILayoutDebugger._renderOverlay()
619
+ }
620
+
621
+ static clearDiff() {
622
+ UILayoutDebugger._baseline = null
623
+ UILayoutDebugger._diffSnapshot = null
624
+ UILayoutDebugger._diffMode = false
625
+ UILayoutDebugger._renderOverlay()
626
+ }
627
+
628
+ /**
629
+ * ☢ Stale Layout Report
630
+ *
631
+ * The single authoritative way to discover missing cache invalidations.
632
+ *
633
+ * What this does, in order:
634
+ * 1. Snapshots every view's frame and intrinsic cache right now (the
635
+ * "before" state — potentially stale/incorrect).
636
+ * 2. Calls performForcedSubtreeLayout() on the root view, which nukes all
637
+ * caches and forces a complete cold remeasure of the entire tree.
638
+ * 3. Snapshots again ("after" state — ground truth).
639
+ * 4. Diffs the two snapshots. Any view that changed between before and
640
+ * after had stale state that was never correctly invalidated.
641
+ * 5. For each changed view, cross-references the cache writes from the
642
+ * forced pass so you can see exactly which call path recomputed the
643
+ * correct value — working backwards from that to find the missing
644
+ * invalidation site.
645
+ *
646
+ * The result is shown in the ☢ Stale panel to the right of the pass
647
+ * inspector. Views corrected by the forced pass are also tinted amber in
648
+ * the pass inspector tree on the subsequent pass.
649
+ *
650
+ * Limitations:
651
+ * - Calls performForcedSubtreeLayout(), which is itself a nuclear option.
652
+ * The tree will be left in its corrected state — not the buggy state.
653
+ * Use this at the point where the bug is visible, not before.
654
+ * - The forced layout will generate a new trace (the remeasure pass).
655
+ * The ☢ panel cross-references its cache writes automatically.
656
+ * - Only intrinsic-size cache corrections are cross-referenced. Frame
657
+ * corrections are shown as diffs but do not yet have a write-stack.
658
+ */
659
+ static captureStaleLayoutReport() {
660
+ if (!UILayoutDebugger._isEnabled) { return }
661
+ const rootView = UILayoutDebugger._lastKnownRootView
662
+ if (!rootView) {
663
+ console.warn(
664
+ "[UILayoutDebugger] captureStaleLayoutReport: no root view found yet — " +
665
+ "trigger a layout pass first."
666
+ )
667
+ return
668
+ }
669
+
670
+ // Step 1 — snapshot before.
671
+ // We pin rootView here and pass it directly to a targeted walk rather than
672
+ // going through _lastKnownRootView, because performForcedSubtreeLayout
673
+ // drives layoutViewsIfNeeded synchronously, which fires didFinishLayoutPass,
674
+ // which may update _lastKnownRootView to a detached view that was temporarily
675
+ // inserted into document.body for intrinsic-size measurement. If that
676
+ // happened the "after" snapshot would walk from a completely different root
677
+ // than "before", causing spurious diffs for every view in the real tree.
678
+ const before = UILayoutDebugger._captureStateSnapshotFromRoot(rootView, "Before (potentially stale)")
679
+
680
+ // Step 2 — nuclear reset
681
+ rootView.performForcedSubtreeLayout?.()
682
+
683
+ // Step 3 — snapshot after (ground truth), using the same pinned root.
684
+ const after = UILayoutDebugger._captureStateSnapshotFromRoot(rootView, "After (forced cold remeasure)")
685
+
686
+ // Step 4 — diff
687
+ const diffs = UILayoutDebugger._diffSnapshots(before, after)
688
+ .filter(d => d.kind !== "unchanged")
689
+
690
+ // Step 5 — collect cache writes from the forced pass (the trace that was
691
+ // just recorded by performForcedSubtreeLayout), keyed by viewIndex.
692
+ const forcedPassCacheChanges = new Map<number, UILayoutDebugCacheChangeEvent[]>()
693
+ const forcedTrace = UILayoutDebugger._traces[0] ?? null // newest = the forced pass
694
+ const forcedPassIndex = forcedTrace?.passIndex ?? -1
695
+ if (forcedTrace) {
696
+ for (const ev of forcedTrace.cacheChanges) {
697
+ let bucket = forcedPassCacheChanges.get(ev.viewIndex)
698
+ if (!bucket) {
699
+ bucket = []
700
+ forcedPassCacheChanges.set(ev.viewIndex, bucket)
701
+ }
702
+ bucket.push(ev)
703
+ }
704
+ }
705
+
706
+ UILayoutDebugger._staleReportResult = { before, after, diffs, forcedPassCacheChanges, passIndex: forcedPassIndex }
707
+ UILayoutDebugger._staleReportMode = true
708
+ UILayoutDebugger._renderOverlay()
709
+
710
+ const correctedCount = diffs.length
711
+ console.log(
712
+ `%c[UILayoutDebugger] Stale layout report: ${correctedCount} view(s) had stale state corrected by forced layout.`,
713
+ "color: #ffaa55; font-weight: bold"
714
+ )
715
+ }
716
+
717
+ static clearStaleReport() {
718
+ UILayoutDebugger._staleReportResult = null
719
+ UILayoutDebugger._staleReportMode = false
720
+ UILayoutDebugger._renderOverlay()
721
+ }
722
+
723
+ static toggleLiveInspector() {
724
+ UILayoutDebugger._liveInspectorMode = !UILayoutDebugger._liveInspectorMode
725
+ UILayoutDebugger._renderOverlay()
726
+ }
727
+
728
+ static _captureStateSnapshot(label: string): UILayoutDebugStateSnapshot | null {
729
+ const rootView = UILayoutDebugger._lastKnownRootView
730
+ if (!rootView) { return null }
731
+ return UILayoutDebugger._captureStateSnapshotFromRoot(rootView, label)
732
+ }
733
+
734
+ /**
735
+ * Like _captureStateSnapshot but walks from an explicit root rather than
736
+ * _lastKnownRootView. Use this whenever the root must be pinned across a
737
+ * call that may update _lastKnownRootView (e.g. captureStaleLayoutReport,
738
+ * which drives a layout pass internally).
739
+ */
740
+ static _captureStateSnapshotFromRoot(rootView: any, label: string): UILayoutDebugStateSnapshot {
741
+ const views = new Map<number, UILayoutDebugViewState>()
742
+ UILayoutDebugger._walkViewTree(rootView, views, new Set())
743
+
744
+ // Read UITextMeasurement global cache sizes. Both maps are private, but
745
+ // accessible via the class reference on window if exposed, or via the
746
+ // module-level import. We reach them defensively so the debugger never
747
+ // throws if the import shape changes.
748
+ const tm: any = (window as any).UITextMeasurement
749
+ const textMeasurement: UILayoutDebugTextMeasurementSnapshot = {
750
+ preparedCacheSize: tm?._preparedCache?.size ?? -1,
751
+ styleCacheSize: tm?.globalStyleCache?.size ?? -1,
752
+ }
753
+
754
+ return { label, takenAt: Date.now(), views, textMeasurement }
755
+ }
756
+
757
+ static _walkViewTree(
758
+ view: any,
759
+ out: Map<number, UILayoutDebugViewState>,
760
+ visited: Set<number>,
761
+ ) {
762
+ const idx: number = view?._UIViewIndex ?? -1
763
+ if (idx < 0 || visited.has(idx)) { return }
764
+ visited.add(idx)
765
+
766
+ out.set(idx, {
767
+ viewIndex: idx,
768
+ className: view?.constructor?.name ?? "UnknownView",
769
+ elementID: view?.elementID ?? String(idx),
770
+ frame: UILayoutDebugger._captureFrame(view),
771
+ cache: UILayoutDebugger._captureCache(view),
772
+ })
773
+
774
+ const subviews: any[] = view?.subviews ?? []
775
+ for (const sv of subviews) { UILayoutDebugger._walkViewTree(sv, out, visited) }
776
+ }
777
+
778
+ static _diffSnapshots(
779
+ baseline: UILayoutDebugStateSnapshot,
780
+ current: UILayoutDebugStateSnapshot,
781
+ ): UILayoutDebugViewDiff[] {
782
+ const diffs: UILayoutDebugViewDiff[] = []
783
+ const allKeys = new Set([...baseline.views.keys(), ...current.views.keys()])
784
+
785
+ for (const idx of allKeys) {
786
+ const b = baseline.views.get(idx) ?? null
787
+ const c = current.views.get(idx) ?? null
788
+
789
+ if (!b) {
790
+ diffs.push({ kind: "appeared", viewIndex: idx,
791
+ className: c!.className, elementID: c!.elementID,
792
+ baselineFrame: null, currentFrame: c!.frame,
793
+ baselineCache: null, currentCache: c!.cache })
794
+ continue
795
+ }
796
+ if (!c) {
797
+ diffs.push({ kind: "disappeared", viewIndex: idx,
798
+ className: b.className, elementID: b.elementID,
799
+ baselineFrame: b.frame, currentFrame: null,
800
+ baselineCache: b.cache, currentCache: null })
801
+ continue
802
+ }
803
+
804
+ const frameChanged = UILayoutDebugger._framesEqual(b.frame, c.frame) === false
805
+ const cacheChanged = UILayoutDebugger._cachesEqual(b.cache, c.cache) === false
806
+ const kind: UILayoutDebugDiffKind =
807
+ frameChanged && cacheChanged ? "both"
808
+ : frameChanged ? "frame"
809
+ : cacheChanged ? "cache"
810
+ : "unchanged"
811
+
812
+ diffs.push({ kind, viewIndex: idx,
813
+ className: c.className, elementID: c.elementID,
814
+ baselineFrame: b.frame, currentFrame: c.frame,
815
+ baselineCache: b.cache, currentCache: c.cache })
816
+ }
817
+
818
+ // Sort: appeared, disappeared, both, frame, cache, unchanged
819
+ const order: Record<UILayoutDebugDiffKind, number> = {
820
+ appeared: 0, disappeared: 1, both: 2, frame: 3, cache: 4, unchanged: 5,
821
+ }
822
+ diffs.sort((a, b) => order[a.kind] - order[b.kind])
823
+ return diffs
824
+ }
825
+
826
+ static _framesEqual(a: UILayoutDebugFrame | null, b: UILayoutDebugFrame | null): boolean {
827
+ if (!a && !b) { return true }
828
+ if (!a || !b) { return false }
829
+ if (UILayoutDebugger._boundsBasedDiff) {
830
+ // Bounds mode: ignore origin, compare size only.
831
+ return a.width === b.width && a.height === b.height
832
+ }
833
+ return a.left === b.left && a.top === b.top && a.width === b.width && a.height === b.height
834
+ }
835
+
836
+ static _cachesEqual(a: UILayoutDebugCacheSnapshot | null, b: UILayoutDebugCacheSnapshot | null): boolean {
837
+ if (!a && !b) { return true }
838
+ if (!a || !b) { return false }
839
+ if (a.entryCount !== b.entryCount) { return false }
840
+ for (const key of Object.keys(a.entries)) {
841
+ const ae = a.entries[key]
842
+ const be = b.entries[key]
843
+ if (!be || ae.width !== be.width || ae.height !== be.height) { return false }
844
+ }
845
+ if (a.hasFrameCache !== b.hasFrameCache) { return false }
846
+ if (a.hasFrameCache && b.hasFrameCache) {
847
+ const af = a.frameCache, bf = b.frameCache
848
+ if (!af || !bf || af.top !== bf.top || af.left !== bf.left || af.width !== bf.width || af.height !== bf.height) { return false }
849
+ }
850
+ if (a.hasVirtualFrameCache !== b.hasVirtualFrameCache) { return false }
851
+ if (a.hasVirtualFrameCache && b.hasVirtualFrameCache) {
852
+ const av = a.virtualFrameCache, bv = b.virtualFrameCache
853
+ if (!av || !bv || av.top !== bv.top || av.left !== bv.left || av.width !== bv.width || av.height !== bv.height) { return false }
854
+ }
855
+ return true
856
+ }
857
+
858
+
859
+ // ── Hook: called from layoutSubviews() ───────────────────────────────────
860
+
861
+ /**
862
+ * Called at the top of layoutSubviews(), before the subview frame loop.
863
+ * Nothing to do here — before-frames were already captured in willLayoutView().
864
+ */
865
+ static willSetSubviewFrames(_view: any) {
866
+ // Reserved for future use; before-frames captured in willLayoutView().
867
+ }
868
+
869
+ /**
870
+ * Called at the bottom of layoutSubviews(), after the subview frame loop.
871
+ * Merges the before/after subview frames into the pending step.
872
+ */
873
+ static didSetSubviewFrames(view: any) {
874
+ if (!UILayoutDebugger._isEnabled) { return }
875
+
876
+ const step = UILayoutDebugger._pendingStep
877
+ if (!step) { return }
878
+
879
+ const subviews: any[] = view?.subviews ?? []
880
+ for (let i = 0; i < subviews.length; i++) {
881
+ const sv = subviews[i]
882
+ const idx: number = sv?._UIViewIndex ?? -i
883
+ const record: UILayoutDebugSubviewRecord = {
884
+ viewIndex: idx,
885
+ className: sv?.constructor?.name ?? "UnknownView",
886
+ elementID: sv?.elementID ?? String(idx),
887
+ frameBefore: UILayoutDebugger._pendingSubviewsBefore.get(idx) ?? null,
888
+ frameAfter: UILayoutDebugger._captureFrame(sv),
889
+ }
890
+ step.subviewRecords.push(record)
891
+ }
892
+ }
893
+
894
+
895
+ // ── Breakpoint sentinel ──────────────────────────────────────────────────
896
+
897
+ /**
898
+ * Returns true when breakpoints are enabled, causing the sentinel block
899
+ * in UIView.ts to execute. Put a browser debugger breakpoint on the
900
+ * `const breakpointOnThisLine` assignment inside that block.
901
+ */
902
+ static _shouldHitBreakpoint(_view: any): boolean {
903
+ return UILayoutDebugger._isEnabled && UILayoutDebugger._breakpointsEnabled
904
+ }
905
+
906
+
907
+ // ── Internal helpers ─────────────────────────────────────────────────────
908
+
909
+ static _cleanStack(rawStack: string): string {
910
+ const lines = rawStack.split("\n")
911
+ let firstAppFrameIndex = 1 // skip the "Error" header on line 0
912
+
913
+ for (let i = 1; i < lines.length; i++) {
914
+ const trimmed = lines[i].trim()
915
+
916
+ // V8 frame: "at ClassName.methodName (file:line:col)"
917
+ // or: "at methodName (file:line:col)"
918
+ // or: "at file:line:col"
919
+ // Extract just the function/method name for noise matching.
920
+ const atMatch = trimmed.match(/^at\s+([\w.<>$\s]+?)\s*(?:\(|$)/)
921
+ const frameName = atMatch ? atMatch[1].trim() : trimmed
922
+
923
+ // The method name is the part after the last dot (if any).
924
+ const methodName = frameName.includes(".")
925
+ ? frameName.slice(frameName.lastIndexOf(".") + 1)
926
+ : frameName
927
+
928
+ const isNoise = UILayoutDebugger._noiseFramePrefixes.some(prefix => {
929
+ // Match against the full qualified name OR just the method name,
930
+ // so "setNeedsLayout" catches UITextField.setNeedsLayout too.
931
+ return frameName === prefix || methodName === prefix || frameName.endsWith("." + prefix)
932
+ })
933
+
934
+ if (!isNoise) {
935
+ firstAppFrameIndex = i
936
+ break
937
+ }
938
+ }
939
+ return lines.slice(firstAppFrameIndex).join("\n")
940
+ }
941
+
942
+ static _extractCallerFunctionName(cleanStack: string): string {
943
+ const firstLine = cleanStack.split("\n")[0]?.trim() ?? ""
944
+ // V8: "at ClassName.methodName (file:line:col)"
945
+ const atMatch = firstLine.match(/^at\s+([\w.<>$\s]+?)\s*(?:\(|$)/)
946
+ if (atMatch) { return atMatch[1].trim() }
947
+ return firstLine.substring(0, 80) || "(unknown)"
948
+ }
949
+
950
+ static _captureFrame(view: any): UILayoutDebugFrame | null {
951
+ const f = view?._frame
952
+ if (!f) { return null }
953
+ return {
954
+ top: f.top ?? f.y ?? 0,
955
+ left: f.left ?? f.x ?? 0,
956
+ width: f.width ?? 0,
957
+ height: f.height ?? 0,
958
+ }
959
+ }
960
+
961
+ static _captureCache(view: any): UILayoutDebugCacheSnapshot | null {
962
+ if (!view) { return null }
963
+ const sharedKey: string | undefined = view.sharedIntrinsicSizeCacheIdentifier
964
+ const isShared = !!sharedKey
965
+ let rawEntries: Record<string, any>
966
+ if (isShared) {
967
+ rawEntries = (view.constructor?._sharedIntrinsicSizeCaches ?? view.__proto__?.constructor?._sharedIntrinsicSizeCaches)
968
+ ?.get(sharedKey) ?? {}
969
+ }
970
+ else {
971
+ rawEntries = view._intrinsicSizesCache ?? {}
972
+ }
973
+ const entries: Record<string, { width: number; height: number }> = {}
974
+ for (const key of Object.keys(rawEntries)) {
975
+ const r = rawEntries[key]
976
+ entries[key] = { width: r?.width ?? 0, height: r?.height ?? 0 }
977
+ }
978
+
979
+ // Frame caches — these are per-instance UIRectangle | undefined fields.
980
+ const rawFrameCache = view._frameCache
981
+ const rawVirtualFrameCache = view._frameCacheForVirtualLayouting
982
+ const frameCache: UILayoutDebugFrame | null = rawFrameCache
983
+ ? { top: rawFrameCache.top ?? rawFrameCache.y ?? 0, left: rawFrameCache.left ?? rawFrameCache.x ?? 0, width: rawFrameCache.width ?? 0, height: rawFrameCache.height ?? 0 }
984
+ : null
985
+ const virtualFrameCache: UILayoutDebugFrame | null = rawVirtualFrameCache
986
+ ? { top: rawVirtualFrameCache.top ?? rawVirtualFrameCache.y ?? 0, left: rawVirtualFrameCache.left ?? rawVirtualFrameCache.x ?? 0, width: rawVirtualFrameCache.width ?? 0, height: rawVirtualFrameCache.height ?? 0 }
987
+ : null
988
+
989
+ return {
990
+ entryCount: Object.keys(entries).length,
991
+ entries,
992
+ isShared,
993
+ sharedKey,
994
+ hasFrameCache: rawFrameCache !== undefined,
995
+ frameCache,
996
+ hasVirtualFrameCache: rawVirtualFrameCache !== undefined,
997
+ virtualFrameCache,
998
+ }
999
+ }
1000
+
1001
+ static _buildTreeSnapshot(
1002
+ view: any,
1003
+ depth: number,
1004
+ visited: Set<number> = new Set(),
1005
+ ): UILayoutDebugTreeNode {
1006
+ const idx: number = view?._UIViewIndex ?? -1
1007
+ const node: UILayoutDebugTreeNode = {
1008
+ viewIndex: idx,
1009
+ className: view?.constructor?.name ?? "UnknownView",
1010
+ elementID: view?.elementID ?? String(idx),
1011
+ depth,
1012
+ frame: UILayoutDebugger._captureFrame(view),
1013
+ layoutCount: UILayoutDebugger._layoutCountsThisPass.get(idx) ?? 0,
1014
+ cacheAfterPass: UILayoutDebugger._captureCache(view),
1015
+ children: [],
1016
+ }
1017
+ const subviews: any[] = view?.subviews ?? []
1018
+ for (let i = 0; i < subviews.length; i++) {
1019
+ const sv = subviews[i]
1020
+ const svIdx: number = sv?._UIViewIndex ?? -1
1021
+ if (svIdx < 0 || visited.has(svIdx)) { continue }
1022
+ visited.add(svIdx)
1023
+ node.children.push(UILayoutDebugger._buildTreeSnapshot(sv, depth + 1, visited))
1024
+ }
1025
+ return node
1026
+ }
1027
+
1028
+
1029
+ // ── Baseline / diff state ─────────────────────────────────────────────────
1030
+
1031
+ static _baseline: UILayoutDebugStateSnapshot | null = null
1032
+ static _diffSnapshot: UILayoutDebugStateSnapshot | null = null
1033
+ static _diffMode: boolean = false
1034
+ static _liveInspectorMode: boolean = false
1035
+
1036
+ // ── Stale layout report state ─────────────────────────────────────────────
1037
+
1038
+ /** Result of the last captureStaleLayoutReport() run. */
1039
+ static _staleReportResult: {
1040
+ before: UILayoutDebugStateSnapshot
1041
+ after: UILayoutDebugStateSnapshot
1042
+ diffs: UILayoutDebugViewDiff[]
1043
+ /** viewIndex → cacheChanges from the forced-layout pass, for cross-referencing */
1044
+ forcedPassCacheChanges: Map<number, UILayoutDebugCacheChangeEvent[]>
1045
+ passIndex: number
1046
+ } | null = null
1047
+
1048
+ /** Whether the stale report side-panel is open. */
1049
+ static _staleReportMode: boolean = false
1050
+
1051
+ // Persists across passes so captureBaseline() works between passes.
1052
+ static _lastKnownRootView: any = null
1053
+
1054
+ /**
1055
+ * Finds the first trace (chronologically) recorded after baselineTakenAt
1056
+ * that contains a step for the given viewIndex. Returns {traceIndex, stepIndex}
1057
+ * into _traces, or null if none found.
1058
+ */
1059
+ static _findCausingTrace(
1060
+ viewIndex: number,
1061
+ baselineTakenAt: number,
1062
+ ): { traceIndex: number; stepIndex: number; passIndex: number } | null {
1063
+ // _traces is newest-first, so iterate in reverse for chronological order
1064
+ for (let ti = UILayoutDebugger._traces.length - 1; ti >= 0; ti--) {
1065
+ const trace = UILayoutDebugger._traces[ti]
1066
+ // We don't store a timestamp on traces, but passIndex is monotonically
1067
+ // increasing. The baseline was taken at a wall-clock time; the closest
1068
+ // proxy is to find traces whose passIndex is greater than any pass that
1069
+ // completed before the baseline. Since we can't correlate exactly, we
1070
+ // just find the first trace (oldest) that touched the view and show it.
1071
+ const si = trace.steps.findIndex(s => s.viewIndex === viewIndex)
1072
+ if (si >= 0) {
1073
+ return { traceIndex: ti, stepIndex: si, passIndex: trace.passIndex }
1074
+ }
1075
+ }
1076
+ return null
1077
+ }
1078
+
1079
+ // ── Overlay UI ───────────────────────────────────────────────────────────
1080
+
1081
+ static _overlayRoot: HTMLElement | null = null
1082
+ static _overlayVisible: boolean = true
1083
+ static _helpMode: boolean = false
1084
+
1085
+ static _ensureOverlay() {
1086
+ if (UILayoutDebugger._overlayRoot) { return }
1087
+
1088
+ const root = document.createElement("div")
1089
+ root.id = "__UILayoutDebugger_overlay"
1090
+ root.style.cssText = [
1091
+ "position: fixed",
1092
+ "top: 8px",
1093
+ "right: 8px",
1094
+ "max-height: calc(100vh - 16px)",
1095
+ "background: rgba(15, 15, 20, 0.96)",
1096
+ "color: #e8e8e8",
1097
+ "font: 11px/1.4 'SF Mono', 'Menlo', 'Consolas', monospace",
1098
+ "border-radius: 8px",
1099
+ "border: 1px solid rgba(255,255,255,0.12)",
1100
+ "box-shadow: 0 8px 32px rgba(0,0,0,0.6)",
1101
+ "z-index: 2147483647",
1102
+ "display: flex",
1103
+ "flex-direction: column",
1104
+ "overflow: hidden",
1105
+ "user-select: none",
1106
+ ].join("; ")
1107
+
1108
+ document.body.appendChild(root)
1109
+ UILayoutDebugger._overlayRoot = root
1110
+ UILayoutDebugger._makeDraggable(root)
1111
+ }
1112
+
1113
+ static _removeOverlay() {
1114
+ UILayoutDebugger._overlayRoot?.remove()
1115
+ UILayoutDebugger._overlayRoot = null
1116
+ }
1117
+
1118
+ static _renderOverlay() {
1119
+ const root = UILayoutDebugger._overlayRoot
1120
+ if (!root) { return }
1121
+
1122
+ const cmp = UILayoutDebugger._compareMode
1123
+ const diff = UILayoutDebugger._diffMode && !!UILayoutDebugger._baseline && !!UILayoutDebugger._diffSnapshot
1124
+ const live = UILayoutDebugger._liveInspectorMode && !!UILayoutDebugger._lastKnownRootView
1125
+ const stale = UILayoutDebugger._staleReportMode && !!UILayoutDebugger._staleReportResult
1126
+ const passWidth = cmp ? 1140 : 570
1127
+ const extraWidth = (diff ? 320 : 0) + (live ? 320 : 0) + (stale ? 360 : 0)
1128
+ root.style.width = (passWidth + extraWidth) + "px"
1129
+ root.innerHTML = ""
1130
+
1131
+ // ── Header ──────────────────────────────────────────────────────────
1132
+ // ── Header row 1: title + core controls ─────────────────────────────
1133
+ const headerRow1 = UILayoutDebugger._el("div", [
1134
+ "padding: 8px 10px 5px",
1135
+ "background: rgba(255,255,255,0.05)",
1136
+ "display: flex",
1137
+ "align-items: center",
1138
+ "gap: 6px",
1139
+ "cursor: move",
1140
+ "flex-shrink: 0",
1141
+ ])
1142
+ headerRow1.dataset.dragHandle = "1"
1143
+
1144
+ const title = UILayoutDebugger._el("span", ["flex: 1", "font-weight: bold", "font-size: 11px", "color: #c8d8ff"])
1145
+ title.textContent = "⚙ UILayoutDebugger"
1146
+
1147
+ const helpBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle(
1148
+ UILayoutDebugger._helpMode ? "#ffcc88" : "#9090a8"
1149
+ ))
1150
+ helpBtn.textContent = "ⓘ"
1151
+ helpBtn.title = "Show help"
1152
+ helpBtn.onclick = () => {
1153
+ UILayoutDebugger._helpMode = !UILayoutDebugger._helpMode
1154
+ UILayoutDebugger._renderOverlay()
1155
+ }
1156
+
1157
+ const bpBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle(
1158
+ UILayoutDebugger._breakpointsEnabled ? "#ffaa33" : "#9090a8"
1159
+ ))
1160
+ bpBtn.textContent = UILayoutDebugger._breakpointsEnabled ? "⏸ BP ON" : "⏸ BP OFF"
1161
+ bpBtn.title = "Toggle breakpoint step-through"
1162
+ bpBtn.onclick = () => {
1163
+ UILayoutDebugger._breakpointsEnabled
1164
+ ? UILayoutDebugger.disableBreakpoints()
1165
+ : UILayoutDebugger.enableBreakpoints()
1166
+ UILayoutDebugger._renderOverlay()
1167
+ }
1168
+
1169
+ const cmpBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle(cmp ? "#7bc8ff" : "#9090a8"))
1170
+ cmpBtn.textContent = cmp ? "⧉ Compare ON" : "⧉ Compare"
1171
+ cmpBtn.title = "Toggle side-by-side pass comparison"
1172
+ cmpBtn.onclick = () => {
1173
+ UILayoutDebugger._compareMode = !UILayoutDebugger._compareMode
1174
+ if (UILayoutDebugger._compareMode) {
1175
+ UILayoutDebugger._compareTraceIndex = Math.min(1, UILayoutDebugger._traces.length - 1)
1176
+ UILayoutDebugger._compareStepIndex = -1
1177
+ UILayoutDebugger._sharedExpandState = new Map()
1178
+ }
1179
+ UILayoutDebugger._renderOverlay()
1180
+ }
1181
+
1182
+ const filterLabels: Record<string, string> = {
1183
+ all: "⊘ All",
1184
+ changed: "⊘ Changed",
1185
+ unchanged: "⊘ Unchanged",
1186
+ }
1187
+ const filterColors: Record<string, string> = {
1188
+ all: "#9090a8",
1189
+ changed: "#7bc8ff",
1190
+ unchanged: "#ffcc88",
1191
+ }
1192
+ const filterCycle: Record<string, "all" | "changed" | "unchanged"> = {
1193
+ all: "changed", changed: "unchanged", unchanged: "all",
1194
+ }
1195
+ const filterBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle(
1196
+ filterColors[UILayoutDebugger._frameFilter]
1197
+ ))
1198
+ filterBtn.textContent = filterLabels[UILayoutDebugger._frameFilter]
1199
+ filterBtn.title = "Cycle: show all steps → only changed frames → only unchanged frames"
1200
+ filterBtn.onclick = () => {
1201
+ UILayoutDebugger._frameFilter = filterCycle[UILayoutDebugger._frameFilter]
1202
+ UILayoutDebugger._replayStepIndex = -1
1203
+ UILayoutDebugger._compareStepIndex = -1
1204
+ UILayoutDebugger._renderOverlay()
1205
+ }
1206
+
1207
+ const clearBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle("#9090a8"))
1208
+ clearBtn.textContent = "⌫ Clear"
1209
+ clearBtn.title = "Clear all recorded traces and restart recording"
1210
+ clearBtn.onclick = () => UILayoutDebugger.clearTraces()
1211
+
1212
+ const toggleBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle("#9090a8"))
1213
+ toggleBtn.textContent = UILayoutDebugger._overlayVisible ? "▾" : "▸"
1214
+ toggleBtn.title = "Collapse / expand"
1215
+ toggleBtn.onclick = () => {
1216
+ UILayoutDebugger._overlayVisible = !UILayoutDebugger._overlayVisible
1217
+ UILayoutDebugger._renderOverlay()
1218
+ }
1219
+
1220
+ const closeBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle("#dd5555"))
1221
+ closeBtn.textContent = "✕"
1222
+ closeBtn.title = "Disable UILayoutDebugger"
1223
+ closeBtn.onclick = () => UILayoutDebugger.disable()
1224
+
1225
+ headerRow1.append(title, helpBtn, bpBtn, cmpBtn, filterBtn, clearBtn, toggleBtn, closeBtn)
1226
+
1227
+ // ── Header row 2: baseline / diff and future feature buttons ─────────
1228
+ const headerRow2 = UILayoutDebugger._el("div", [
1229
+ "padding: 4px 10px 6px",
1230
+ "background: rgba(255,255,255,0.05)",
1231
+ "border-bottom: 1px solid rgba(255,255,255,0.10)",
1232
+ "display: flex",
1233
+ "align-items: center",
1234
+ "gap: 6px",
1235
+ "flex-shrink: 0",
1236
+ "flex-wrap: wrap",
1237
+ ])
1238
+ headerRow2.dataset.dragHandle = "1"
1239
+
1240
+ const hasBaseline = !!UILayoutDebugger._baseline
1241
+ const baselineBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle(
1242
+ UILayoutDebugger._diffMode ? "#88ddff" : hasBaseline ? "#ffcc88" : "#9090a8"
1243
+ ))
1244
+ baselineBtn.textContent = UILayoutDebugger._diffMode
1245
+ ? "⊕ Diff ON"
1246
+ : hasBaseline ? "📍 Baseline set" : "📍 Baseline"
1247
+ baselineBtn.title = hasBaseline
1248
+ ? "Baseline captured — click to recapture, or use ⊕ to diff against it"
1249
+ : "Capture current view tree state as baseline"
1250
+ baselineBtn.onclick = () => {
1251
+ if (UILayoutDebugger._diffMode) {
1252
+ UILayoutDebugger.clearDiff()
1253
+ } else {
1254
+ UILayoutDebugger.captureBaseline()
1255
+ }
1256
+ }
1257
+
1258
+ const diffBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle(
1259
+ hasBaseline ? "#88ff99" : "#9090a8"
1260
+ )) as HTMLButtonElement
1261
+ diffBtn.textContent = "⊕ Diff"
1262
+ diffBtn.title = "Capture current state and diff against baseline"
1263
+ diffBtn.disabled = !hasBaseline
1264
+ diffBtn.onclick = () => UILayoutDebugger.captureAndDiff()
1265
+
1266
+ const liveBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle(
1267
+ live ? "#88ff99" : "#9090a8"
1268
+ ))
1269
+ liveBtn.textContent = live ? "👁 Live ON" : "👁 Live"
1270
+ liveBtn.title = "Show live view tree with current frames and cache state"
1271
+ liveBtn.onclick = () => UILayoutDebugger.toggleLiveInspector()
1272
+
1273
+ const hasStaleResult = !!UILayoutDebugger._staleReportResult
1274
+ const staleBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle(
1275
+ stale ? "#ffaa55" : hasStaleResult ? "#a06030" : "#9090a8"
1276
+ ))
1277
+ staleBtn.textContent = stale ? "☢ Stale ON" : "☢ Stale"
1278
+ staleBtn.title = hasStaleResult
1279
+ ? "Stale layout report available — click to toggle the panel, or re-run to refresh"
1280
+ : "Run a forced full-tree remeasure and show which views had stale/missing invalidations"
1281
+ staleBtn.onclick = () => {
1282
+ if (stale) {
1283
+ // Panel already open — toggle it off
1284
+ UILayoutDebugger._staleReportMode = false
1285
+ UILayoutDebugger._renderOverlay()
1286
+ }
1287
+ else if (hasStaleResult) {
1288
+ // Result exists but panel is closed — reopen it
1289
+ UILayoutDebugger._staleReportMode = true
1290
+ UILayoutDebugger._renderOverlay()
1291
+ }
1292
+ else {
1293
+ UILayoutDebugger.captureStaleLayoutReport()
1294
+ }
1295
+ }
1296
+
1297
+ // Long-press / right-click to re-run even when a result already exists
1298
+ staleBtn.oncontextmenu = (e) => {
1299
+ e.preventDefault()
1300
+ UILayoutDebugger.captureStaleLayoutReport()
1301
+ }
1302
+
1303
+ const boundsToggleBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle(
1304
+ UILayoutDebugger._boundsBasedDiff ? "#c8d8ff" : "#9090a8"
1305
+ ))
1306
+ boundsToggleBtn.textContent = UILayoutDebugger._boundsBasedDiff ? "⬚ Bounds" : "⬚ Frame"
1307
+ boundsToggleBtn.title = UILayoutDebugger._boundsBasedDiff
1308
+ ? "Diffing by bounds (size only — origin ignored). Click to switch to frame diffing (position + size)."
1309
+ : "Diffing by frame (position + size). Click to switch to bounds diffing (size only — position changes are ignored)."
1310
+ boundsToggleBtn.onclick = () => {
1311
+ UILayoutDebugger._boundsBasedDiff = !UILayoutDebugger._boundsBasedDiff
1312
+ UILayoutDebugger._renderOverlay()
1313
+ }
1314
+
1315
+ headerRow2.append(baselineBtn, diffBtn, liveBtn, staleBtn, boundsToggleBtn)
1316
+
1317
+ // ── Wrap both rows in the header container ────────────────────────────
1318
+ const header = UILayoutDebugger._el("div", ["flex-shrink: 0"])
1319
+ header.append(headerRow1, headerRow2)
1320
+ root.appendChild(header)
1321
+
1322
+ if (!UILayoutDebugger._overlayVisible) { return }
1323
+
1324
+ // ── Help panel ───────────────────────────────────────────────────────
1325
+ if (UILayoutDebugger._helpMode) {
1326
+ root.appendChild(UILayoutDebugger._renderHelpPanel())
1327
+ return
1328
+ }
1329
+
1330
+ // ── Baseline captured but no diff yet ────────────────────────────────
1331
+ if (UILayoutDebugger._baseline && !diff) {
1332
+ const msg = UILayoutDebugger._el("div", [
1333
+ "padding: 8px 12px",
1334
+ "color: #ffcc88",
1335
+ "font-size: 10px",
1336
+ "border-bottom: 1px solid rgba(255,255,255,0.08)",
1337
+ "flex-shrink: 0",
1338
+ ])
1339
+ const ts = new Date(UILayoutDebugger._baseline.takenAt)
1340
+ msg.textContent = `📍 Baseline set at ${ts.toLocaleTimeString()} — ${UILayoutDebugger._baseline.views.size} views. Hit ⊕ Diff to compare.`
1341
+ root.appendChild(msg)
1342
+ }
1343
+
1344
+ if (UILayoutDebugger._traces.length === 0 && !diff) {
1345
+ const msg = UILayoutDebugger._el("div", ["padding: 10px 12px", "color: #9090a8", "font-size: 10px"])
1346
+ msg.textContent = "No layout pass recorded yet. Trigger a layout to begin."
1347
+ root.appendChild(msg)
1348
+ return
1349
+ }
1350
+
1351
+ // ── Main body: pass inspector + optional diff panel ──────────────────
1352
+ const body = UILayoutDebugger._el("div", [
1353
+ "display: flex",
1354
+ "flex: 1",
1355
+ "overflow: hidden",
1356
+ "min-height: 0",
1357
+ ])
1358
+ root.appendChild(body)
1359
+
1360
+ // ── Pass inspector (single or compare columns) ────────────────────────
1361
+ if (UILayoutDebugger._traces.length > 0) {
1362
+ const passSection = UILayoutDebugger._el("div", [
1363
+ "display: flex",
1364
+ "flex: 1",
1365
+ "overflow: hidden",
1366
+ "min-height: 0",
1367
+ ])
1368
+
1369
+ if (cmp) {
1370
+ let leftTreeEl: HTMLElement | null = null
1371
+ let rightTreeEl: HTMLElement | null = null
1372
+
1373
+ const leftCol = UILayoutDebugger._renderPassColumn(
1374
+ UILayoutDebugger._replayTraceIndex,
1375
+ UILayoutDebugger._replayStepIndex,
1376
+ (si) => { UILayoutDebugger._replayStepIndex = si; UILayoutDebugger._renderOverlay() },
1377
+ (ti) => { UILayoutDebugger._replayTraceIndex = ti; UILayoutDebugger._replayStepIndex = -1; UILayoutDebugger._renderOverlay() },
1378
+ UILayoutDebugger._sharedExpandState,
1379
+ (el) => { leftTreeEl = el },
1380
+ () => rightTreeEl,
1381
+ )
1382
+ const colDivider = UILayoutDebugger._el("div", [
1383
+ "width: 1px", "background: rgba(255,255,255,0.10)", "flex-shrink: 0",
1384
+ ])
1385
+ const rightCol = UILayoutDebugger._renderPassColumn(
1386
+ UILayoutDebugger._compareTraceIndex,
1387
+ UILayoutDebugger._compareStepIndex,
1388
+ (si) => { UILayoutDebugger._compareStepIndex = si; UILayoutDebugger._renderOverlay() },
1389
+ (ti) => { UILayoutDebugger._compareTraceIndex = ti; UILayoutDebugger._compareStepIndex = -1; UILayoutDebugger._renderOverlay() },
1390
+ UILayoutDebugger._sharedExpandState,
1391
+ (el) => { rightTreeEl = el },
1392
+ () => leftTreeEl,
1393
+ )
1394
+ passSection.append(leftCol, colDivider, rightCol)
1395
+ }
1396
+ else {
1397
+ const col = UILayoutDebugger._renderPassColumn(
1398
+ UILayoutDebugger._replayTraceIndex,
1399
+ UILayoutDebugger._replayStepIndex,
1400
+ (si) => { UILayoutDebugger._replayStepIndex = si; UILayoutDebugger._renderOverlay() },
1401
+ (ti) => { UILayoutDebugger._replayTraceIndex = ti; UILayoutDebugger._replayStepIndex = -1; UILayoutDebugger._renderOverlay() },
1402
+ UILayoutDebugger._singleExpandState,
1403
+ () => {},
1404
+ () => null,
1405
+ )
1406
+ col.style.flex = "1"
1407
+ passSection.appendChild(col)
1408
+ }
1409
+
1410
+ body.appendChild(passSection)
1411
+ }
1412
+
1413
+ // ── Diff panel ────────────────────────────────────────────────────────
1414
+ if (diff) {
1415
+ const divider = UILayoutDebugger._el("div", [
1416
+ "width: 1px", "background: rgba(255,255,255,0.10)", "flex-shrink: 0",
1417
+ ])
1418
+ const diffPanel = UILayoutDebugger._renderDiffPanel(
1419
+ UILayoutDebugger._baseline!,
1420
+ UILayoutDebugger._diffSnapshot!,
1421
+ (viewIndex) => {
1422
+ for (let ti = 0; ti < UILayoutDebugger._traces.length; ti++) {
1423
+ const trace = UILayoutDebugger._traces[ti]
1424
+ const si = trace.steps.findIndex(s => s.viewIndex === viewIndex)
1425
+ if (si >= 0) {
1426
+ UILayoutDebugger._replayTraceIndex = ti
1427
+ UILayoutDebugger._replayStepIndex = si
1428
+ UILayoutDebugger._renderOverlay()
1429
+ return
1430
+ }
1431
+ }
1432
+ },
1433
+ UILayoutDebugger._baseline!.takenAt,
1434
+ )
1435
+ diffPanel.style.width = "320px"
1436
+ diffPanel.style.flexShrink = "0"
1437
+ body.append(divider, diffPanel)
1438
+ }
1439
+
1440
+ // ── Live inspector panel ──────────────────────────────────────────────
1441
+ if (live) {
1442
+ const divider = UILayoutDebugger._el("div", [
1443
+ "width: 1px", "background: rgba(255,255,255,0.10)", "flex-shrink: 0",
1444
+ ])
1445
+ const livePanel = UILayoutDebugger._renderLiveInspectorPanel()
1446
+ livePanel.style.width = "320px"
1447
+ livePanel.style.flexShrink = "0"
1448
+ body.append(divider, livePanel)
1449
+ }
1450
+
1451
+ // ── Stale layout report panel ─────────────────────────────────────────
1452
+ if (stale) {
1453
+ const divider = UILayoutDebugger._el("div", [
1454
+ "width: 1px", "background: rgba(255,255,255,0.10)", "flex-shrink: 0",
1455
+ ])
1456
+ const stalePanel = UILayoutDebugger._renderStaleReportPanel(UILayoutDebugger._staleReportResult!)
1457
+ stalePanel.style.width = "360px"
1458
+ stalePanel.style.flexShrink = "0"
1459
+ body.append(divider, stalePanel)
1460
+ }
1461
+ }
1462
+
1463
+ static _renderPassColumn(
1464
+ traceIndex: number,
1465
+ stepIndex: number,
1466
+ onStepChange: (si: number) => void,
1467
+ onTraceChange: (ti: number) => void,
1468
+ expandState: Map<number, boolean> | null,
1469
+ registerTree: (el: HTMLElement) => void,
1470
+ getPeerTree: () => HTMLElement | null,
1471
+ ): HTMLElement {
1472
+ const col = UILayoutDebugger._el("div", [
1473
+ "display: flex",
1474
+ "flex-direction: column",
1475
+ "flex: 1",
1476
+ "min-width: 0",
1477
+ "overflow: hidden",
1478
+ ])
1479
+
1480
+ const trace = UILayoutDebugger._traces[traceIndex] ?? null
1481
+
1482
+ // ── Trace picker ─────────────────────────────────────────────────────
1483
+ const pickerRow = UILayoutDebugger._el("div", [
1484
+ "padding: 5px 10px",
1485
+ "border-bottom: 1px solid rgba(255,255,255,0.08)",
1486
+ "display: flex",
1487
+ "align-items: center",
1488
+ "gap: 6px",
1489
+ "flex-shrink: 0",
1490
+ "font-size: 10px",
1491
+ ])
1492
+ const pickerLabel = UILayoutDebugger._el("span", ["color: #a0a0b8", "flex-shrink: 0"])
1493
+ pickerLabel.textContent = "Pass:"
1494
+
1495
+ const sel = document.createElement("select")
1496
+ sel.style.cssText = [
1497
+ "flex: 1",
1498
+ "background: #1e1e2e",
1499
+ "color: #d8d8f0",
1500
+ "border: 1px solid #444",
1501
+ "border-radius: 3px",
1502
+ "font: inherit",
1503
+ "padding: 1px 4px",
1504
+ ].join("; ")
1505
+
1506
+ for (let i = 0; i < UILayoutDebugger._traces.length; i++) {
1507
+ const t = UILayoutDebugger._traces[i]
1508
+ const opt = document.createElement("option")
1509
+ opt.value = String(i)
1510
+ opt.textContent = `#${t.passIndex} (${t.steps.length} steps, ${t.totalIterations} iter)`
1511
+ if (i === traceIndex) { opt.selected = true }
1512
+ sel.appendChild(opt)
1513
+ }
1514
+ sel.onchange = () => onTraceChange(parseInt(sel.value, 10))
1515
+ pickerRow.append(pickerLabel, sel)
1516
+ col.appendChild(pickerRow)
1517
+
1518
+ if (!trace) { return col }
1519
+
1520
+ // Apply frame filter to produce the visible step list.
1521
+ const frameFilter = UILayoutDebugger._frameFilter
1522
+ const visibleSteps = frameFilter === "all"
1523
+ ? trace.steps
1524
+ : trace.steps.filter(s => {
1525
+ const f = s.frameBefore
1526
+ const g = s.frameAfter
1527
+ const changed = !f || !g
1528
+ || f.left !== g.left || f.top !== g.top
1529
+ || f.width !== g.width || f.height !== g.height
1530
+ return frameFilter === "changed" ? changed : !changed
1531
+ })
1532
+
1533
+ const clampedStep = Math.max(-1, Math.min(stepIndex, visibleSteps.length - 1))
1534
+ const activeStep = visibleSteps[clampedStep] ?? null
1535
+ const activeViewIndex = activeStep?.viewIndex ?? -1
1536
+
1537
+ // countMap still uses all steps for heat colouring (unfiltered).
1538
+ // stepMap stores the last step per viewIndex for frame/cache delta display.
1539
+ const countMap = new Map<number, number>()
1540
+ const stepMap = new Map<number, UILayoutDebugStep>()
1541
+ for (const step of trace.steps) {
1542
+ countMap.set(step.viewIndex, (countMap.get(step.viewIndex) ?? 0) + 1)
1543
+ stepMap.set(step.viewIndex, step)
1544
+ }
1545
+
1546
+ // ── Step controls ─────────────────────────────────────────────────────
1547
+ const stepBar = UILayoutDebugger._el("div", [
1548
+ "padding: 5px 10px",
1549
+ "border-bottom: 1px solid rgba(255,255,255,0.08)",
1550
+ "display: flex",
1551
+ "align-items: center",
1552
+ "gap: 6px",
1553
+ "flex-shrink: 0",
1554
+ ])
1555
+
1556
+ const backBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle("#59599b")) as HTMLButtonElement
1557
+ backBtn.textContent = "◀"
1558
+ backBtn.title = "Step back"
1559
+ backBtn.disabled = clampedStep <= -1
1560
+ backBtn.onclick = () => onStepChange(Math.max(clampedStep - 1, -1))
1561
+
1562
+ const fwdBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle("#59599b")) as HTMLButtonElement
1563
+ fwdBtn.textContent = "▶"
1564
+ fwdBtn.title = "Step forward"
1565
+ fwdBtn.disabled = clampedStep >= visibleSteps.length - 1
1566
+ fwdBtn.onclick = () => onStepChange(Math.min(clampedStep + 1, visibleSteps.length - 1))
1567
+
1568
+ const slider = document.createElement("input")
1569
+ slider.type = "range"
1570
+ slider.min = "-1"
1571
+ slider.max = String(visibleSteps.length - 1)
1572
+ slider.value = String(clampedStep)
1573
+ slider.style.cssText = "flex: 1; cursor: pointer; accent-color: #59599b;"
1574
+ slider.oninput = () => onStepChange(parseInt(slider.value, 10))
1575
+
1576
+ const stepLabel = UILayoutDebugger._el("span", ["color: #b0b0c8", "font-size: 10px", "white-space: nowrap"])
1577
+ const totalLabel = frameFilter === "all"
1578
+ ? String(visibleSteps.length)
1579
+ : `${visibleSteps.length}/${trace.steps.length}`
1580
+ stepLabel.textContent = clampedStep < 0
1581
+ ? `— / ${totalLabel}`
1582
+ : `${clampedStep + 1} / ${totalLabel}`
1583
+
1584
+ stepBar.append(backBtn, slider, fwdBtn, stepLabel)
1585
+ col.appendChild(stepBar)
1586
+
1587
+ // ── Active step detail ────────────────────────────────────────────────
1588
+ if (activeStep) {
1589
+ col.appendChild(UILayoutDebugger._renderStepDetail(activeStep))
1590
+ }
1591
+
1592
+ // ── View tree ─────────────────────────────────────────────────────────
1593
+ const treeContainer = UILayoutDebugger._el("div", [
1594
+ "overflow-y: scroll",
1595
+ "flex: 1",
1596
+ "padding: 4px 0",
1597
+ "min-height: 0",
1598
+ "position: relative",
1599
+ ])
1600
+ registerTree(treeContainer)
1601
+
1602
+ treeContainer.addEventListener("scroll", () => {
1603
+ const peer = getPeerTree()
1604
+ if (peer && peer.scrollTop !== treeContainer.scrollTop) {
1605
+ peer.scrollTop = treeContainer.scrollTop
1606
+ }
1607
+ })
1608
+
1609
+ if (trace.roots.length > 0) {
1610
+ let activeRow: HTMLElement | null = null
1611
+ for (const treeRoot of trace.roots) {
1612
+ const result = UILayoutDebugger._renderTreeNode(
1613
+ treeRoot, treeContainer, countMap, activeViewIndex, expandState, stepMap
1614
+ )
1615
+ if (result) { activeRow = result }
1616
+ }
1617
+ // Scroll the active row into view after the DOM is fully built.
1618
+ // Use a 0ms timeout so the browser has laid out the container first.
1619
+ if (activeRow) {
1620
+ const rowRef = activeRow
1621
+ const containerRef = treeContainer
1622
+ setTimeout(() => {
1623
+ const rowTop = rowRef.offsetTop
1624
+ const rowBottom = rowTop + rowRef.offsetHeight
1625
+ const visTop = containerRef.scrollTop
1626
+ const visBottom = visTop + containerRef.clientHeight
1627
+ if (rowTop < visTop || rowBottom > visBottom) {
1628
+ containerRef.scrollTop = rowTop - containerRef.clientHeight / 2
1629
+ }
1630
+ }, 0)
1631
+ }
1632
+ }
1633
+ else {
1634
+ const msg = UILayoutDebugger._el("div", ["padding: 8px 10px", "color: #6a6a80"])
1635
+ msg.textContent = "No steps recorded in this pass."
1636
+ treeContainer.appendChild(msg)
1637
+ }
1638
+
1639
+ col.appendChild(treeContainer)
1640
+
1641
+ // ── Cache change events ───────────────────────────────────────────────
1642
+ if (trace.cacheChanges.length > 0) {
1643
+ let cacheListExpanded = false
1644
+
1645
+ const cacheHeader = UILayoutDebugger._el("div", [
1646
+ "padding: 5px 10px",
1647
+ "border-top: 1px solid rgba(255,255,255,0.08)",
1648
+ "display: flex",
1649
+ "align-items: center",
1650
+ "gap: 5px",
1651
+ "flex-shrink: 0",
1652
+ "cursor: pointer",
1653
+ "font-size: 10px",
1654
+ ])
1655
+
1656
+ const cacheChevron = UILayoutDebugger._el("span", ["color: #7070a0", "font-size: 8px", "width: 10px"])
1657
+ cacheChevron.textContent = "▸"
1658
+
1659
+ const cacheTitle = UILayoutDebugger._el("span", ["color: #a0a0b8"])
1660
+ cacheTitle.textContent = `Cache writes (${trace.cacheChanges.length})`
1661
+
1662
+ cacheHeader.append(cacheChevron, cacheTitle)
1663
+ col.appendChild(cacheHeader)
1664
+
1665
+ const cacheList = UILayoutDebugger._el("div", [
1666
+ "display: none",
1667
+ "overflow-y: auto",
1668
+ "max-height: 180px",
1669
+ "flex-shrink: 0",
1670
+ "font-size: 10px",
1671
+ "padding: 2px 0",
1672
+ ])
1673
+
1674
+ for (const ev of trace.cacheChanges) {
1675
+ const evRow = UILayoutDebugger._el("div", [
1676
+ "padding: 2px 10px 2px 14px",
1677
+ "display: flex",
1678
+ "flex-direction: column",
1679
+ "gap: 1px",
1680
+ "border-bottom: 1px solid rgba(255,255,255,0.04)",
1681
+ ])
1682
+
1683
+ const topLine = UILayoutDebugger._el("div", ["display: flex", "gap: 5px", "align-items: baseline"])
1684
+
1685
+ const evIdx = UILayoutDebugger._el("span", ["color: #5a5a70", "flex-shrink: 0"])
1686
+ evIdx.textContent = `#${ev.eventIndex}`
1687
+
1688
+ const evClass = UILayoutDebugger._el("span", ["color: #ffcc88", "font-weight: bold"])
1689
+ evClass.textContent = ev.className
1690
+
1691
+ const evEid = UILayoutDebugger._el("span", ["color: #6a6a80"])
1692
+ evEid.textContent = `#${ev.elementID}`
1693
+
1694
+ const evKey = UILayoutDebugger._el("span", ["color: #9090a8"])
1695
+ const match = ev.cacheKey.match(/h_(\d+(?:\.\d+)?)__w_(\d+(?:\.\d+)?)/)
1696
+ evKey.textContent = match
1697
+ ? (match[1] !== "0" && match[2] !== "0"
1698
+ ? `h≤${match[1]} w≤${match[2]}`
1699
+ : match[2] !== "0" ? `w≤${match[2]}` : `h≤${match[1]}`)
1700
+ : ev.cacheKey
1701
+
1702
+ const evVal = UILayoutDebugger._el("span", ["color: #88ddff", "margin-left: auto"])
1703
+ evVal.textContent = `${ev.newValue.width.toFixed(0)}×${ev.newValue.height.toFixed(0)}`
1704
+
1705
+ topLine.append(evIdx, evClass, evEid, evKey, evVal)
1706
+
1707
+ const callerLine = UILayoutDebugger._el("div", [
1708
+ "display: flex", "gap: 5px", "align-items: baseline",
1709
+ ])
1710
+ const stepRef = UILayoutDebugger._el("span", ["color: #5a5a70", "flex-shrink: 0"])
1711
+ stepRef.textContent = ev.stepIndex >= 0 ? `step ${ev.stepIndex}` : "between steps"
1712
+
1713
+ const callerFn = UILayoutDebugger._el("span", ["color: #7878a0", "cursor: pointer"])
1714
+ callerFn.textContent = ev.callerFunction + "()"
1715
+ callerFn.title = ev.cleanStack
1716
+
1717
+ let stackExpanded = false
1718
+ const stackEl = UILayoutDebugger._el("div", [
1719
+ "display: none",
1720
+ "margin-top: 2px",
1721
+ "padding: 3px 6px",
1722
+ "background: rgba(255,255,255,0.04)",
1723
+ "border-radius: 3px",
1724
+ "color: #6060808",
1725
+ "font-size: 9px",
1726
+ "white-space: pre",
1727
+ "overflow-x: auto",
1728
+ ])
1729
+ stackEl.textContent = ev.cleanStack
1730
+ callerFn.onclick = () => {
1731
+ stackExpanded = !stackExpanded
1732
+ stackEl.style.display = stackExpanded ? "block" : "none"
1733
+ }
1734
+
1735
+ callerLine.append(stepRef, callerFn)
1736
+ evRow.append(topLine, callerLine, stackEl)
1737
+ cacheList.appendChild(evRow)
1738
+ }
1739
+
1740
+ cacheHeader.onclick = () => {
1741
+ cacheListExpanded = !cacheListExpanded
1742
+ cacheList.style.display = cacheListExpanded ? "block" : "none"
1743
+ cacheChevron.textContent = cacheListExpanded ? "▾" : "▸"
1744
+ }
1745
+
1746
+ col.appendChild(cacheList)
1747
+ }
1748
+
1749
+ return col
1750
+ }
1751
+
1752
+ static _renderStaleReportPanel(result: NonNullable<typeof UILayoutDebugger._staleReportResult>): HTMLElement {
1753
+ const panel = UILayoutDebugger._el("div", [
1754
+ "display: flex",
1755
+ "flex-direction: column",
1756
+ "flex: 1",
1757
+ "min-height: 0",
1758
+ "overflow: hidden",
1759
+ ])
1760
+
1761
+ // ── Header bar ────────────────────────────────────────────────────────
1762
+ const bar = UILayoutDebugger._el("div", [
1763
+ "padding: 5px 10px",
1764
+ "border-bottom: 1px solid rgba(255,255,255,0.08)",
1765
+ "display: flex",
1766
+ "align-items: center",
1767
+ "gap: 6px",
1768
+ "flex-shrink: 0",
1769
+ "font-size: 10px",
1770
+ ])
1771
+
1772
+ const barTitle = UILayoutDebugger._el("span", ["color: #ffaa55", "flex: 1", "font-weight: bold"])
1773
+ barTitle.textContent = "☢ Stale Layout Report"
1774
+
1775
+ const rerunBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle("#ffaa55"))
1776
+ rerunBtn.textContent = "↺ Re-run"
1777
+ rerunBtn.title = "Re-run performForcedSubtreeLayout and refresh the report"
1778
+ rerunBtn.onclick = () => UILayoutDebugger.captureStaleLayoutReport()
1779
+
1780
+ const closeBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle("#9090a8"))
1781
+ closeBtn.textContent = "✕"
1782
+ closeBtn.title = "Close stale report panel"
1783
+ closeBtn.onclick = () => {
1784
+ UILayoutDebugger._staleReportMode = false
1785
+ UILayoutDebugger._renderOverlay()
1786
+ }
1787
+
1788
+ bar.append(barTitle, rerunBtn, closeBtn)
1789
+ panel.appendChild(bar)
1790
+
1791
+ // ── Summary bar ───────────────────────────────────────────────────────
1792
+ const { diffs, forcedPassCacheChanges, passIndex } = result
1793
+
1794
+ const counts = { frame: 0, cache: 0, both: 0, appeared: 0, disappeared: 0 }
1795
+ for (const d of diffs) {
1796
+ if (d.kind === "frame") { counts.frame++ }
1797
+ else if (d.kind === "cache") { counts.cache++ }
1798
+ else if (d.kind === "both") { counts.both++ }
1799
+ else if (d.kind === "appeared") { counts.appeared++ }
1800
+ else if (d.kind === "disappeared") { counts.disappeared++ }
1801
+ }
1802
+ const totalCorrected = diffs.length
1803
+
1804
+ const summaryBar = UILayoutDebugger._el("div", [
1805
+ "padding: 5px 10px",
1806
+ "border-bottom: 1px solid rgba(255,255,255,0.08)",
1807
+ "display: flex",
1808
+ "gap: 8px",
1809
+ "align-items: center",
1810
+ "flex-shrink: 0",
1811
+ "font-size: 10px",
1812
+ "flex-wrap: wrap",
1813
+ ])
1814
+
1815
+ if (totalCorrected === 0) {
1816
+ const clean = UILayoutDebugger._el("span", ["color: #88ff99", "font-weight: bold"])
1817
+ clean.textContent = "✓ No stale state detected — all views were already correct."
1818
+ summaryBar.appendChild(clean)
1819
+ }
1820
+ else {
1821
+ const total = UILayoutDebugger._el("span", ["color: #ffaa55", "font-weight: bold"])
1822
+ total.textContent = `${totalCorrected} stale view${totalCorrected !== 1 ? "s" : ""} corrected`
1823
+ summaryBar.appendChild(total)
1824
+
1825
+ const summaryItems: [string, number, string][] = [
1826
+ ["frame+cache", counts.both, "#ffaa55"],
1827
+ ["frame", counts.frame, "#7bc8ff"],
1828
+ ["cache", counts.cache, "#ffcc88"],
1829
+ ["appeared", counts.appeared, "#88ff99"],
1830
+ ["disappeared", counts.disappeared, "#ff8888"],
1831
+ ]
1832
+ for (const [label, count, color] of summaryItems) {
1833
+ if (count === 0) { continue }
1834
+ const chip = UILayoutDebugger._el("span", [`color: ${color}`])
1835
+ chip.textContent = `${count} ${label}`
1836
+ summaryBar.appendChild(chip)
1837
+ }
1838
+ }
1839
+
1840
+ const passTag = UILayoutDebugger._el("span", ["color: #5a5a70", "margin-left: auto", "white-space: nowrap", "flex-shrink: 0"])
1841
+ passTag.textContent = passIndex >= 0 ? `forced pass #${passIndex}` : ""
1842
+ summaryBar.appendChild(passTag)
1843
+ panel.appendChild(summaryBar)
1844
+
1845
+ // ── UITextMeasurement global cache summary ────────────────────────────
1846
+ const tmBefore = result.before.textMeasurement
1847
+ const tmAfter = result.after.textMeasurement
1848
+ const tmPreparedCleared = tmBefore.preparedCacheSize > 0 && tmAfter.preparedCacheSize === 0
1849
+ const tmStyleCleared = tmBefore.styleCacheSize > 0 && tmAfter.styleCacheSize === 0
1850
+ const tmKnown = tmBefore.preparedCacheSize >= 0
1851
+
1852
+ if (tmKnown) {
1853
+ const tmBar = UILayoutDebugger._el("div", [
1854
+ "padding: 4px 10px",
1855
+ "border-bottom: 1px solid rgba(255,255,255,0.08)",
1856
+ "display: flex",
1857
+ "gap: 10px",
1858
+ "flex-shrink: 0",
1859
+ "font-size: 9px",
1860
+ "color: #7060a0",
1861
+ "flex-wrap: wrap",
1862
+ ])
1863
+ const tmLabel = UILayoutDebugger._el("span", ["color: #7060a0", "font-weight: bold", "flex-shrink: 0"])
1864
+ tmLabel.textContent = "UITextMeasurement (global):"
1865
+ tmBar.appendChild(tmLabel)
1866
+
1867
+ const prepChip = UILayoutDebugger._el("span", [
1868
+ "color: " + (tmPreparedCleared ? "#ffaa55" : "#5a5a70"),
1869
+ ])
1870
+ prepChip.textContent = `preparedCache ${tmBefore.preparedCacheSize} → ${tmAfter.preparedCacheSize}` +
1871
+ (tmPreparedCleared ? " ✓ cleared" : "")
1872
+ tmBar.appendChild(prepChip)
1873
+
1874
+ const styleChip = UILayoutDebugger._el("span", [
1875
+ "color: " + (tmStyleCleared ? "#ffaa55" : "#5a5a70"),
1876
+ ])
1877
+ styleChip.textContent = `styleCache ${tmBefore.styleCacheSize} → ${tmAfter.styleCacheSize}` +
1878
+ (tmStyleCleared ? " ✓ cleared" : "")
1879
+ tmBar.appendChild(styleChip)
1880
+
1881
+ panel.appendChild(tmBar)
1882
+ }
1883
+
1884
+ // ── Filter tabs ───────────────────────────────────────────────────────
1885
+ type StaleFilter = "all" | "frame" | "cache"
1886
+ let staleFilter: StaleFilter = totalCorrected > 0 ? "all" : "all"
1887
+
1888
+ const tabBar = UILayoutDebugger._el("div", [
1889
+ "display: flex",
1890
+ "border-bottom: 1px solid rgba(255,255,255,0.08)",
1891
+ "flex-shrink: 0",
1892
+ "font-size: 10px",
1893
+ ])
1894
+
1895
+ const list = UILayoutDebugger._el("div", [
1896
+ "overflow-y: auto",
1897
+ "flex: 1",
1898
+ "padding: 4px 0",
1899
+ ])
1900
+
1901
+ const renderList = (filter: StaleFilter) => {
1902
+ list.innerHTML = ""
1903
+
1904
+ if (diffs.length === 0) {
1905
+ const msg = UILayoutDebugger._el("div", ["padding: 10px 12px", "color: #88ff99", "font-size: 10px"])
1906
+ msg.textContent = "✓ Nothing was corrected — no missing invalidations detected."
1907
+ list.appendChild(msg)
1908
+ return
1909
+ }
1910
+
1911
+ const visible = filter === "all"
1912
+ ? diffs
1913
+ : filter === "frame"
1914
+ ? diffs.filter(d => d.kind === "frame" || d.kind === "both")
1915
+ : diffs.filter(d => d.kind === "cache" || d.kind === "both")
1916
+
1917
+ if (visible.length === 0) {
1918
+ const msg = UILayoutDebugger._el("div", ["padding: 8px 12px", "color: #5a5a70", "font-size: 10px"])
1919
+ msg.textContent = "No items."
1920
+ list.appendChild(msg)
1921
+ return
1922
+ }
1923
+
1924
+ for (const d of visible) {
1925
+ const cacheWrites = forcedPassCacheChanges.get(d.viewIndex) ?? []
1926
+
1927
+ const row = UILayoutDebugger._el("div", [
1928
+ "padding: 5px 10px",
1929
+ "border-bottom: 1px solid rgba(255,255,255,0.05)",
1930
+ "font-size: 10px",
1931
+ ])
1932
+
1933
+ // ── Top line: kind tag + class + elementID ────────────────────
1934
+ const topLine = UILayoutDebugger._el("div", ["display: flex", "gap: 6px", "align-items: baseline", "margin-bottom: 2px"])
1935
+
1936
+ const kindColors: Record<UILayoutDebugDiffKind, string> = {
1937
+ appeared: "#88ff99", disappeared: "#ff8888",
1938
+ both: "#ffaa55", frame: "#7bc8ff", cache: "#ffcc88", unchanged: "#5a5a70",
1939
+ }
1940
+ const kindTag = UILayoutDebugger._el("span", [
1941
+ `color: ${kindColors[d.kind]}`,
1942
+ "flex-shrink: 0",
1943
+ "font-size: 9px",
1944
+ "font-weight: bold",
1945
+ "min-width: 70px",
1946
+ ])
1947
+ kindTag.textContent = d.kind.toUpperCase()
1948
+
1949
+ const cls = UILayoutDebugger._el("span", ["color: #ffcc88", "font-weight: bold"])
1950
+ cls.textContent = d.className
1951
+
1952
+ const eid = UILayoutDebugger._el("span", ["color: #6a6a80"])
1953
+ eid.textContent = ` #${d.elementID}`
1954
+
1955
+ // Jump to the forced pass in the pass inspector
1956
+ const jumpBtn = UILayoutDebugger._el("span", [
1957
+ "color: #59599b",
1958
+ "margin-left: auto",
1959
+ "font-size: 9px",
1960
+ "cursor: pointer",
1961
+ "flex-shrink: 0",
1962
+ ])
1963
+ jumpBtn.textContent = `pass #${passIndex} ↗`
1964
+ jumpBtn.title = "Jump to this view in the forced-layout pass"
1965
+ jumpBtn.onclick = () => {
1966
+ const trace = UILayoutDebugger._traces.find(t => t.passIndex === passIndex)
1967
+ if (!trace) { return }
1968
+ const ti = UILayoutDebugger._traces.indexOf(trace)
1969
+ const si = trace.steps.findIndex(s => s.viewIndex === d.viewIndex)
1970
+ if (si < 0) { return }
1971
+ UILayoutDebugger._replayTraceIndex = ti
1972
+ UILayoutDebugger._replayStepIndex = si
1973
+ UILayoutDebugger._renderOverlay()
1974
+ }
1975
+
1976
+ topLine.append(kindTag, cls, eid, jumpBtn)
1977
+ row.appendChild(topLine)
1978
+
1979
+ // ── Frame correction ──────────────────────────────────────────
1980
+ if (d.kind === "frame" || d.kind === "both") {
1981
+ const frameLine = UILayoutDebugger._el("div", ["padding-left: 76px", "color: #b0b0c8", "font-size: 9px", "margin-bottom: 1px"])
1982
+ frameLine.textContent = "frame: " + UILayoutDebugger._formatFrameDiff(d.baselineFrame, d.currentFrame)
1983
+ row.appendChild(frameLine)
1984
+ }
1985
+
1986
+ // ── Cache correction + write cross-reference ──────────────────
1987
+ if (d.kind === "cache" || d.kind === "both") {
1988
+ const cacheLine = UILayoutDebugger._el("div", ["padding-left: 76px", "color: #a0a090", "font-size: 9px", "margin-bottom: 1px"])
1989
+ cacheLine.textContent = "cache: " + UILayoutDebugger._formatCacheDiff(d.baselineCache, d.currentCache)
1990
+ row.appendChild(cacheLine)
1991
+
1992
+ // Cross-reference: which call paths recomputed the correct value?
1993
+ if (cacheWrites.length > 0) {
1994
+ const xrefHeader = UILayoutDebugger._el("div", [
1995
+ "padding-left: 76px",
1996
+ "color: #7060a0",
1997
+ "font-size: 9px",
1998
+ "margin-top: 3px",
1999
+ "margin-bottom: 1px",
2000
+ "font-weight: bold",
2001
+ ])
2002
+ xrefHeader.textContent = `↳ recomputed by (${cacheWrites.length} write${cacheWrites.length !== 1 ? "s" : ""} in forced pass):`
2003
+ row.appendChild(xrefHeader)
2004
+
2005
+ for (const ev of cacheWrites) {
2006
+ const writeRow = UILayoutDebugger._el("div", [
2007
+ "padding-left: 84px",
2008
+ "display: flex",
2009
+ "gap: 5px",
2010
+ "align-items: baseline",
2011
+ "font-size: 9px",
2012
+ ])
2013
+
2014
+ const keyMatch = ev.cacheKey.match(/h_(\d+(?:\.\d+)?)__w_(\d+(?:\.\d+)?)/)
2015
+ const keyLabel = keyMatch
2016
+ ? (keyMatch[1] !== "0" && keyMatch[2] !== "0"
2017
+ ? `h≤${keyMatch[1]} w≤${keyMatch[2]}`
2018
+ : keyMatch[2] !== "0" ? `w≤${keyMatch[2]}` : `h≤${keyMatch[1]}`)
2019
+ : ev.cacheKey
2020
+
2021
+ const keySpan = UILayoutDebugger._el("span", ["color: #6060808", "flex-shrink: 0"])
2022
+ keySpan.textContent = keyLabel
2023
+
2024
+ const valSpan = UILayoutDebugger._el("span", ["color: #88ddff", "flex-shrink: 0"])
2025
+ valSpan.textContent = `→ ${ev.newValue.width.toFixed(0)}×${ev.newValue.height.toFixed(0)}`
2026
+
2027
+ const callerSpan = UILayoutDebugger._el("span", ["color: #7070a8", "cursor: pointer"])
2028
+ callerSpan.textContent = ev.callerFunction + "()"
2029
+ callerSpan.title = ev.cleanStack
2030
+
2031
+ let stackOpen = false
2032
+ const stackEl = UILayoutDebugger._el("div", [
2033
+ "display: none",
2034
+ "margin-top: 2px",
2035
+ "padding: 3px 6px",
2036
+ "background: rgba(255,255,255,0.04)",
2037
+ "border-radius: 3px",
2038
+ "color: #6060808",
2039
+ "font-size: 9px",
2040
+ "white-space: pre",
2041
+ "overflow-x: auto",
2042
+ ])
2043
+ stackEl.textContent = ev.cleanStack
2044
+ callerSpan.onclick = () => {
2045
+ stackOpen = !stackOpen
2046
+ stackEl.style.display = stackOpen ? "block" : "none"
2047
+ }
2048
+
2049
+ writeRow.append(keySpan, valSpan, callerSpan)
2050
+ row.appendChild(writeRow)
2051
+ row.appendChild(stackEl)
2052
+ }
2053
+ }
2054
+ }
2055
+
2056
+ list.appendChild(row)
2057
+ }
2058
+ }
2059
+
2060
+ const filterTabs: [string, StaleFilter, number, string][] = [
2061
+ ["All", "all", diffs.length, "#ffaa55"],
2062
+ ["Frame", "frame", counts.frame + counts.both, "#7bc8ff"],
2063
+ ["Cache", "cache", counts.cache + counts.both, "#ffcc88"],
2064
+ ]
2065
+
2066
+ const buildTabs = () => {
2067
+ tabBar.innerHTML = ""
2068
+ for (const [label, filter, count, color] of filterTabs) {
2069
+ if (count === 0 && filter !== "all") { continue }
2070
+ const tab = UILayoutDebugger._el("div", [
2071
+ "padding: 4px 8px",
2072
+ "cursor: pointer",
2073
+ "border-bottom: 2px solid " + (staleFilter === filter ? color : "transparent"),
2074
+ `color: ${staleFilter === filter ? color : "#6a6a80"}`,
2075
+ "white-space: nowrap",
2076
+ "font-size: 10px",
2077
+ ])
2078
+ tab.textContent = `${label} (${count})`
2079
+ tab.onclick = () => {
2080
+ staleFilter = filter
2081
+ buildTabs()
2082
+ renderList(staleFilter)
2083
+ }
2084
+ tabBar.appendChild(tab)
2085
+ }
2086
+ }
2087
+
2088
+ buildTabs()
2089
+ panel.appendChild(tabBar)
2090
+ panel.appendChild(list)
2091
+ renderList(staleFilter)
2092
+
2093
+ return panel
2094
+ }
2095
+
2096
+
2097
+ static _renderHelpPanel(): HTMLElement {
2098
+ const panel = UILayoutDebugger._el("div", [
2099
+ "overflow-y: auto",
2100
+ "flex: 1",
2101
+ "padding: 14px 16px",
2102
+ "font-size: 11px",
2103
+ "line-height: 1.6",
2104
+ "color: #c0c0d8",
2105
+ ])
2106
+
2107
+ const section = (heading: string, body: string) => {
2108
+ const h = UILayoutDebugger._el("div", [
2109
+ "font-weight: bold",
2110
+ "color: #c8d8ff",
2111
+ "margin-top: 14px",
2112
+ "margin-bottom: 4px",
2113
+ "font-size: 11px",
2114
+ "border-bottom: 1px solid rgba(255,255,255,0.08)",
2115
+ "padding-bottom: 3px",
2116
+ ])
2117
+ h.textContent = heading
2118
+ const b = UILayoutDebugger._el("div", ["color: #a0a0b8", "white-space: pre-wrap"])
2119
+ b.textContent = body
2120
+ panel.appendChild(h)
2121
+ panel.appendChild(b)
2122
+ }
2123
+
2124
+ const kv = (key: string, value: string) => {
2125
+ const row = UILayoutDebugger._el("div", ["display: flex", "gap: 8px", "margin-bottom: 4px"])
2126
+ const k = UILayoutDebugger._el("span", [
2127
+ "color: #ffcc88",
2128
+ "font-weight: bold",
2129
+ "flex-shrink: 0",
2130
+ "min-width: 110px",
2131
+ ])
2132
+ k.textContent = key
2133
+ const v = UILayoutDebugger._el("span", ["color: #a0a0b8"])
2134
+ v.textContent = value
2135
+ row.append(k, v)
2136
+ panel.appendChild(row)
2137
+ }
2138
+
2139
+ // ── Concepts ─────────────────────────────────────────────────────────
2140
+
2141
+ section("Core concepts", "")
2142
+
2143
+ kv("Pass",
2144
+ "One full run of layoutViewsIfNeeded(). The scheduler collects every " +
2145
+ "view that called setNeedsLayout() and works through them all. A single " +
2146
+ "user action typically triggers one pass.")
2147
+
2148
+ kv("Iteration",
2149
+ "One loop of the while-loop inside a pass. Laying out a view can call " +
2150
+ "setNeedsLayout() on another view, adding it to the queue mid-pass. " +
2151
+ "The loop repeats until the queue is empty. Most passes have 1 iteration; " +
2152
+ "more than 1 signals that layout is triggering further layout.")
2153
+
2154
+ kv("Step",
2155
+ "One view being laid out within a pass — one call to layoutIfNeeded(). " +
2156
+ "A pass with 8 steps means 8 views had their layout computed. Steps are " +
2157
+ "the atomic unit you step through in the scrubber.")
2158
+
2159
+ kv("Trigger",
2160
+ "The call stack at the moment setNeedsLayout() was called on a view. " +
2161
+ "Tells you what caused the view to enter the layout queue.")
2162
+
2163
+ kv("Heat colour",
2164
+ "Green = laid out once. Orange = laid out twice (possible unnecessary work). " +
2165
+ "Red = laid out 3+ times (likely a layout cycle or redundant invalidation).")
2166
+
2167
+ // ── Pass inspector ────────────────────────────────────────────────────
2168
+
2169
+ section("Pass inspector", "")
2170
+
2171
+ kv("Pass picker",
2172
+ "Selects which recorded pass to inspect. The most recent pass is shown " +
2173
+ "first. Up to 20 passes are stored.")
2174
+
2175
+ kv("◀ ▶ scrubber",
2176
+ "Steps through the views laid out in the selected pass one at a time. " +
2177
+ "The active view is highlighted in the tree and its frame diff, intrinsic " +
2178
+ "cache diff, trigger stack, and subview changes are shown above.")
2179
+
2180
+ kv("Frame filter",
2181
+ "All: show every step. Changed: only steps where the frame actually moved " +
2182
+ "or resized. Unchanged: only no-ops. The counter shows n/total when filtered.")
2183
+
2184
+ kv("Compare ⧉",
2185
+ "Splits the panel into two columns, each showing an independent pass. " +
2186
+ "The tree expands and collapses in sync between both columns.")
2187
+
2188
+ kv("Cache writes",
2189
+ "Collapsible section at the bottom of each column. Every write to a " +
2190
+ "view's intrinsic size cache during that pass is listed, with the value " +
2191
+ "written and the caller that triggered the measurement.")
2192
+
2193
+ // ── Baseline / diff ───────────────────────────────────────────────────
2194
+
2195
+ section("Baseline & diff", "")
2196
+
2197
+ kv("📍 Baseline",
2198
+ "Captures a flat snapshot of every view's frame and intrinsic cache right " +
2199
+ "now. Requires at least one layout pass to have run so the root view is " +
2200
+ "known. Click again to recapture.")
2201
+
2202
+ kv("⊕ Diff",
2203
+ "Captures a second snapshot and shows what changed since the baseline. " +
2204
+ "Changes are sorted: appeared, disappeared, frame+cache, frame-only, " +
2205
+ "cache-only. Click any row to jump to the pass that caused that change.")
2206
+
2207
+ kv("pass #N tag",
2208
+ "Shown on each diff row. Indicates the first recorded pass (by pass " +
2209
+ "number) in which that view was laid out after the baseline was taken.")
2210
+
2211
+ kv("⬚ Frame / Bounds",
2212
+ "Toggles how frame comparisons are made across all diff panels. " +
2213
+ "Frame mode (default): a view is considered changed if its position or " +
2214
+ "size changed. Bounds mode: only size changes are counted — origin (x/y) " +
2215
+ "is ignored. Use bounds mode when you care only about content-affecting " +
2216
+ "size changes and want to suppress noise from views that merely moved.")
2217
+
2218
+ // ── Stale layout report ───────────────────────────────────────────────
2219
+
2220
+ section("Stale layout report ☢", "")
2221
+
2222
+ kv("☢ Stale",
2223
+ "The primary tool for finding missing cache invalidations. Click once to " +
2224
+ "snapshot the current tree state, then immediately call " +
2225
+ "performForcedSubtreeLayout() for a complete cold remeasure, then diff the " +
2226
+ "two snapshots. Any view that changed was holding stale/incorrect state — " +
2227
+ "its invalidation was missed somewhere.")
2228
+
2229
+ kv("Corrected views",
2230
+ "Each row in the ☢ panel is a view the forced pass corrected. The kind " +
2231
+ "tag shows whether the frame, intrinsic cache, or both were stale.")
2232
+
2233
+ kv("↳ recomputed by",
2234
+ "For cache corrections, the exact call path(s) that recomputed the correct " +
2235
+ "value during the forced pass are listed below each row, with expandable " +
2236
+ "stack traces. Work backwards from that call site to find where the " +
2237
+ "corresponding invalidation should have been triggered but wasn't.")
2238
+
2239
+ kv("pass #N ↗",
2240
+ "Each row has a jump link to the forced-layout pass in the pass inspector " +
2241
+ "so you can step through the remeasure for that view in full detail.")
2242
+
2243
+ kv("Re-run",
2244
+ "Right-click the ☢ Stale button (or use ↺ Re-run in the panel) to re-run " +
2245
+ "the report without closing and reopening. Useful after you've applied a fix " +
2246
+ "and want to verify the stale count drops to zero.")
2247
+
2248
+ kv("When to use it",
2249
+ "Reproduce the bug, then immediately click ☢ Stale before triggering any " +
2250
+ "other layout. The tree must be in its incorrect state at the moment you " +
2251
+ "click. The forced layout will correct it, which is visible in the UI after " +
2252
+ "the report runs.")
2253
+
2254
+ // ── Live inspector ────────────────────────────────────────────────────
2255
+
2256
+ section("Live inspector 👁", "")
2257
+
2258
+ kv("👁 Live",
2259
+ "Shows the current view tree as it exists right now — not a recorded " +
2260
+ "snapshot. Frames and cache entries reflect the live DOM state. " +
2261
+ "Use ↺ Refresh to re-read after triggering layout manually.")
2262
+
2263
+ // ── Breakpoints ───────────────────────────────────────────────────────
2264
+
2265
+ section("Breakpoint mode ⏸", "")
2266
+
2267
+ kv("⏸ BP",
2268
+ "When enabled, a sentinel line executes before every layoutIfNeeded() " +
2269
+ "call. Set a browser debugger breakpoint on that line to pause before " +
2270
+ "each step with the full live JS stack in scope.")
2271
+
2272
+ kv("Finding the line",
2273
+ "In Chrome DevTools Sources, press Cmd+Opt+F (Mac) or Ctrl+Shift+F " +
2274
+ "(Windows) and search for: breakpointOnThisLine")
2275
+
2276
+ // ── Console API ───────────────────────────────────────────────────────
2277
+
2278
+ section("Console API", "All methods are on the global UILayoutDebugger object.")
2279
+
2280
+ kv("UILayoutDebugger.enable()", "Start or stop recording and show/hide the overlay.")
2281
+ kv("UILayoutDebugger.disable()", "Stop recording and hide the overlay.")
2282
+ kv("UILayoutDebugger.enableBreakpoints()", "Turn on breakpoint step-through mode.")
2283
+ kv("UILayoutDebugger.captureBaseline()", "Snapshot current state as baseline.")
2284
+ kv("UILayoutDebugger.captureAndDiff()", "Snapshot and diff against baseline.")
2285
+ kv("UILayoutDebugger.captureStaleLayoutReport()", "Snapshot → forced remeasure → diff. Primary cache-staleness diagnostic.")
2286
+ kv("UILayoutDebugger.clearStaleReport()", "Clear the stale report result and close the panel.")
2287
+ kv("UILayoutDebugger.clearTraces()", "Discard all recorded passes and reset the counter.")
2288
+ kv("UILayoutDebugger.goToStep(n)", "Jump to step n (0-based) in the current pass.")
2289
+ kv("UILayoutDebugger.toggleLiveInspector()", "Show or hide the live view tree panel.")
2290
+ kv("UILayoutDebugger._boundsBasedDiff = true", "Switch all frame diffs to size-only (bounds) mode from the console.")
2291
+
2292
+ return panel
2293
+ }
2294
+
2295
+ static _renderLiveInspectorPanel(): HTMLElement {
2296
+ const panel = UILayoutDebugger._el("div", [
2297
+ "display: flex",
2298
+ "flex-direction: column",
2299
+ "flex: 1",
2300
+ "min-height: 0",
2301
+ "overflow: hidden",
2302
+ ])
2303
+
2304
+ // ── Header bar ────────────────────────────────────────────────────────
2305
+ const bar = UILayoutDebugger._el("div", [
2306
+ "padding: 5px 10px",
2307
+ "border-bottom: 1px solid rgba(255,255,255,0.08)",
2308
+ "display: flex",
2309
+ "align-items: center",
2310
+ "gap: 6px",
2311
+ "flex-shrink: 0",
2312
+ "font-size: 10px",
2313
+ ])
2314
+
2315
+ const barTitle = UILayoutDebugger._el("span", ["color: #a0a0b8", "flex: 1", "font-weight: bold"])
2316
+ barTitle.textContent = "👁 Live View Tree"
2317
+
2318
+ const refreshBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle("#9090a8"))
2319
+ refreshBtn.textContent = "↺ Refresh"
2320
+ refreshBtn.title = "Re-capture current state"
2321
+ refreshBtn.onclick = () => UILayoutDebugger._renderOverlay()
2322
+
2323
+ const syncBtn = UILayoutDebugger._el("button", UILayoutDebugger._btnStyle("#9090a8"))
2324
+ syncBtn.textContent = "⇄ Sync"
2325
+ syncBtn.title = "Sync collapse/expand state from pass inspector. Views not in the pass inspector are kept collapsed."
2326
+ syncBtn.onclick = () => {
2327
+ // Read the active pass expand state
2328
+ const source = UILayoutDebugger._compareMode
2329
+ ? UILayoutDebugger._sharedExpandState
2330
+ : UILayoutDebugger._singleExpandState
2331
+
2332
+ // Build a set of all viewIndices that appear in any recorded trace step
2333
+ const tracedViews = new Set<number>()
2334
+ for (const trace of UILayoutDebugger._traces) {
2335
+ for (const step of trace.steps) { tracedViews.add(step.viewIndex) }
2336
+ }
2337
+
2338
+ // Copy source state; any viewIndex not in source and not in traced
2339
+ // views defaults to collapsed
2340
+ const newState = new Map<number, boolean>()
2341
+ for (const [idx, expanded] of source) {
2342
+ newState.set(idx, expanded)
2343
+ }
2344
+ // For views present in the live tree but absent from pass inspector
2345
+ // state, collapse them unless they appear in a trace
2346
+ UILayoutDebugger._walkViewTree(
2347
+ UILayoutDebugger._lastKnownRootView,
2348
+ new Map(), // discard — we only need the side effect of visiting indices
2349
+ new Set(),
2350
+ )
2351
+ // Actually walk to collect all live view indices
2352
+ const liveIndices = new Set<number>()
2353
+ const collectIndices = (view: any, visited: Set<number>) => {
2354
+ const idx: number = view?._UIViewIndex ?? -1
2355
+ if (idx < 0 || visited.has(idx)) { return }
2356
+ visited.add(idx)
2357
+ liveIndices.add(idx)
2358
+ for (const sv of view?.subviews ?? []) { collectIndices(sv, visited) }
2359
+ }
2360
+ collectIndices(UILayoutDebugger._lastKnownRootView, new Set())
2361
+
2362
+ for (const idx of liveIndices) {
2363
+ if (!newState.has(idx)) {
2364
+ // Not in pass expand state: expand only if it appears in a trace
2365
+ newState.set(idx, tracedViews.has(idx))
2366
+ }
2367
+ }
2368
+
2369
+ UILayoutDebugger._liveExpandState = newState
2370
+ UILayoutDebugger._renderOverlay()
2371
+ }
2372
+
2373
+ bar.append(barTitle, refreshBtn, syncBtn)
2374
+ panel.appendChild(bar)
2375
+
2376
+ const root = UILayoutDebugger._lastKnownRootView
2377
+ if (!root) {
2378
+ const msg = UILayoutDebugger._el("div", ["padding: 10px 12px", "color: #6a6a80", "font-size: 10px"])
2379
+ msg.textContent = "No root view found yet — trigger a layout pass first."
2380
+ panel.appendChild(msg)
2381
+ return panel
2382
+ }
2383
+
2384
+ // ── Tree ──────────────────────────────────────────────────────────────
2385
+ const treeContainer = UILayoutDebugger._el("div", [
2386
+ "overflow-y: auto",
2387
+ "flex: 1",
2388
+ "padding: 4px 0",
2389
+ "position: relative",
2390
+ ])
2391
+
2392
+ UILayoutDebugger._renderLiveNode(
2393
+ root, treeContainer, 0, new Set(), UILayoutDebugger._liveExpandState
2394
+ )
2395
+ panel.appendChild(treeContainer)
2396
+ return panel
2397
+ }
2398
+
2399
+ static _renderLiveNode(
2400
+ view: any,
2401
+ container: HTMLElement,
2402
+ depth: number,
2403
+ visited: Set<number>,
2404
+ expandState: Map<number, boolean>,
2405
+ ) {
2406
+ const idx: number = view?._UIViewIndex ?? -1
2407
+ if (idx < 0 || visited.has(idx)) { return }
2408
+ visited.add(idx)
2409
+
2410
+ const frame = UILayoutDebugger._captureFrame(view)
2411
+ const cache = UILayoutDebugger._captureCache(view)
2412
+ const className: string = view?.constructor?.name ?? "UnknownView"
2413
+ const elementID: string = view?.elementID ?? String(idx)
2414
+ const subviews: any[] = view?.subviews ?? []
2415
+ const hasChildren = subviews.length > 0
2416
+
2417
+ // Default to expanded if not in state map yet
2418
+ if (!expandState.has(idx)) { expandState.set(idx, true) }
2419
+ let expanded = expandState.get(idx)!
2420
+
2421
+ const row = UILayoutDebugger._el("div", [
2422
+ "display: flex",
2423
+ "align-items: baseline",
2424
+ "padding: 1px 10px 1px " + (10 + depth * 12) + "px",
2425
+ "cursor: " + (hasChildren ? "pointer" : "default"),
2426
+ "border-radius: 3px",
2427
+ "margin: 0 4px",
2428
+ ])
2429
+
2430
+ const chevron = UILayoutDebugger._el("span", [
2431
+ "display: inline-block", "width: 10px", "flex-shrink: 0",
2432
+ "color: #7070a0", "font-size: 8px", "margin-right: 2px", "text-align: center",
2433
+ ])
2434
+ chevron.textContent = !hasChildren ? "" : expanded ? "▾" : "▸"
2435
+
2436
+ const dot = UILayoutDebugger._el("span", [
2437
+ "display: inline-block", "width: 7px", "height: 7px",
2438
+ "border-radius: 50%", "margin-right: 5px", "flex-shrink: 0",
2439
+ "background: #4a4a5a",
2440
+ ])
2441
+
2442
+ const classSpan = UILayoutDebugger._el("span", ["color: #9090a8"])
2443
+ classSpan.textContent = className
2444
+
2445
+ const eidSpan = UILayoutDebugger._el("span", ["color: #6a6a80", "margin-left: 4px"])
2446
+ eidSpan.textContent = `#${elementID}`
2447
+
2448
+ const frameSpan = UILayoutDebugger._el("span", ["color: #55556a", "margin-left: 4px", "font-size: 9px"])
2449
+ frameSpan.textContent = frame
2450
+ ? `${frame.left.toFixed(0)},${frame.top.toFixed(0)} ${frame.width.toFixed(0)}×${frame.height.toFixed(0)}`
2451
+ : ""
2452
+
2453
+ row.append(chevron, dot, classSpan, eidSpan, frameSpan)
2454
+
2455
+ row.title = [
2456
+ `${className} #${elementID}`,
2457
+ frame
2458
+ ? `frame: ${frame.left.toFixed(1)},${frame.top.toFixed(1)} ${frame.width.toFixed(1)}×${frame.height.toFixed(1)}`
2459
+ : "frame: (none)",
2460
+ "cache: " + UILayoutDebugger._formatCacheSnapshot(cache),
2461
+ ].join("\n")
2462
+
2463
+ container.appendChild(row)
2464
+
2465
+ if (!hasChildren) { return }
2466
+
2467
+ const childContainer = UILayoutDebugger._el("div", [
2468
+ "display: " + (expanded ? "block" : "none"),
2469
+ ])
2470
+ container.appendChild(childContainer)
2471
+
2472
+ for (const sv of subviews) {
2473
+ UILayoutDebugger._renderLiveNode(sv, childContainer, depth + 1, visited, expandState)
2474
+ }
2475
+
2476
+ row.onclick = () => {
2477
+ expanded = !expanded
2478
+ expandState.set(idx, expanded)
2479
+ childContainer.style.display = expanded ? "block" : "none"
2480
+ chevron.textContent = expanded ? "▾" : "▸"
2481
+ }
2482
+ }
2483
+
2484
+ static _renderDiffPanel(
2485
+ baseline: UILayoutDebugStateSnapshot,
2486
+ current: UILayoutDebugStateSnapshot,
2487
+ onNavigate: (viewIndex: number) => void,
2488
+ baselineTakenAt: number,
2489
+ ): HTMLElement {
2490
+ const panel = UILayoutDebugger._el("div", [
2491
+ "display: flex",
2492
+ "flex-direction: column",
2493
+ "flex: 1",
2494
+ "min-height: 0",
2495
+ "overflow: hidden",
2496
+ ])
2497
+
2498
+ // ── Summary bar ───────────────────────────────────────────────────────
2499
+ const diffs = UILayoutDebugger._diffSnapshots(baseline, current)
2500
+ const counts = { appeared: 0, disappeared: 0, frame: 0, cache: 0, both: 0, unchanged: 0 }
2501
+ for (const d of diffs) { counts[d.kind]++ }
2502
+ const changed = counts.appeared + counts.disappeared + counts.frame + counts.cache + counts.both
2503
+
2504
+ const summaryBar = UILayoutDebugger._el("div", [
2505
+ "padding: 5px 10px",
2506
+ "border-bottom: 1px solid rgba(255,255,255,0.08)",
2507
+ "display: flex",
2508
+ "gap: 8px",
2509
+ "align-items: center",
2510
+ "flex-shrink: 0",
2511
+ "font-size: 10px",
2512
+ "flex-wrap: wrap",
2513
+ ])
2514
+
2515
+ const summaryItems: [string, number, string][] = [
2516
+ ["appeared", counts.appeared, "#88ff99"],
2517
+ ["disappeared", counts.disappeared, "#ff8888"],
2518
+ ["frame+cache", counts.both, "#ffaa55"],
2519
+ ["frame", counts.frame, "#7bc8ff"],
2520
+ ["cache", counts.cache, "#ffcc88"],
2521
+ ["unchanged", counts.unchanged, "#5a5a70"],
2522
+ ]
2523
+ for (const [label, count, color] of summaryItems) {
2524
+ if (count === 0) { continue }
2525
+ const chip = UILayoutDebugger._el("span", [`color: ${color}`])
2526
+ chip.textContent = `${count} ${label}`
2527
+ summaryBar.appendChild(chip)
2528
+ }
2529
+ if (changed === 0) {
2530
+ const chip = UILayoutDebugger._el("span", ["color: #9090a8"])
2531
+ chip.textContent = "No changes"
2532
+ summaryBar.appendChild(chip)
2533
+ }
2534
+
2535
+ const ts = UILayoutDebugger._el("span", ["color: #5a5a70", "margin-left: auto", "white-space: nowrap"])
2536
+ const elapsed = ((current.takenAt - baseline.takenAt) / 1000).toFixed(1)
2537
+ ts.textContent = `+${elapsed}s`
2538
+ summaryBar.appendChild(ts)
2539
+
2540
+ panel.appendChild(summaryBar)
2541
+
2542
+ // ── Filter tabs ───────────────────────────────────────────────────────
2543
+ let diffFilter: UILayoutDebugDiffKind | "all" = changed > 0 ? "frame" : "all"
2544
+
2545
+ const tabBar = UILayoutDebugger._el("div", [
2546
+ "display: flex",
2547
+ "border-bottom: 1px solid rgba(255,255,255,0.08)",
2548
+ "flex-shrink: 0",
2549
+ "font-size: 10px",
2550
+ ])
2551
+
2552
+ const list = UILayoutDebugger._el("div", [
2553
+ "overflow-y: auto",
2554
+ "flex: 1",
2555
+ "padding: 4px 0",
2556
+ ])
2557
+
2558
+ const renderList = (filter: UILayoutDebugDiffKind | "all") => {
2559
+ list.innerHTML = ""
2560
+ const visible = filter === "all"
2561
+ ? diffs
2562
+ : diffs.filter(d => d.kind === filter || (filter === "frame" && d.kind === "both"))
2563
+
2564
+ if (visible.length === 0) {
2565
+ const msg = UILayoutDebugger._el("div", ["padding: 8px 12px", "color: #5a5a70"])
2566
+ msg.textContent = "No items."
2567
+ list.appendChild(msg)
2568
+ return
2569
+ }
2570
+
2571
+ for (const d of visible) {
2572
+ const kindColors: Record<UILayoutDebugDiffKind, string> = {
2573
+ appeared: "#88ff99", disappeared: "#ff8888",
2574
+ both: "#ffaa55", frame: "#7bc8ff", cache: "#ffcc88", unchanged: "#5a5a70",
2575
+ }
2576
+
2577
+ // Find which pass first changed this view after the baseline
2578
+ const causingTrace = d.kind !== "disappeared"
2579
+ ? UILayoutDebugger._findCausingTrace(d.viewIndex, baselineTakenAt)
2580
+ : null
2581
+ const hasTrace = !!causingTrace
2582
+
2583
+ const row = UILayoutDebugger._el("div", [
2584
+ "padding: 3px 10px",
2585
+ "border-bottom: 1px solid rgba(255,255,255,0.04)",
2586
+ "font-size: 10px",
2587
+ hasTrace ? "cursor: pointer" : "cursor: default",
2588
+ ])
2589
+ if (hasTrace) {
2590
+ row.title = `Click to jump to pass #${causingTrace!.passIndex} in the pass inspector`
2591
+ row.onmouseenter = () => { row.style.background = "rgba(255,255,255,0.06)" }
2592
+ row.onmouseleave = () => { row.style.background = "" }
2593
+ row.onclick = () => onNavigate(d.viewIndex)
2594
+ }
2595
+
2596
+ const topLine = UILayoutDebugger._el("div", ["display: flex", "gap: 6px", "align-items: baseline"])
2597
+
2598
+ const kindTag = UILayoutDebugger._el("span", [
2599
+ `color: ${kindColors[d.kind]}`,
2600
+ "flex-shrink: 0",
2601
+ "font-size: 9px",
2602
+ "font-weight: bold",
2603
+ "min-width: 70px",
2604
+ ])
2605
+ kindTag.textContent = d.kind.toUpperCase()
2606
+
2607
+ const cls = UILayoutDebugger._el("span", ["color: #ffcc88", "font-weight: bold"])
2608
+ cls.textContent = d.className
2609
+
2610
+ const eid = UILayoutDebugger._el("span", ["color: #6a6a80"])
2611
+ eid.textContent = `#${d.elementID}`
2612
+
2613
+ if (causingTrace) {
2614
+ const passTag = UILayoutDebugger._el("span", [
2615
+ "color: #59599b",
2616
+ "margin-left: auto",
2617
+ "font-size: 9px",
2618
+ "flex-shrink: 0",
2619
+ ])
2620
+ passTag.textContent = `pass #${causingTrace.passIndex}`
2621
+ topLine.append(kindTag, cls, eid, passTag)
2622
+ }
2623
+ else {
2624
+ topLine.append(kindTag, cls, eid)
2625
+ }
2626
+ row.appendChild(topLine)
2627
+
2628
+ if (d.kind !== "appeared" && d.kind !== "disappeared") {
2629
+ if (d.kind === "frame" || d.kind === "both") {
2630
+ const frameLine = UILayoutDebugger._el("div", ["padding-left: 76px", "color: #b0b0c8", "font-size: 9px"])
2631
+ frameLine.textContent = "frame: " + UILayoutDebugger._formatFrameDiff(d.baselineFrame, d.currentFrame)
2632
+ row.appendChild(frameLine)
2633
+ }
2634
+ if (d.kind === "cache" || d.kind === "both") {
2635
+ const cacheLine = UILayoutDebugger._el("div", ["padding-left: 76px", "color: #a0a090", "font-size: 9px"])
2636
+ cacheLine.textContent = "cache: " + UILayoutDebugger._formatCacheDiff(d.baselineCache, d.currentCache)
2637
+ row.appendChild(cacheLine)
2638
+ }
2639
+ }
2640
+ else {
2641
+ const frameLine = UILayoutDebugger._el("div", ["padding-left: 76px", "color: #b0b0c8", "font-size: 9px"])
2642
+ frameLine.textContent = "frame: " + UILayoutDebugger._formatFrame(
2643
+ d.kind === "appeared" ? d.currentFrame : d.baselineFrame
2644
+ )
2645
+ row.appendChild(frameLine)
2646
+ }
2647
+
2648
+ list.appendChild(row)
2649
+ }
2650
+ }
2651
+
2652
+ const tabs: [string, UILayoutDebugDiffKind | "all", number, string][] = [
2653
+ ["All", "all", diffs.length, "#9090a8"],
2654
+ ["Frame", "frame", counts.frame + counts.both, "#7bc8ff"],
2655
+ ["Cache", "cache", counts.cache + counts.both, "#ffcc88"],
2656
+ ["Appeared", "appeared", counts.appeared, "#88ff99"],
2657
+ ["Disappeared", "disappeared", counts.disappeared, "#ff8888"],
2658
+ ]
2659
+
2660
+ const buildTabs = () => {
2661
+ tabBar.innerHTML = ""
2662
+ for (const [label, filter, count, color] of tabs) {
2663
+ if (count === 0 && filter !== "all") { continue }
2664
+ const tab = UILayoutDebugger._el("div", [
2665
+ "padding: 4px 8px",
2666
+ "cursor: pointer",
2667
+ "border-bottom: 2px solid " + (diffFilter === filter ? color : "transparent"),
2668
+ `color: ${diffFilter === filter ? color : "#6a6a80"}`,
2669
+ "white-space: nowrap",
2670
+ "font-size: 10px",
2671
+ ])
2672
+ tab.textContent = `${label} (${count})`
2673
+ tab.onclick = () => {
2674
+ diffFilter = filter
2675
+ buildTabs()
2676
+ renderList(diffFilter)
2677
+ }
2678
+ tabBar.appendChild(tab)
2679
+ }
2680
+ }
2681
+
2682
+ buildTabs()
2683
+ panel.appendChild(tabBar)
2684
+ panel.appendChild(list)
2685
+ renderList(diffFilter)
2686
+
2687
+ return panel
2688
+ }
2689
+
2690
+ static _renderStepDetail(activeStep: UILayoutDebugStep): HTMLElement {
2691
+ const detail = UILayoutDebugger._el("div", [
2692
+ "padding: 6px 10px",
2693
+ "border-bottom: 1px solid rgba(255,255,255,0.08)",
2694
+ "flex-shrink: 0",
2695
+ "font-size: 10px",
2696
+ ])
2697
+
2698
+ const titleRow = UILayoutDebugger._el("div", ["margin-bottom: 3px"])
2699
+
2700
+ const stepTag = UILayoutDebugger._el("span", [
2701
+ "background: #2a2a44",
2702
+ "border-radius: 3px",
2703
+ "padding: 0 4px",
2704
+ "margin-right: 5px",
2705
+ "color: #aac8ff",
2706
+ "font-weight: bold",
2707
+ ])
2708
+ stepTag.textContent = `step ${activeStep.stepIndex}`
2709
+
2710
+ const iterTag = UILayoutDebugger._el("span", ["color: #9090a8", "margin-right: 6px"])
2711
+ iterTag.textContent = `iter ${activeStep.iteration + 1}`
2712
+
2713
+ const className = UILayoutDebugger._el("span", ["color: #ffcc88", "font-weight: bold"])
2714
+ className.textContent = activeStep.className
2715
+
2716
+ const eid = UILayoutDebugger._el("span", ["color: #9090a8", "margin-left: 4px"])
2717
+ eid.textContent = `#${activeStep.elementID}`
2718
+
2719
+ titleRow.append(stepTag, iterTag, className, eid)
2720
+ detail.appendChild(titleRow)
2721
+
2722
+ const frameRow = UILayoutDebugger._el("div", ["color: #c0c0d8", "margin-bottom: 2px"])
2723
+ frameRow.textContent = "frame: " + UILayoutDebugger._formatFrameDiff(activeStep.frameBefore, activeStep.frameAfter)
2724
+ detail.appendChild(frameRow)
2725
+
2726
+ // ── Intrinsic cache diff ──────────────────────────────────────────────
2727
+ const cacheDiffStr = UILayoutDebugger._formatCacheDiff(activeStep.cacheBefore, activeStep.cacheAfter)
2728
+ const cacheRow = UILayoutDebugger._el("div", ["margin-bottom: 3px"])
2729
+
2730
+ const cacheLabel = UILayoutDebugger._el("span", ["color: #8888a0", "margin-right: 4px"])
2731
+ cacheLabel.textContent = "cache:"
2732
+
2733
+ const cacheChanged =
2734
+ (activeStep.cacheBefore?.entryCount ?? 0) !== (activeStep.cacheAfter?.entryCount ?? 0) ||
2735
+ cacheDiffStr.includes("~") || cacheDiffStr.includes("+") || cacheDiffStr.includes("-")
2736
+
2737
+ const cacheSummary = UILayoutDebugger._el("span", [
2738
+ "color: " + (cacheChanged ? "#ffcc88" : "#7878a0"),
2739
+ "cursor: pointer",
2740
+ "font-weight: " + (cacheChanged ? "bold" : "normal"),
2741
+ ])
2742
+ const bCount = activeStep.cacheBefore?.entryCount ?? 0
2743
+ const aCount = activeStep.cacheAfter?.entryCount ?? 0
2744
+ cacheSummary.textContent = bCount === aCount
2745
+ ? `${aCount} entr${aCount === 1 ? "y" : "ies"}${cacheChanged ? " (changed)" : ""}`
2746
+ : `${bCount} → ${aCount} entries`
2747
+
2748
+ let cacheExpanded = false
2749
+ const cacheDetail = UILayoutDebugger._el("div", [
2750
+ "display: none",
2751
+ "margin-top: 2px",
2752
+ "padding: 4px 6px",
2753
+ "background: rgba(255,255,255,0.04)",
2754
+ "border-radius: 3px",
2755
+ "color: #9090a8",
2756
+ "font-size: 9px",
2757
+ "line-height: 1.6",
2758
+ "white-space: pre",
2759
+ ])
2760
+ cacheDetail.textContent = cacheDiffStr
2761
+
2762
+ cacheSummary.onclick = () => {
2763
+ cacheExpanded = !cacheExpanded
2764
+ cacheDetail.style.display = cacheExpanded ? "block" : "none"
2765
+ }
2766
+
2767
+ cacheRow.append(cacheLabel, cacheSummary)
2768
+ detail.appendChild(cacheRow)
2769
+ detail.appendChild(cacheDetail)
2770
+
2771
+ const trigger = activeStep.trigger
2772
+ const triggerRow = UILayoutDebugger._el("div", ["margin-top: 4px", "margin-bottom: 2px"])
2773
+ if (trigger) {
2774
+ const triggerLabel = UILayoutDebugger._el("span", ["color: #8888a0", "margin-right: 4px"])
2775
+ triggerLabel.textContent = "setNeedsLayout from"
2776
+
2777
+ const triggerFn = UILayoutDebugger._el("span", ["color: #88ddff", "font-weight: bold", "cursor: pointer"])
2778
+ triggerFn.textContent = trigger.callerFunction + "()"
2779
+ triggerFn.title = trigger.cleanStack
2780
+
2781
+ let stackExpanded = false
2782
+ const stackEl = UILayoutDebugger._el("div", [
2783
+ "display: none",
2784
+ "margin-top: 3px",
2785
+ "padding: 4px 6px",
2786
+ "background: rgba(255,255,255,0.04)",
2787
+ "border-radius: 3px",
2788
+ "color: #7878a0",
2789
+ "font-size: 9px",
2790
+ "line-height: 1.5",
2791
+ "white-space: pre",
2792
+ "overflow-x: auto",
2793
+ ])
2794
+ stackEl.textContent = trigger.cleanStack
2795
+
2796
+ triggerFn.onclick = () => {
2797
+ stackExpanded = !stackExpanded
2798
+ stackEl.style.display = stackExpanded ? "block" : "none"
2799
+ }
2800
+
2801
+ triggerRow.append(triggerLabel, triggerFn)
2802
+ detail.appendChild(triggerRow)
2803
+ detail.appendChild(stackEl)
2804
+ }
2805
+ else {
2806
+ const triggerLabel = UILayoutDebugger._el("span", ["color: #5a5a70"])
2807
+ triggerLabel.textContent = "setNeedsLayout trigger not captured"
2808
+ triggerRow.appendChild(triggerLabel)
2809
+ detail.appendChild(triggerRow)
2810
+ }
2811
+
2812
+ if (activeStep.subviewRecords.length > 0) {
2813
+ const svHeader = UILayoutDebugger._el("div", ["color: #8888a0", "margin-top: 4px", "margin-bottom: 2px"])
2814
+ svHeader.textContent = `Subviews set (${activeStep.subviewRecords.length}):`
2815
+ detail.appendChild(svHeader)
2816
+
2817
+ for (const sv of activeStep.subviewRecords) {
2818
+ const svRow = UILayoutDebugger._el("div", ["padding-left: 8px", "color: #a0a0b8"])
2819
+ const svClass = UILayoutDebugger._el("span", ["color: #d8aacc"])
2820
+ svClass.textContent = sv.className
2821
+ const svEid = UILayoutDebugger._el("span", ["color: #6a6a80"])
2822
+ svEid.textContent = ` #${sv.elementID} `
2823
+ const svFrames = UILayoutDebugger._el("span", ["color: #9090a8"])
2824
+ svFrames.textContent = UILayoutDebugger._formatFrameDiff(sv.frameBefore, sv.frameAfter)
2825
+ svRow.append(svClass, svEid, svFrames)
2826
+ detail.appendChild(svRow)
2827
+ }
2828
+ }
2829
+
2830
+ return detail
2831
+ }
2832
+
2833
+ static _subtreeHasTouched(node: UILayoutDebugTreeNode, countMap: Map<number, number>): boolean {
2834
+ if ((countMap.get(node.viewIndex) ?? 0) > 0) { return true }
2835
+ for (const child of node.children) {
2836
+ if (UILayoutDebugger._subtreeHasTouched(child, countMap)) { return true }
2837
+ }
2838
+ return false
2839
+ }
2840
+
2841
+ static _subtreeContains(node: UILayoutDebugTreeNode, viewIndex: number): boolean {
2842
+ if (node.viewIndex === viewIndex) { return true }
2843
+ for (const child of node.children) {
2844
+ if (UILayoutDebugger._subtreeContains(child, viewIndex)) { return true }
2845
+ }
2846
+ return false
2847
+ }
2848
+
2849
+ static _renderTreeNode(
2850
+ node: UILayoutDebugTreeNode,
2851
+ container: HTMLElement,
2852
+ countMap: Map<number, number>,
2853
+ activeViewIndex: number,
2854
+ expandState: Map<number, boolean> | null,
2855
+ stepMap: Map<number, UILayoutDebugStep>,
2856
+ ): HTMLElement | null {
2857
+ const count = countMap.get(node.viewIndex) ?? 0
2858
+ const isActive = node.viewIndex === activeViewIndex && activeViewIndex >= 0
2859
+ const hasChildren = node.children.length > 0
2860
+
2861
+ // Expand only if this node is on the path to the active view.
2862
+ // If there is no active view, fall back to "has touched descendants"
2863
+ // so the tree isn't entirely collapsed before any step is selected.
2864
+ const isAncestorOfActive = activeViewIndex >= 0
2865
+ ? UILayoutDebugger._subtreeContains(node, activeViewIndex)
2866
+ : node.children.some(c => UILayoutDebugger._subtreeHasTouched(c, countMap))
2867
+ const defaultExpanded = isAncestorOfActive || isActive
2868
+
2869
+ let childrenExpanded: boolean
2870
+ if (expandState !== null) {
2871
+ if (!expandState.has(node.viewIndex)) {
2872
+ expandState.set(node.viewIndex, defaultExpanded)
2873
+ }
2874
+ childrenExpanded = expandState.get(node.viewIndex)!
2875
+ }
2876
+ else {
2877
+ childrenExpanded = defaultExpanded
2878
+ }
2879
+
2880
+ // ── Row ──────────────────────────────────────────────────────────────
2881
+ const row = UILayoutDebugger._el("div", [
2882
+ "display: flex",
2883
+ "align-items: baseline",
2884
+ "padding: 1px 10px 1px " + (10 + node.depth * 12) + "px",
2885
+ "cursor: " + (hasChildren ? "pointer" : "default"),
2886
+ "border-radius: 3px",
2887
+ "margin: 0 4px",
2888
+ isActive
2889
+ ? "background: rgba(100,120,220,0.35); outline: 1px solid #59599b"
2890
+ : "background: transparent",
2891
+ ])
2892
+
2893
+ // Expand/collapse chevron
2894
+ const chevron = UILayoutDebugger._el("span", [
2895
+ "display: inline-block",
2896
+ "width: 10px",
2897
+ "flex-shrink: 0",
2898
+ "color: #7070a0",
2899
+ "font-size: 8px",
2900
+ "margin-right: 2px",
2901
+ "text-align: center",
2902
+ ])
2903
+ chevron.textContent = !hasChildren ? "" : childrenExpanded ? "▾" : "▸"
2904
+
2905
+ // Heat dot
2906
+ const dot = UILayoutDebugger._el("span", [
2907
+ "display: inline-block",
2908
+ "width: 7px",
2909
+ "height: 7px",
2910
+ "border-radius: 50%",
2911
+ "margin-right: 5px",
2912
+ "flex-shrink: 0",
2913
+ "background: " + UILayoutDebugger._heatColor(count),
2914
+ ])
2915
+
2916
+ // Untouched nodes are muted; touched nodes are bright
2917
+ const untouchedColor = count === 0 ? "#9090a8" : "#ffcc88"
2918
+ const className = UILayoutDebugger._el("span", [
2919
+ "color: " + untouchedColor,
2920
+ "font-weight: " + (count > 0 ? "bold" : "normal"),
2921
+ ])
2922
+ className.textContent = node.className
2923
+
2924
+ const eid = UILayoutDebugger._el("span", [
2925
+ "color: " + (count === 0 ? "#6a6a80" : "#a0a0b8"),
2926
+ "margin-left: 4px",
2927
+ ])
2928
+ eid.textContent = `#${node.elementID}`
2929
+
2930
+ const frameStr = node.frame
2931
+ ? ` ${node.frame.left.toFixed(0)},${node.frame.top.toFixed(0)} ${node.frame.width.toFixed(0)}×${node.frame.height.toFixed(0)}`
2932
+ : ""
2933
+ const frameSpan = UILayoutDebugger._el("span", [
2934
+ "color: " + (count === 0 ? "#55556a" : "#7878a0"),
2935
+ "margin-left: 4px",
2936
+ ])
2937
+ frameSpan.textContent = frameStr
2938
+
2939
+ // For touched nodes, show what changed in this pass inline
2940
+ const step = stepMap.get(node.viewIndex)
2941
+ const frameChanged = step
2942
+ ? !UILayoutDebugger._framesEqual(step.frameBefore, step.frameAfter)
2943
+ : false
2944
+ const cacheChanged = step
2945
+ ? !UILayoutDebugger._cachesEqual(step.cacheBefore, step.cacheAfter)
2946
+ : false
2947
+
2948
+ const deltaSpan = UILayoutDebugger._el("span", [
2949
+ "margin-left: 5px",
2950
+ "font-size: 9px",
2951
+ "flex-shrink: 0",
2952
+ ])
2953
+ if (frameChanged && cacheChanged) {
2954
+ deltaSpan.style.color = "#ffaa55"
2955
+ deltaSpan.textContent = "frame+cache"
2956
+ }
2957
+ else if (frameChanged) {
2958
+ deltaSpan.style.color = "#7bc8ff"
2959
+ deltaSpan.textContent = UILayoutDebugger._formatFrameDiff(step!.frameBefore, step!.frameAfter)
2960
+ }
2961
+ else if (cacheChanged) {
2962
+ deltaSpan.style.color = "#ffcc88"
2963
+ deltaSpan.textContent = "cache changed"
2964
+ }
2965
+
2966
+ if (count > 0) {
2967
+ const countBadge = UILayoutDebugger._el("span", [
2968
+ "margin-left: 5px",
2969
+ "background: " + UILayoutDebugger._heatColor(count),
2970
+ "color: #000",
2971
+ "border-radius: 8px",
2972
+ "padding: 0 4px",
2973
+ "font-size: 9px",
2974
+ "font-weight: bold",
2975
+ ])
2976
+ countBadge.textContent = String(count) + "×"
2977
+ if (frameChanged || cacheChanged) {
2978
+ row.append(chevron, dot, className, eid, frameSpan, deltaSpan, countBadge)
2979
+ }
2980
+ else {
2981
+ row.append(chevron, dot, className, eid, frameSpan, countBadge)
2982
+ }
2983
+ }
2984
+ else {
2985
+ row.append(chevron, dot, className, eid, frameSpan)
2986
+ }
2987
+
2988
+ // Tooltip on hover
2989
+ const cacheStr = UILayoutDebugger._formatCacheSnapshot(node.cacheAfterPass)
2990
+ const tooltipLines = [
2991
+ node.className + " #" + node.elementID,
2992
+ "viewIndex: " + node.viewIndex,
2993
+ node.frame
2994
+ ? `frame: ${node.frame.left.toFixed(1)}, ${node.frame.top.toFixed(1)} ${node.frame.width.toFixed(1)}×${node.frame.height.toFixed(1)}`
2995
+ : "frame: (none)",
2996
+ "laid out: " + count + "×",
2997
+ "intrinsic cache (post-pass): " + cacheStr,
2998
+ ]
2999
+ if (step && frameChanged) {
3000
+ tooltipLines.push("frame Δ: " + UILayoutDebugger._formatFrameDiff(step.frameBefore, step.frameAfter))
3001
+ }
3002
+ if (step && cacheChanged) {
3003
+ tooltipLines.push("cache Δ: " + UILayoutDebugger._formatCacheDiff(step.cacheBefore, step.cacheAfter))
3004
+ }
3005
+ row.title = tooltipLines.join("\n")
3006
+
3007
+ container.appendChild(row)
3008
+
3009
+ // ── Children container ───────────────────────────────────────────────
3010
+ if (!hasChildren) { return isActive ? row : null }
3011
+
3012
+ const childContainer = UILayoutDebugger._el("div", [
3013
+ "display: " + (childrenExpanded ? "block" : "none"),
3014
+ ])
3015
+ container.appendChild(childContainer)
3016
+
3017
+ let activeRow: HTMLElement | null = isActive ? row : null
3018
+
3019
+ for (const child of node.children) {
3020
+ const result = UILayoutDebugger._renderTreeNode(
3021
+ child, childContainer, countMap, activeViewIndex, expandState, stepMap
3022
+ )
3023
+ if (result) { activeRow = result }
3024
+ }
3025
+
3026
+ // Toggle expand/collapse on row click.
3027
+ row.onclick = () => {
3028
+ childrenExpanded = !childrenExpanded
3029
+ if (expandState !== null) {
3030
+ expandState.set(node.viewIndex, childrenExpanded)
3031
+ }
3032
+ childContainer.style.display = childrenExpanded ? "block" : "none"
3033
+ chevron.textContent = childrenExpanded ? "▾" : "▸"
3034
+ }
3035
+
3036
+ return activeRow
3037
+ }
3038
+
3039
+
3040
+ // ── Formatting helpers ───────────────────────────────────────────────────
3041
+
3042
+ static _formatFrame(f: UILayoutDebugFrame | null): string {
3043
+ if (!f) { return "(none)" }
3044
+ return `${f.left.toFixed(0)},${f.top.toFixed(0)} ${f.width.toFixed(0)}×${f.height.toFixed(0)}`
3045
+ }
3046
+
3047
+ static _formatFrameDiff(
3048
+ before: UILayoutDebugFrame | null,
3049
+ after: UILayoutDebugFrame | null
3050
+ ): string {
3051
+ if (!before && !after) { return "(no frame data)" }
3052
+ const bounds = UILayoutDebugger._boundsBasedDiff
3053
+ const fmt = (f: UILayoutDebugFrame | null) => {
3054
+ if (!f) { return "(none)" }
3055
+ return bounds
3056
+ ? `${f.width.toFixed(0)}×${f.height.toFixed(0)}`
3057
+ : UILayoutDebugger._formatFrame(f)
3058
+ }
3059
+ if (!before) { return `→ ${fmt(after)}` }
3060
+ if (!after) { return `${fmt(before)} → (none)` }
3061
+ const changed = bounds
3062
+ ? (before.width !== after.width || before.height !== after.height)
3063
+ : (before.left !== after.left || before.top !== after.top ||
3064
+ before.width !== after.width || before.height !== after.height)
3065
+ if (!changed) {
3066
+ return `= ${fmt(after)}`
3067
+ }
3068
+ return `${fmt(before)} → ${fmt(after)}`
3069
+ }
3070
+
3071
+ static _formatCacheSnapshot(c: UILayoutDebugCacheSnapshot | null): string {
3072
+ if (!c) { return "(none)" }
3073
+ const lines: string[] = []
3074
+ if (c.entryCount === 0) {
3075
+ lines.push("intrinsic: empty")
3076
+ }
3077
+ else {
3078
+ const intrinsicLines = Object.entries(c.entries).map(([key, val]) => {
3079
+ const match = key.match(/h_(\d+(?:\.\d+)?)__w_(\d+(?:\.\d+)?)/)
3080
+ const label = match
3081
+ ? (match[1] !== "0" && match[2] !== "0"
3082
+ ? `h≤${match[1]} w≤${match[2]}`
3083
+ : match[2] !== "0" ? `w≤${match[2]}` : `h≤${match[1]}`)
3084
+ : key
3085
+ return ` ${label}: ${val.width.toFixed(0)}×${val.height.toFixed(0)}`
3086
+ })
3087
+ const prefix = c.isShared ? `shared(${c.sharedKey}) ` : ""
3088
+ lines.push(`${prefix}${c.entryCount} entr${c.entryCount === 1 ? "y" : "ies"}`)
3089
+ lines.push(...intrinsicLines)
3090
+ }
3091
+ lines.push(c.hasFrameCache
3092
+ ? `frameCache: ${UILayoutDebugger._formatFrame(c.frameCache)}`
3093
+ : "frameCache: (empty)")
3094
+ lines.push(c.hasVirtualFrameCache
3095
+ ? `virtualFrameCache: ${UILayoutDebugger._formatFrame(c.virtualFrameCache)}`
3096
+ : "virtualFrameCache: (empty)")
3097
+ return lines.join("\n")
3098
+ }
3099
+
3100
+ static _formatCacheDiff(
3101
+ before: UILayoutDebugCacheSnapshot | null,
3102
+ after: UILayoutDebugCacheSnapshot | null
3103
+ ): string {
3104
+ if (!before && !after) { return "(no cache data)" }
3105
+ const bCount = before?.entryCount ?? 0
3106
+ const aCount = after?.entryCount ?? 0
3107
+ if (bCount === 0 && aCount === 0 && !before?.hasFrameCache && !after?.hasFrameCache && !before?.hasVirtualFrameCache && !after?.hasVirtualFrameCache) { return "empty → empty" }
3108
+
3109
+ const lines: string[] = []
3110
+ const allKeys = new Set([
3111
+ ...Object.keys(before?.entries ?? {}),
3112
+ ...Object.keys(after?.entries ?? {}),
3113
+ ])
3114
+ for (const key of allKeys) {
3115
+ const b = before?.entries[key]
3116
+ const a = after?.entries[key]
3117
+ const match = key.match(/h_(\d+(?:\.\d+)?)__w_(\d+(?:\.\d+)?)/)
3118
+ const label = match
3119
+ ? (match[1] !== "0" && match[2] !== "0"
3120
+ ? `h≤${match[1]} w≤${match[2]}`
3121
+ : match[2] !== "0" ? `w≤${match[2]}` : `h≤${match[1]}`)
3122
+ : key
3123
+ if (!b) {
3124
+ lines.push(` + ${label}: ${a!.width.toFixed(0)}×${a!.height.toFixed(0)}`)
3125
+ }
3126
+ else if (!a) {
3127
+ lines.push(` - ${label}: ${b.width.toFixed(0)}×${b.height.toFixed(0)}`)
3128
+ }
3129
+ else if (b.width !== a.width || b.height !== a.height) {
3130
+ lines.push(` ~ ${label}: ${b.width.toFixed(0)}×${b.height.toFixed(0)} → ${a.width.toFixed(0)}×${a.height.toFixed(0)}`)
3131
+ }
3132
+ else {
3133
+ lines.push(` = ${label}: ${a.width.toFixed(0)}×${a.height.toFixed(0)}`)
3134
+ }
3135
+ }
3136
+
3137
+ // Frame cache diff
3138
+ const bHasF = before?.hasFrameCache ?? false
3139
+ const aHasF = after?.hasFrameCache ?? false
3140
+ if (bHasF || aHasF) {
3141
+ if (bHasF && !aHasF) {
3142
+ lines.push(` - frameCache: ${UILayoutDebugger._formatFrame(before!.frameCache)}`)
3143
+ }
3144
+ else if (!bHasF && aHasF) {
3145
+ lines.push(` + frameCache: ${UILayoutDebugger._formatFrame(after!.frameCache)}`)
3146
+ }
3147
+ else {
3148
+ const bf = before!.frameCache, af = after!.frameCache
3149
+ const changed = !bf || !af || bf.top !== af.top || bf.left !== af.left || bf.width !== af.width || bf.height !== af.height
3150
+ lines.push(changed
3151
+ ? ` ~ frameCache: ${UILayoutDebugger._formatFrame(bf)} → ${UILayoutDebugger._formatFrame(af)}`
3152
+ : ` = frameCache: ${UILayoutDebugger._formatFrame(af)}`)
3153
+ }
3154
+ }
3155
+ else {
3156
+ lines.push(" = frameCache: (empty)")
3157
+ }
3158
+
3159
+ // Virtual frame cache diff
3160
+ const bHasV = before?.hasVirtualFrameCache ?? false
3161
+ const aHasV = after?.hasVirtualFrameCache ?? false
3162
+ if (bHasV || aHasV) {
3163
+ if (bHasV && !aHasV) {
3164
+ lines.push(` - virtualFrameCache: ${UILayoutDebugger._formatFrame(before!.virtualFrameCache)}`)
3165
+ }
3166
+ else if (!bHasV && aHasV) {
3167
+ lines.push(` + virtualFrameCache: ${UILayoutDebugger._formatFrame(after!.virtualFrameCache)}`)
3168
+ }
3169
+ else {
3170
+ const bv = before!.virtualFrameCache, av = after!.virtualFrameCache
3171
+ const changed = !bv || !av || bv.top !== av.top || bv.left !== av.left || bv.width !== av.width || bv.height !== av.height
3172
+ lines.push(changed
3173
+ ? ` ~ virtualFrameCache: ${UILayoutDebugger._formatFrame(bv)} → ${UILayoutDebugger._formatFrame(av)}`
3174
+ : ` = virtualFrameCache: ${UILayoutDebugger._formatFrame(av)}`)
3175
+ }
3176
+ }
3177
+
3178
+ const header = bCount === aCount
3179
+ ? `${aCount} entr${aCount === 1 ? "y" : "ies"}`
3180
+ : `${bCount} → ${aCount} entries`
3181
+ return lines.length ? `${header}\n${lines.join("\n")}` : header
3182
+ }
3183
+
3184
+ static _heatColor(count: number): string {
3185
+ if (count === 0) { return "#4a4a5a" }
3186
+ if (count === 1) { return "#3a8" }
3187
+ if (count === 2) { return "#e80" }
3188
+ return "#d33"
3189
+ }
3190
+
3191
+ static _el(tag: string, styles: string[]): HTMLElement {
3192
+ const el = document.createElement(tag)
3193
+ el.style.cssText = styles.join("; ")
3194
+ return el
3195
+ }
3196
+
3197
+ static _btnStyle(color: string): string[] {
3198
+ return [
3199
+ `color: ${color}`,
3200
+ "background: rgba(255,255,255,0.06)",
3201
+ "border: 1px solid rgba(255,255,255,0.1)",
3202
+ "border-radius: 4px",
3203
+ "padding: 1px 6px",
3204
+ "font: inherit",
3205
+ "cursor: pointer",
3206
+ "flex-shrink: 0",
3207
+ ]
3208
+ }
3209
+
3210
+
3211
+ // ── Drag-to-move ─────────────────────────────────────────────────────────
3212
+
3213
+ static _makeDraggable(panel: HTMLElement) {
3214
+ let dragging = false
3215
+ let startX = 0, startY = 0, origRight = 8, origTop = 8
3216
+
3217
+ panel.addEventListener("mousedown", (e: MouseEvent) => {
3218
+ const target = e.target as HTMLElement
3219
+ if (!target.closest("[data-drag-handle]")) { return }
3220
+ dragging = true
3221
+ startX = e.clientX
3222
+ startY = e.clientY
3223
+ origRight = parseInt(panel.style.right, 10) || 8
3224
+ origTop = parseInt(panel.style.top, 10) || 8
3225
+ e.preventDefault()
3226
+ })
3227
+
3228
+ document.addEventListener("mousemove", (e: MouseEvent) => {
3229
+ if (!dragging) { return }
3230
+ const dx = e.clientX - startX
3231
+ const dy = e.clientY - startY
3232
+ panel.style.right = (origRight - dx) + "px"
3233
+ panel.style.top = (origTop + dy) + "px"
3234
+ })
3235
+
3236
+ document.addEventListener("mouseup", () => { dragging = false })
3237
+ }
3238
+
3239
+ }
3240
+
3241
+
3242
+ // ── Window registration & global helpers ─────────────────────────────────────
3243
+
3244
+ window.UILayoutDebugger = UILayoutDebugger
3245
+
3246
+ declare global {
3247
+ interface Window {
3248
+ UILayoutDebugger?: typeof UILayoutDebugger
3249
+ }
3250
+ }
3251
+
3252
+ /// #endif