@typecad/ui 1.0.0-alpha.12 → 1.0.0-alpha.14

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.
Files changed (124) hide show
  1. package/assets/fonts/dejavu/DejaVuSans-Bold.ttf +0 -0
  2. package/assets/fonts/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  3. package/assets/fonts/dejavu/DejaVuSans-Oblique.ttf +0 -0
  4. package/assets/fonts/dejavu/DejaVuSans.ttf +0 -0
  5. package/assets/fonts/dejavu/DejaVuSansMono-Bold.ttf +0 -0
  6. package/assets/fonts/dejavu/DejaVuSansMono.ttf +0 -0
  7. package/assets/fonts/dejavu/LICENSE +187 -0
  8. package/assets/fonts/dejavu/README.md +49 -0
  9. package/dist/cli.js +22 -19
  10. package/dist/engine-index.js +2 -0
  11. package/dist/preview/build-program.d.ts +7 -1
  12. package/dist/preview/build-program.js +94 -15
  13. package/dist/preview/host-gfx.d.ts +3 -0
  14. package/dist/preview/host-gfx.js +30 -3
  15. package/dist/preview/host-ui-runtime.d.ts +115 -0
  16. package/dist/preview/host-ui-runtime.js +927 -46
  17. package/dist/ui-engine/block-layout.js +25 -11
  18. package/dist/ui-engine/color.d.ts +6 -0
  19. package/dist/ui-engine/color.js +80 -2
  20. package/dist/ui-engine/compat-report.d.ts +8 -0
  21. package/dist/ui-engine/compat-report.js +124 -0
  22. package/dist/ui-engine/css-imports.d.ts +4 -0
  23. package/dist/ui-engine/css-imports.js +70 -0
  24. package/dist/ui-engine/css-parser.js +58 -14
  25. package/dist/ui-engine/default-font.d.ts +25 -0
  26. package/dist/ui-engine/default-font.js +80 -0
  27. package/dist/ui-engine/font-assets.d.ts +2 -2
  28. package/dist/ui-engine/font-assets.js +22 -5
  29. package/dist/ui-engine/html-parser.d.ts +4 -0
  30. package/dist/ui-engine/html-parser.js +241 -26
  31. package/dist/ui-engine/image-assets.d.ts +8 -1
  32. package/dist/ui-engine/image-assets.js +35 -2
  33. package/dist/ui-engine/image-decode.d.ts +22 -0
  34. package/dist/ui-engine/image-decode.js +194 -0
  35. package/dist/ui-engine/inline-parser.js +1 -1
  36. package/dist/ui-engine/layout-engine.d.ts +19 -0
  37. package/dist/ui-engine/layout-engine.js +59 -3
  38. package/dist/ui-engine/model.d.ts +18 -0
  39. package/dist/ui-engine/model.js +82 -8
  40. package/dist/ui-engine/runtime-header/blend-bodies.js +29 -9
  41. package/dist/ui-engine/runtime-header/canvas-helpers.js +3 -0
  42. package/dist/ui-engine/runtime-header/canvas-scrollbar.js +115 -111
  43. package/dist/ui-engine/runtime-header/cuttlefish-gfx.js +21 -0
  44. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +182 -179
  45. package/dist/ui-engine/runtime-header/forward-decls.js +215 -167
  46. package/dist/ui-engine/runtime-header/init-press-input.js +138 -135
  47. package/dist/ui-engine/runtime-header/keyboard.js +682 -380
  48. package/dist/ui-engine/runtime-header/node-draw-body.js +1658 -1386
  49. package/dist/ui-engine/runtime-header/paint-order-coords.js +259 -214
  50. package/dist/ui-engine/runtime-header/paint-rects-repair.js +736 -568
  51. package/dist/ui-engine/runtime-header/scroll-physics.js +25 -1
  52. package/dist/ui-engine/runtime-header/state-bindings-nav.js +9 -0
  53. package/dist/ui-engine/runtime-header/structs.js +228 -216
  54. package/dist/ui-engine/runtime-header/text-rendering.js +888 -770
  55. package/dist/ui-engine/runtime-header/tick/bindings-phase.js +118 -116
  56. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +897 -633
  57. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +24 -25
  58. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +78 -23
  59. package/dist/ui-engine/runtime-header/types-defines.js +110 -93
  60. package/dist/ui-engine/shadcn-kit.d.ts +1 -0
  61. package/dist/ui-engine/shadcn-kit.js +506 -0
  62. package/dist/ui-engine/style-resolver.d.ts +4 -0
  63. package/dist/ui-engine/style-resolver.js +58 -6
  64. package/dist/ui-engine/transpile-ui.js +12 -5
  65. package/dist/ui-engine/ua-stylesheet.d.ts +22 -1
  66. package/dist/ui-engine/ua-stylesheet.js +119 -32
  67. package/dist/ui-engine/ui-lowering.js +37 -3
  68. package/dist/ui-engine/ui-registry.js +32 -4
  69. package/dist/ui-engine/yoga-layout.js +57 -52
  70. package/dist/wizard/index.d.ts +2 -0
  71. package/dist/wizard/index.js +1 -0
  72. package/dist/wizard/integration-wizard.d.ts +3 -0
  73. package/dist/wizard/integration-wizard.js +67 -7
  74. package/dist/wizard/package-writer.d.ts +20 -0
  75. package/dist/wizard/package-writer.js +79 -0
  76. package/package.json +11 -5
  77. package/src/cli.ts +90 -87
  78. package/src/engine-index.ts +53 -51
  79. package/src/preview/build-program.ts +810 -734
  80. package/src/preview/host-gfx.ts +30 -3
  81. package/src/preview/host-ui-runtime.ts +4132 -3289
  82. package/src/ui-engine/block-layout.ts +24 -11
  83. package/src/ui-engine/color.ts +77 -2
  84. package/src/ui-engine/compat-report.ts +139 -0
  85. package/src/ui-engine/css-imports.ts +69 -0
  86. package/src/ui-engine/css-parser.ts +64 -15
  87. package/src/ui-engine/default-font.ts +95 -0
  88. package/src/ui-engine/font-assets.ts +545 -525
  89. package/src/ui-engine/html-parser.ts +615 -397
  90. package/src/ui-engine/image-assets.ts +37 -2
  91. package/src/ui-engine/image-decode.ts +244 -0
  92. package/src/ui-engine/inline-parser.ts +1 -1
  93. package/src/ui-engine/layout-engine.ts +61 -3
  94. package/src/ui-engine/model.ts +1322 -1230
  95. package/src/ui-engine/runtime-header/blend-bodies.ts +29 -9
  96. package/src/ui-engine/runtime-header/canvas-helpers.ts +3 -0
  97. package/src/ui-engine/runtime-header/canvas-scrollbar.ts +121 -117
  98. package/src/ui-engine/runtime-header/cuttlefish-gfx.ts +21 -0
  99. package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +188 -185
  100. package/src/ui-engine/runtime-header/forward-decls.ts +227 -179
  101. package/src/ui-engine/runtime-header/init-press-input.ts +144 -141
  102. package/src/ui-engine/runtime-header/keyboard.ts +689 -386
  103. package/src/ui-engine/runtime-header/node-draw-body.ts +1683 -1411
  104. package/src/ui-engine/runtime-header/paint-order-coords.ts +265 -220
  105. package/src/ui-engine/runtime-header/paint-rects-repair.ts +742 -574
  106. package/src/ui-engine/runtime-header/scroll-physics.ts +25 -1
  107. package/src/ui-engine/runtime-header/state-bindings-nav.ts +9 -0
  108. package/src/ui-engine/runtime-header/structs.ts +234 -222
  109. package/src/ui-engine/runtime-header/text-rendering.ts +894 -776
  110. package/src/ui-engine/runtime-header/tick/bindings-phase.ts +124 -122
  111. package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +902 -638
  112. package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +30 -31
  113. package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +78 -23
  114. package/src/ui-engine/runtime-header/types-defines.ts +116 -99
  115. package/src/ui-engine/shadcn-kit.ts +507 -0
  116. package/src/ui-engine/style-resolver.ts +471 -416
  117. package/src/ui-engine/transpile-ui.ts +12 -5
  118. package/src/ui-engine/ua-stylesheet.ts +137 -31
  119. package/src/ui-engine/ui-lowering.ts +486 -452
  120. package/src/ui-engine/ui-registry.ts +556 -524
  121. package/src/ui-engine/yoga-layout.ts +307 -309
  122. package/src/wizard/index.ts +46 -38
  123. package/src/wizard/integration-wizard.ts +689 -619
  124. package/src/wizard/package-writer.ts +98 -0
@@ -75,7 +75,21 @@ function findMatchingCloseParen(text, openIdx) {
75
75
  }
76
76
  return -1;
77
77
  }
78
- function lerpColor(a, b, k100) {
78
+ /** Linear color lerp at the ACTIVE depth (module runtimeColorFormat): 888
79
+ * targets lerp per 8-bit channel; 565 targets lerp per 5/6-bit channel
80
+ * (device parity — the C++ ui_lerp macro switches the same way). Exported
81
+ * for tests. */
82
+ export function lerpColor(a, b, k100) {
83
+ if (runtimeColorFormat === "rgb666" || runtimeColorFormat === "rgb888") {
84
+ if (k100 >= 100)
85
+ return b & 0xffffff;
86
+ const mix = (shift) => {
87
+ const av = (a >> shift) & 0xff;
88
+ const bv = (b >> shift) & 0xff;
89
+ return (av + Math.trunc(((bv - av) * k100) / 100)) & 0xff;
90
+ };
91
+ return (mix(16) << 16) | (mix(8) << 8) | mix(0);
92
+ }
79
93
  if (k100 >= 100)
80
94
  return b & 0xffff;
81
95
  const ar = (a >> 11) & 0x1f;
@@ -114,6 +128,18 @@ function blendRuntime(fg, bg, opacity) {
114
128
  ? blendRgb888(fg, bg, opacity)
115
129
  : blendRgb565(fg, bg, opacity);
116
130
  }
131
+ /** Halve a color at the active depth (device parity: UI_DIM_MASK — 0x7BEF on
132
+ * 565, 0x7F7F7F per channel on 888/666). The 565-only form channel-shifts
133
+ * on rgb888 targets. */
134
+ function dimRuntimeColor(c) {
135
+ return c & ((runtimeColorFormat === "rgb666" || runtimeColorFormat === "rgb888") ? 0x7f7f7f : 0x7bef);
136
+ }
137
+ /** Halve a runtime color for HTML-disabled controls — matches the device's
138
+ * `(c >> 1) & UI_DIM_MASK` (dimRuntimeColor's bare AND barely changes dark
139
+ * 565 colors; the shift gives a real fade on both depths). */
140
+ function dimDisabledColor(c) {
141
+ return (c >> 1) & ((runtimeColorFormat === "rgb666" || runtimeColorFormat === "rgb888") ? 0x7f7f7f : 0x7bef);
142
+ }
117
143
  function mergeClassRules(classes, rules) {
118
144
  const merged = {};
119
145
  const classSet = new Set(classes ?? []);
@@ -244,6 +270,10 @@ export class PreviewUIRuntime {
244
270
  settleFromScrollY = 0; // settle start value (edge snap; +toward 0, -toward max)
245
271
  rangeNode = -1;
246
272
  keyboardVisible = false;
273
+ /** Modal <select> option list: node index while open, -1 when closed. */
274
+ selectMenuNode = -1;
275
+ /** True while the select modal overlay needs (re)stamping this frame. */
276
+ selectMenuDirty = false;
247
277
  keyboardDirty = 0;
248
278
  keyboardTarget = -1;
249
279
  keyboardKeys = [];
@@ -293,6 +323,13 @@ export class PreviewUIRuntime {
293
323
  // Snap draw colors to black/white for mono/e-ink targets (parity with the
294
324
  // device's UI_NATIVE_MONO draw-path snap). No-op for color targets.
295
325
  this.gfx.setMonoSnap(snapshot.program.colorFormat === "mono");
326
+ // Buffer storage depth tracks the target: 565 panels store packed 565,
327
+ // rgb888 stores packed 888, rgb666 stores 888 and quantizes at the canvas
328
+ // push. Without this, 888-packed colors render channel-shifted (the
329
+ // buffer was historically assumed to always hold 565).
330
+ this.gfx.setStorageMode(snapshot.program.colorFormat === "rgb888" ? "rgb888"
331
+ : snapshot.program.colorFormat === "rgb666" ? "rgb666"
332
+ : "rgb565");
296
333
  this.createScreenProxy();
297
334
  }
298
335
  start() {
@@ -302,7 +339,14 @@ export class PreviewUIRuntime {
302
339
  this.seedModuleScope();
303
340
  this.applyInitialAssignments();
304
341
  this.uiInit();
342
+ this.seedDrawersClosed();
305
343
  this.tick(16);
344
+ // Device parity: the device's loop() calls ui_tick() every iteration —
345
+ // that is what advances CSS keyframe animations, transitions, and scroll
346
+ // settles. The browser has no loop(), so drive the same tick from a ~20ms
347
+ // poller (the microtask-pump cadence the pin watcher uses). Idle ticks are
348
+ // cheap: drawDirty() finds nothing and no frame is pushed.
349
+ this.timers.push(setInterval(() => this.tick(), 20));
306
350
  for (const interval of this.intervals) {
307
351
  this.timers.push(setInterval(() => {
308
352
  this.runBody(interval.body);
@@ -355,16 +399,25 @@ export class PreviewUIRuntime {
355
399
  const now = Date.now();
356
400
  const delta = deltaMs ?? Math.max(0, now - this.lastTickTime);
357
401
  this.lastTickTime = now;
402
+ if (this.debugCapture)
403
+ this.debugPaintedRects = [];
358
404
  this.evaluateBindings();
359
405
  this.advanceTransitions(delta);
360
406
  this.advanceAnimations(delta);
407
+ this.applyDrawers(delta);
361
408
  // Advance any in-flight scroll settle animation (bounce-back / edge-snap).
362
409
  for (let i = 0; i < this.nodes.length; i++) {
363
410
  if (this.nodes[i].settling)
364
411
  this.advanceScrollSettle(i);
365
412
  }
366
- const changed = this.drawDirty() || this.directFrameChanged;
413
+ let changed = this.drawDirty() || this.directFrameChanged;
367
414
  this.directFrameChanged = false;
415
+ // Modal <select> list: stamp the overlay whenever the frame beneath it
416
+ // changed (or it was just opened) so redraws never bury it.
417
+ if (this.selectMenuNode >= 0 && (changed || this.selectMenuDirty)) {
418
+ this.drawSelectMenu();
419
+ changed = true;
420
+ }
368
421
  if (changed) {
369
422
  this.onFrame?.(this.gfx.toRgbaBytes());
370
423
  }
@@ -383,6 +436,19 @@ export class PreviewUIRuntime {
383
436
  this.handleNoTouch();
384
437
  this.tick();
385
438
  }
439
+ /** Mouse-wheel scroll: a discrete delta applied to the scroll owner under
440
+ * (x, y) — the same hit-scan a drag uses, without the gesture state
441
+ * machine. Wheel-down (positive deltaPx) shows later content; any
442
+ * overscroll from the tick bounces back via the standard settle. */
443
+ wheel(x, y, deltaPx) {
444
+ const owner = this.findScrollNode(x, y);
445
+ if (owner < 0)
446
+ return;
447
+ if (this.applyScrollDelta(owner, -Math.trunc(deltaPx))) {
448
+ this.releaseScroll(owner);
449
+ }
450
+ this.tick();
451
+ }
386
452
  triggerPin(control) {
387
453
  if (control.kind === "toggle" && control.nodeIndex !== undefined) {
388
454
  const node = this.nodes[control.nodeIndex];
@@ -513,16 +579,31 @@ export class PreviewUIRuntime {
513
579
  }
514
580
  return true;
515
581
  }
582
+ /** Effective stacking z, CSS-style: a non-zero zIndex raises the node AND
583
+ * its subtree — a z-raised panel forms a stacking context, so its
584
+ * descendants stack WITH it (above lower-z siblings), never
585
+ * independently UNDER it (a flat (z, index) sort painted a dialog card
586
+ * over its own children). 0 = "auto" — keep walking ancestors. */
587
+ stackingZ(node) {
588
+ let n = node;
589
+ while (n) {
590
+ const z = Math.trunc(n.zIndex ?? 0);
591
+ if (z !== 0)
592
+ return z;
593
+ n = n.parentIndex >= 0 && n.parentIndex < this.nodes.length ? this.nodes[n.parentIndex] : undefined;
594
+ }
595
+ return 0;
596
+ }
516
597
  drawsBefore(a, b) {
517
- const az = Math.trunc(a.zIndex ?? 0);
518
- const bz = Math.trunc(b.zIndex ?? 0);
598
+ const az = this.stackingZ(a);
599
+ const bz = this.stackingZ(b);
519
600
  if (az !== bz)
520
601
  return az < bz;
521
602
  return a.index < b.index;
522
603
  }
523
604
  compareDrawOrder(a, b) {
524
- const az = Math.trunc(a.zIndex ?? 0);
525
- const bz = Math.trunc(b.zIndex ?? 0);
605
+ const az = this.stackingZ(a);
606
+ const bz = this.stackingZ(b);
526
607
  if (az !== bz)
527
608
  return az - bz;
528
609
  return a.index - b.index;
@@ -539,6 +620,20 @@ export class PreviewUIRuntime {
539
620
  this.keyboardPressedKey = -1;
540
621
  this.keyboardBackspaceHeld = false;
541
622
  }
623
+ this.closeSelectMenu(false);
624
+ this.drawerStates.clear();
625
+ for (const node of this.nodes) {
626
+ const n = node;
627
+ if (n.__drawerDx)
628
+ n.__drawerDx = 0;
629
+ if (n.__drawerDy)
630
+ n.__drawerDy = 0;
631
+ if (n.drawerSide !== undefined) {
632
+ n.transformOffsetX = 0;
633
+ n.transformOffsetY = 0;
634
+ }
635
+ }
636
+ this.seedDrawersClosed();
542
637
  this.gfx.fillScreen(0x0000);
543
638
  for (const node of this.nodes) {
544
639
  node.dirty = true;
@@ -555,6 +650,10 @@ export class PreviewUIRuntime {
555
650
  const value = this.evaluateExpression(binding.expression);
556
651
  if (binding.property === "text") {
557
652
  const text = clampText(value);
653
+ // A text-bound node draws its buffer (build-time hasTextBinding only
654
+ // covers auto-wire/interpolation synthesis — ui.bind targets set it
655
+ // here so the bound string actually renders).
656
+ node.hasTextBinding = true;
558
657
  if (text !== node.textBuffer) {
559
658
  node.textBuffer = text;
560
659
  this.markDirty(binding.nodeIndex);
@@ -830,11 +929,12 @@ export class PreviewUIRuntime {
830
929
  }
831
930
  baseDrawXForNode(nodeIndex) {
832
931
  const node = this.nodes[nodeIndex];
833
- return node.box.x + (node.transformOffsetX ?? 0);
932
+ return node.box.x + (node.transformOffsetX ?? 0) + (node.__drawerDx ?? 0);
834
933
  }
835
934
  baseDrawYForNode(nodeIndex) {
836
- let y = this.nodes[nodeIndex].box.y + (this.nodes[nodeIndex].transformOffsetY ?? 0);
837
- let parent = this.nodes[nodeIndex].parentIndex;
935
+ const node = this.nodes[nodeIndex];
936
+ let y = node.box.y + (node.transformOffsetY ?? 0) + (node.__drawerDy ?? 0);
937
+ let parent = node.parentIndex;
838
938
  while (parent >= 0 && this.nodes[parent]) {
839
939
  if (this.nodes[parent].scrollable) {
840
940
  y -= this.nodes[parent].scrollY;
@@ -883,9 +983,18 @@ export class PreviewUIRuntime {
883
983
  const y0 = Math.min(baseY, baseY + oy);
884
984
  const x1 = Math.max(baseX + node.box.w, baseX + ox + node.box.w);
885
985
  const y1 = Math.max(baseY + node.box.h, baseY + oy + node.box.h);
886
- if (this.repairCurrentNodePaintWithParent(node, { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }))
986
+ // Clip to the node's scroll viewport like every other clear path. During
987
+ // overscroll (rubber-band past the container edge) the box's draw position
988
+ // sits outside the viewport, and the node's own paint was clipped there —
989
+ // an unclipped clear/repair here paints parent background over foreign
990
+ // regions (e.g. the fixed header above the scroll body) and nothing re-
991
+ // dirties that region afterwards, leaving permanent holes.
992
+ const rect = this.intersectClipRect(this.scrollClipForNode(node.index), { x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
993
+ if (rect.w <= 0 || rect.h <= 0)
887
994
  return;
888
- this.gfx.fillRect(x0, y0, x1 - x0, y1 - y0, this.parentClearColor(node));
995
+ if (this.repairCurrentNodePaintWithParent(node, rect))
996
+ return;
997
+ this.gfx.fillRect(rect.x, rect.y, rect.w, rect.h, this.parentClearColor(node));
889
998
  }
890
999
  shadowExtents(node) {
891
1000
  let left = 0;
@@ -1321,6 +1430,7 @@ export class PreviewUIRuntime {
1321
1430
  if (subtreeRect)
1322
1431
  this.markOverlappingHigherLayersDirtyForRect(nodeIndex, subtreeRect);
1323
1432
  node.visible = false;
1433
+ this.reflowAfterVisibility(nodeIndex);
1324
1434
  return;
1325
1435
  }
1326
1436
  node.visible = true;
@@ -1331,6 +1441,170 @@ export class PreviewUIRuntime {
1331
1441
  child.dirty = true;
1332
1442
  this.markOverlappingHigherLayersDirty(i);
1333
1443
  }
1444
+ this.reflowAfterVisibility(nodeIndex);
1445
+ }
1446
+ // ── Visibility reflow (device parity: ui_reflow_visibility) ─────────────
1447
+ // Layout is baked at build time; toggling visible alone leaves a collapsed
1448
+ // pane's space reserved. Re-stack ancestor flow containers from the baked
1449
+ // flowAxis/flowGap/flowFlags metadata, cascading until a container stops
1450
+ // changing, then repaint the active screen once.
1451
+ subtreeFlowExtent(c, axis) {
1452
+ const base = axis === 1 ? this.nodes[c].box.y : this.nodes[c].box.x;
1453
+ let maxEnd = base + (axis === 1 ? this.nodes[c].box.h : this.nodes[c].box.w);
1454
+ const end = Math.min(this.nodes[c].subtreeEnd, this.nodes.length);
1455
+ for (let j = c + 1; j < end; j++) {
1456
+ const rel = (axis === 1 ? this.nodes[j].box.y : this.nodes[j].box.x) - base;
1457
+ if (rel < 0)
1458
+ continue;
1459
+ const jEnd = rel + (axis === 1 ? this.nodes[j].box.h : this.nodes[j].box.w);
1460
+ if (jEnd > maxEnd)
1461
+ maxEnd = jEnd;
1462
+ }
1463
+ return maxEnd - base;
1464
+ }
1465
+ shiftSubtreeMain(c, axis, delta) {
1466
+ const end = Math.min(this.nodes[c].subtreeEnd, this.nodes.length);
1467
+ for (let j = c; j < end; j++) {
1468
+ if (axis === 1)
1469
+ this.nodes[j].box.y += delta;
1470
+ else
1471
+ this.nodes[j].box.x += delta;
1472
+ }
1473
+ }
1474
+ restackFlowContainer(p) {
1475
+ const axis = this.nodes[p].flowAxis === 2 ? 2 : this.nodes[p].flowAxis === 1 ? 1 : 0;
1476
+ if (axis === 0)
1477
+ return false;
1478
+ const gap = this.nodes[p].flowGap ?? 0;
1479
+ let startSlot = 0;
1480
+ let cursor = 0;
1481
+ let sawFlow = false;
1482
+ let sawVisible = false;
1483
+ let changed = false;
1484
+ const end = Math.min(this.nodes[p].subtreeEnd, this.nodes.length);
1485
+ for (let c = p + 1; c < end; c++) {
1486
+ if (this.nodes[c].parentIndex !== p)
1487
+ continue;
1488
+ if (((this.nodes[c].flowFlags ?? 0) & 4) !== 0)
1489
+ continue;
1490
+ const rel = axis === 1
1491
+ ? this.nodes[c].box.y - this.nodes[p].box.y
1492
+ : this.nodes[c].box.x - this.nodes[p].box.x;
1493
+ if (!sawFlow) {
1494
+ startSlot = rel;
1495
+ sawFlow = true;
1496
+ }
1497
+ if (!this.isEffectivelyVisible(this.nodes[c]))
1498
+ continue;
1499
+ if (!sawVisible) {
1500
+ cursor = startSlot;
1501
+ sawVisible = true;
1502
+ }
1503
+ const shift = cursor - rel;
1504
+ if (shift !== 0) {
1505
+ this.shiftSubtreeMain(c, axis, shift);
1506
+ changed = true;
1507
+ }
1508
+ cursor += this.subtreeFlowExtent(c, axis) + gap;
1509
+ }
1510
+ if (!sawVisible)
1511
+ return changed;
1512
+ const autoMain = axis === 1
1513
+ ? ((this.nodes[p].flowFlags ?? 0) & 1)
1514
+ : ((this.nodes[p].flowFlags ?? 0) & 2);
1515
+ if (autoMain) {
1516
+ let newSize = cursor - gap + (axis === 1 ? this.nodes[p].paddingBottom ?? 0 : this.nodes[p].paddingRight ?? 0);
1517
+ if (newSize < 1)
1518
+ newSize = 1;
1519
+ const cur = axis === 1 ? this.nodes[p].box.h : this.nodes[p].box.w;
1520
+ if (newSize !== cur) {
1521
+ if (axis === 1)
1522
+ this.nodes[p].box.h = newSize;
1523
+ else
1524
+ this.nodes[p].box.w = newSize;
1525
+ changed = true;
1526
+ }
1527
+ }
1528
+ return changed;
1529
+ }
1530
+ reflowScrollContent(p) {
1531
+ const node = this.nodes[p];
1532
+ if (!node.scrollable || node.virtualized)
1533
+ return;
1534
+ let maxBottom = node.box.y;
1535
+ const end = Math.min(node.subtreeEnd, this.nodes.length);
1536
+ for (let j = p + 1; j < end; j++) {
1537
+ if (!this.isEffectivelyVisible(this.nodes[j]))
1538
+ continue;
1539
+ const bottom = this.nodes[j].box.y + this.nodes[j].box.h;
1540
+ if (bottom > maxBottom)
1541
+ maxBottom = bottom;
1542
+ }
1543
+ const ch = Math.max(maxBottom - node.box.y, node.box.h);
1544
+ node.contentHeight = ch;
1545
+ const maxScroll = Math.max(0, ch - node.box.h);
1546
+ if (node.scrollY > maxScroll)
1547
+ node.scrollY = maxScroll;
1548
+ node.lastPaintedScrollY = node.scrollY - (node.box.h > 0 ? node.box.h : 1);
1549
+ }
1550
+ reflowAfterVisibility(changedNode) {
1551
+ let p = this.nodes[changedNode]?.parentIndex ?? -1;
1552
+ let anyChange = false;
1553
+ let guard = 0;
1554
+ while (p >= 0 && p < this.nodes.length && guard++ < 64) {
1555
+ const changedHere = this.restackFlowContainer(p);
1556
+ this.reflowScrollContent(p);
1557
+ if (!changedHere)
1558
+ break;
1559
+ anyChange = true;
1560
+ p = this.nodes[p].parentIndex ?? -1;
1561
+ }
1562
+ if (!anyChange)
1563
+ return;
1564
+ for (const node of this.nodes) {
1565
+ if ((node.screenId ?? 0) !== this.activeScreen)
1566
+ continue;
1567
+ node.dirty = true;
1568
+ node.lastTextHeight = 0;
1569
+ }
1570
+ }
1571
+ classifyDirtyAgainstOpenDrawers() {
1572
+ for (const [dIdx, dState] of this.drawerStates) {
1573
+ if (!dState.open || dState.progress < 1)
1574
+ continue;
1575
+ const drawer = this.nodes[dIdx];
1576
+ if (!drawer)
1577
+ continue;
1578
+ const dX = this.drawXForNode(dIdx);
1579
+ const dY = this.drawYForNode(dIdx);
1580
+ for (const node of this.nodes) {
1581
+ if (!node.dirty)
1582
+ continue;
1583
+ if (node.index >= dIdx && node.index < drawer.subtreeEnd)
1584
+ continue;
1585
+ if (!this.isActiveNode(node))
1586
+ continue;
1587
+ const nX = this.drawXForNode(node.index);
1588
+ const nY = this.drawYForNode(node.index);
1589
+ if (nX + node.box.w <= dX || nX >= dX + drawer.box.w ||
1590
+ nY + node.box.h <= dY || nY >= dY + drawer.box.h)
1591
+ continue;
1592
+ const fullyCovered = nX >= dX && nY >= dY &&
1593
+ nX + node.box.w <= dX + drawer.box.w &&
1594
+ nY + node.box.h <= dY + drawer.box.h;
1595
+ if (fullyCovered && drawer.hasBg && drawer.opacity >= 100) {
1596
+ node.dirty = false;
1597
+ }
1598
+ else {
1599
+ drawer.dirty = true;
1600
+ drawer.lastTextHeight = 0;
1601
+ for (let c = dIdx + 1; c < drawer.subtreeEnd && c < this.nodes.length; c++) {
1602
+ this.nodes[c].dirty = true;
1603
+ this.nodes[c].lastTextHeight = 0;
1604
+ }
1605
+ }
1606
+ }
1607
+ }
1334
1608
  }
1335
1609
  scrollClipForNode(nodeIndex) {
1336
1610
  let parent = this.nodes[nodeIndex].parentIndex;
@@ -1374,7 +1648,10 @@ export class PreviewUIRuntime {
1374
1648
  for (const node of this.nodes) {
1375
1649
  if (!this.isActiveNode(node))
1376
1650
  continue;
1377
- if (!node.scrollable || !this.isEffectivelyVisible(node) || node.contentHeight <= node.box.h)
1651
+ // Device parity (dirty-draw-phase): generic scroll passes skip
1652
+ // VIRTUALIZED nodes — lists own their full draw (rows + scrollbar)
1653
+ // in drawListNode. Letting this pass clear a list wipes its rows.
1654
+ if (!node.scrollable || node.virtualized || !this.isEffectivelyVisible(node) || node.contentHeight <= node.box.h)
1378
1655
  continue;
1379
1656
  if (node.dirty) {
1380
1657
  this.markScrollDescendantsDirtyLocal(node.index);
@@ -1397,7 +1674,10 @@ export class PreviewUIRuntime {
1397
1674
  for (const node of this.nodes) {
1398
1675
  if (!this.isActiveNode(node))
1399
1676
  continue;
1400
- if (!node.scrollable || !this.isEffectivelyVisible(node) || node.contentHeight <= node.box.h)
1677
+ // Device parity (dirty-draw-phase): virtualized lists draw their own
1678
+ // scrollbar in drawListNode — drawing here too overdrew it with the
1679
+ // old dim and clamped scrollY against the node-level contentHeight.
1680
+ if (!node.scrollable || node.virtualized || !this.isEffectivelyVisible(node) || node.contentHeight <= node.box.h)
1401
1681
  continue;
1402
1682
  if (!scrollbarDirty.has(node.index))
1403
1683
  continue;
@@ -1405,7 +1685,7 @@ export class PreviewUIRuntime {
1405
1685
  const ty = node.box.y;
1406
1686
  const th = node.box.h;
1407
1687
  node.scrollY = Math.max(0, Math.min(node.scrollY, node.contentHeight - th));
1408
- const trackColor = (node.fg >> 1) & 0x7bef;
1688
+ const trackColor = dimRuntimeColor(node.fg);
1409
1689
  this.gfx.fillRect(tx, ty, 3, th, trackColor);
1410
1690
  const thumbH = Math.max(8, Math.trunc((th * th) / node.contentHeight));
1411
1691
  const thumbY = ty + Math.trunc(((node.box.h - thumbH) * node.scrollY) / Math.max(1, node.contentHeight - th));
@@ -1774,6 +2054,13 @@ export class PreviewUIRuntime {
1774
2054
  if (this.isActiveNode(node) && this.isEffectivelyVisible(node) && node.scrollable && node.dirty)
1775
2055
  scrollbarDirty.add(node.index);
1776
2056
  }
2057
+ // Open-drawer overlap pre-pass (device parity, the dirty-draw loop's
2058
+ // classification): a dirty node under a fully-open opaque drawer paints
2059
+ // its clear+redraw over the panel — a bound text behind the drawer erases
2060
+ // the drawer's buttons. Fully covered -> nothing of it is visible, drop
2061
+ // the dirty flag; partially covered -> re-dirty that drawer's subtree so
2062
+ // it re-stamps on top in this same pass (its z sorts it after).
2063
+ this.classifyDirtyAgainstOpenDrawers();
1777
2064
  const dirtyNodes = this.nodes
1778
2065
  .filter((node) => {
1779
2066
  if (!node.dirty)
@@ -1792,7 +2079,7 @@ export class PreviewUIRuntime {
1792
2079
  for (const node of dirtyNodes) {
1793
2080
  if (!node.dirty)
1794
2081
  continue;
1795
- if (!this.isEffectivelyVisible(node)) {
2082
+ if (!this.isEffectivelyVisible(node) || this.insideClosedDrawer(node.index)) {
1796
2083
  node.dirty = false;
1797
2084
  continue;
1798
2085
  }
@@ -1827,8 +2114,15 @@ export class PreviewUIRuntime {
1827
2114
  if (node.opacity < 100)
1828
2115
  fillBg = blendRuntime(node.bg, this.parentClearColor(node), node.opacity);
1829
2116
  this.gfx.withClipRect(scrollClip, () => {
1830
- node.box.x = baseX;
1831
- this.drawNodeShadow(node, baseY, false);
2117
+ // Lists draw their outset shadow inside drawListNode, on the
2118
+ // full-repaint path only. The shadow rect spans the element, so
2119
+ // pre-drawing it here would paint over the live viewport pixels the
2120
+ // shift path is about to copy (device parity: the C++ main loop also
2121
+ // skips the outset shadow for lists — see node-draw-body.ts).
2122
+ if (node.kind !== "list") {
2123
+ node.box.x = baseX;
2124
+ this.drawNodeShadow(node, baseY, false);
2125
+ }
1832
2126
  node.box.x = drawX;
1833
2127
  switch (node.kind) {
1834
2128
  case "fill":
@@ -1892,9 +2186,72 @@ export class PreviewUIRuntime {
1892
2186
  changed = true;
1893
2187
  node.box.x = origBoxX;
1894
2188
  node.dirty = false;
2189
+ if (this.debugCapture) {
2190
+ this.debugPaintedRects.push({ x: drawX, y: drawY, w: node.box.w, h: node.box.h });
2191
+ }
1895
2192
  }
1896
2193
  return this.drawScrollbars(scrollbarDirty) || changed;
1897
2194
  }
2195
+ /** Enable/disable per-frame debug geometry capture (the client overlay
2196
+ * reads it; zero cost while off). */
2197
+ setDebugCapture(on) {
2198
+ this.debugCapture = on;
2199
+ if (!on)
2200
+ this.debugPaintedRects = [];
2201
+ }
2202
+ /** Current-frame debug geometry for the client overlay: every visible node
2203
+ * on the active screen at its CURRENT draw position (transform/press/
2204
+ * drawer/scroll offsets applied), the scroll viewport clips, and the
2205
+ * regions painted this tick (dirty-flash). Coordinates are logical
2206
+ * display pixels, matching the app canvas 1:1. */
2207
+ debugInfo() {
2208
+ const nodes = [];
2209
+ for (let i = 0; i < this.nodes.length; i++) {
2210
+ const node = this.nodes[i];
2211
+ if (!this.isActiveNode(node) || !this.isEffectivelyVisible(node))
2212
+ continue;
2213
+ if (this.insideClosedDrawer(i))
2214
+ continue;
2215
+ nodes.push({
2216
+ i,
2217
+ id: node.id,
2218
+ tag: node.tag,
2219
+ kind: String(node.kind),
2220
+ x: this.drawXForNode(i),
2221
+ y: this.drawYForNode(i),
2222
+ w: node.box.w,
2223
+ h: node.box.h,
2224
+ tappable: this.hasAnyHandler(i),
2225
+ });
2226
+ }
2227
+ const clips = [];
2228
+ for (const node of this.nodes) {
2229
+ if (!this.isActiveNode(node) || !node.scrollable)
2230
+ continue;
2231
+ clips.push({ x: node.box.x, y: node.box.y, w: node.box.w, h: node.box.h });
2232
+ }
2233
+ return { nodes, clips, painted: this.debugPaintedRects.slice() };
2234
+ }
2235
+ /** Inspect-on-tap: topmost node (draw order) whose CURRENT draw box
2236
+ * contains the point — unlike hitTest, containers count too. Returns the
2237
+ * index, or -1. */
2238
+ debugHit(tx, ty) {
2239
+ let best = -1;
2240
+ for (let i = 0; i < this.nodes.length; i++) {
2241
+ const node = this.nodes[i];
2242
+ if (!this.isActiveNode(node) || !this.isEffectivelyVisible(node))
2243
+ continue;
2244
+ if (this.insideClosedDrawer(i))
2245
+ continue;
2246
+ const x = this.drawXForNode(i);
2247
+ const y = this.drawYForNode(i);
2248
+ if (tx >= x && tx < x + node.box.w && ty >= y && ty < y + node.box.h) {
2249
+ if (best < 0 || this.drawsBefore(this.nodes[best], node))
2250
+ best = i;
2251
+ }
2252
+ }
2253
+ return best;
2254
+ }
1898
2255
  lineX(node, lineWidth, left, maxWidth, align = node.textAlign) {
1899
2256
  if (align === 1)
1900
2257
  return left + Math.trunc((maxWidth - lineWidth) / 2);
@@ -2158,6 +2515,17 @@ export class PreviewUIRuntime {
2158
2515
  const deltaY = node.scrollY - node.lastPaintedScrollY;
2159
2516
  const absDelta = Math.abs(deltaY);
2160
2517
  const canShift = deltaY !== 0 && absDelta < node.box.h;
2518
+ if (!canShift) {
2519
+ // Full repaint: outset shadow at the rest position first, then the bg
2520
+ // fill covers its interior overlap. Skipped on the shift path — the
2521
+ // shadow rect spans the element and would destroy the pixels about to
2522
+ // be shifted (device parity: ui_draw_shadow(i, by, 0) only when
2523
+ // !canShiftList, before the canvas push).
2524
+ const origX = node.box.x;
2525
+ node.box.x = this.baseDrawXForNode(node.index);
2526
+ this.drawNodeShadow(node, this.baseDrawYForNode(node.index), false);
2527
+ node.box.x = origX;
2528
+ }
2161
2529
  const repaint = canShift
2162
2530
  ? this.shiftListViewport(node, drawY, deltaY, bg)
2163
2531
  : (() => {
@@ -2170,7 +2538,10 @@ export class PreviewUIRuntime {
2170
2538
  const listClip = this.intersectClipRect(scrollClip, { x: node.box.x, y: drawY, w: node.box.w, h: node.box.h });
2171
2539
  this.gfx.withClipRect(listClip, () => {
2172
2540
  const tx = node.box.x + node.box.w - 4;
2173
- const trackColor = (node.fg >> 1) & 0x7bef;
2541
+ // Halve the track color at the ACTIVE depth (device parity: the
2542
+ // runtime's UI_DIM_MASK is 0x7BEF on 565, 0x7F7F7F on 888 — the old
2543
+ // 565-only mask channel-shifted the track on rgb888 targets).
2544
+ const trackColor = dimRuntimeColor(node.fg);
2174
2545
  this.gfx.fillRect(tx, drawY, 3, node.box.h, trackColor);
2175
2546
  const thumbH = Math.max(8, Math.trunc((node.box.h * node.box.h) / listContentH));
2176
2547
  const maxScroll = Math.max(1, listContentH - node.box.h);
@@ -2307,7 +2678,11 @@ export class PreviewUIRuntime {
2307
2678
  }
2308
2679
  }
2309
2680
  }
2310
- drawButtonNode(node, displayText, bColor, fillBg, drawY, ts) {
2681
+ drawButtonNode(node, displayText, bColorIn, fillBgIn, drawY, ts) {
2682
+ // HTML disabled: halve the drawn colors (web-like faded control;
2683
+ // device parity: NODE_BUTTON does the same).
2684
+ const bColor = node.disabled ? dimDisabledColor(bColorIn) : bColorIn;
2685
+ const fillBg = node.disabled ? dimDisabledColor(fillBgIn) : fillBgIn;
2311
2686
  if (node.borderRadius > 0 && node.hasBg)
2312
2687
  this.gfx.fillRoundRect(node.box.x, drawY, node.box.w, node.box.h, node.borderRadius, fillBg);
2313
2688
  else if (node.hasBg)
@@ -2334,7 +2709,9 @@ export class PreviewUIRuntime {
2334
2709
  // selected option), so clear the previous text rect before redrawing.
2335
2710
  drawSelectNode(node, displayText, bColor, fillBg, drawY, ts) {
2336
2711
  const insets = this.textInsets(node);
2337
- const contentW = Math.max(1, node.box.w - insets.left - insets.right);
2712
+ // The right end reserves 14px for the dropdown chevron — the label wraps
2713
+ // against the reduced width, never under the chevron.
2714
+ const contentW = Math.max(1, node.box.w - insets.left - insets.right - 14);
2338
2715
  const layout = this.textLayout(node, displayText, contentW, ts);
2339
2716
  const clearW = Math.max(node.box.w, node.lastTextWidth ?? 0, layout.width + insets.left + insets.right);
2340
2717
  const clearH = Math.max(node.box.h, node.lastTextHeight ?? 0, layout.height + insets.top + insets.bottom);
@@ -2351,34 +2728,75 @@ export class PreviewUIRuntime {
2351
2728
  this.drawNodeBorder(node, node.box.x, drawY, bColor);
2352
2729
  const textX = node.box.x + insets.left;
2353
2730
  const textY = drawY + insets.top;
2354
- const textW = Math.max(1, node.box.w - insets.left - insets.right);
2731
+ // Reserve the right end for the dropdown chevron so the label never
2732
+ // collides with it (mirrors NODE_SELECT on device).
2733
+ const textW = Math.max(1, node.box.w - insets.left - insets.right - 14);
2355
2734
  const textH = Math.max(1, node.box.h - insets.top - insets.bottom);
2356
2735
  const top = textY + Math.trunc((textH - layout.height) / 2);
2357
2736
  const glyphBg = node.opacity < 100 ? blendRuntime(node.bg, this.parentClearColor(node), node.opacity) : (node.hasBg ? node.bg : node.clearColor);
2358
2737
  this.drawTextLines(node, displayText, textX, top, textW, ts, node.fg, glyphBg, node.textAlign);
2738
+ // Dropdown chevron: a small solid ▾ at the right end — the affordance
2739
+ // that the control opens/cycles options.
2740
+ const chX = node.box.x + node.box.w - insets.right - 11;
2741
+ const chY = drawY + Math.trunc((node.box.h - 5) / 2);
2742
+ this.gfx.fillRect(chX, chY, 9, 1, node.fg);
2743
+ this.gfx.fillRect(chX + 1, chY + 1, 7, 1, node.fg);
2744
+ this.gfx.fillRect(chX + 2, chY + 2, 5, 1, node.fg);
2745
+ this.gfx.fillRect(chX + 3, chY + 3, 3, 1, node.fg);
2746
+ this.gfx.fillRect(chX + 4, chY + 4, 1, 1, node.fg);
2359
2747
  }
2360
2748
  drawCheckNode(node, displayText, drawY, ts) {
2361
2749
  const layout = this.textLayout(node, displayText, Math.max(0, node.box.w - 22), ts);
2362
2750
  const clearW = Math.max(node.box.w, node.lastTextWidth);
2363
2751
  const clearH = Math.max(node.box.h, node.lastTextHeight ?? 0, layout.height);
2364
- this.gfx.fillRect(node.box.x, drawY, clearW, clearH, node.hasBg ? node.bg : node.clearColor);
2752
+ // border-radius > 0 (kit .switch pills): clear the full text rect to the
2753
+ // backdrop, then paint the rounded box. Device parity: NODE_CHECK does the
2754
+ // same via ui_display_fill_round_rect. The :checked pair (kit wires it to
2755
+ // --primary/--primary-foreground) swaps the TRACK color when on.
2756
+ const onFill = node.value && node.checkedBg >= 0 ? node.checkedBg : node.bg;
2757
+ if (node.borderRadius > 0 && node.hasBg) {
2758
+ this.gfx.fillRect(node.box.x, drawY, clearW, clearH, this.parentClearColor(node));
2759
+ this.gfx.fillRoundRect(node.box.x, drawY, node.box.w, node.box.h, node.borderRadius, onFill);
2760
+ }
2761
+ else {
2762
+ this.gfx.fillRect(node.box.x, drawY, clearW, clearH, node.hasBg ? node.bg : node.clearColor);
2763
+ }
2365
2764
  node.lastTextWidth = 22 + layout.width;
2366
2765
  node.lastTextHeight = Math.max(layout.height, 16);
2367
- const cbX = node.box.x;
2766
+ // Pills inset the 16px indicator from the left edge so the knob doesn't
2767
+ // touch the rounded end; plain checkboxes keep it flush (unchanged look).
2768
+ // The knob SLIDES with state — left when off, right when on — the switch
2769
+ // affordance (static jump; no travel animation).
2770
+ const knobSlide = node.borderRadius > 0 ? Math.max(0, node.box.w - 16 - 6) : 0;
2771
+ const cbX = (node.borderRadius > 0 ? node.box.x + 3 : node.box.x) + (node.value ? knobSlide : 0);
2368
2772
  // Vertically center the 16px indicator within the box so a tall
2369
2773
  // (touch-friendly) checkbox doesn't pin the indicator to the top.
2370
2774
  // (box.h - 16) / 2 is 0 for the default 16px-tall box.
2371
2775
  const checkOff = Math.max(0, ((node.box.h - 16) / 2) | 0);
2372
2776
  const cbY = drawY + checkOff;
2373
2777
  if (node.value) {
2374
- this.gfx.fillRect(cbX, cbY, 16, 16, node.fg);
2375
- const inv = node.hasBg ? node.bg : node.clearColor;
2376
- this.gfx.drawLine(cbX + 3, cbY + 8, cbX + 7, cbY + 12, inv);
2377
- this.gfx.drawLine(cbX + 4, cbY + 8, cbX + 8, cbY + 12, inv);
2378
- this.gfx.drawLine(cbX + 3, cbY + 9, cbX + 7, cbY + 13, inv);
2379
- this.gfx.drawLine(cbX + 7, cbY + 12, cbX + 13, cbY + 4, inv);
2380
- this.gfx.drawLine(cbX + 8, cbY + 12, cbX + 14, cbY + 4, inv);
2381
- this.gfx.drawLine(cbX + 7, cbY + 13, cbX + 13, cbY + 5, inv);
2778
+ if (node.borderRadius > 0) {
2779
+ // Switch pill: the knob stays a knob — a solid circle when on, no
2780
+ // checkbox square + checkmark. Same geometry as the radio indicator.
2781
+ // The knob carries the :checked color (primary-foreground).
2782
+ this.gfx.fillCircle(cbX + 8, cbY + 8, 7, node.checkedFg >= 0 ? node.checkedFg : node.fg);
2783
+ }
2784
+ else {
2785
+ // Checked face carries the :checked background (primary), the
2786
+ // checkmark its color (primary-foreground).
2787
+ this.gfx.fillRect(cbX, cbY, 16, 16, node.checkedBg >= 0 ? node.checkedBg : node.fg);
2788
+ const inv = node.checkedFg >= 0 ? node.checkedFg : (node.hasBg ? node.bg : node.clearColor);
2789
+ this.gfx.drawLine(cbX + 3, cbY + 8, cbX + 7, cbY + 12, inv);
2790
+ this.gfx.drawLine(cbX + 4, cbY + 8, cbX + 8, cbY + 12, inv);
2791
+ this.gfx.drawLine(cbX + 3, cbY + 9, cbX + 7, cbY + 13, inv);
2792
+ this.gfx.drawLine(cbX + 7, cbY + 12, cbX + 13, cbY + 4, inv);
2793
+ this.gfx.drawLine(cbX + 8, cbY + 12, cbX + 14, cbY + 4, inv);
2794
+ this.gfx.drawLine(cbX + 7, cbY + 13, cbX + 13, cbY + 5, inv);
2795
+ }
2796
+ }
2797
+ else if (node.borderRadius > 0) {
2798
+ // Off-state pill: hollow circular knob outline on the track.
2799
+ this.gfx.drawCircle(cbX + 8, cbY + 8, 7, node.fg);
2382
2800
  }
2383
2801
  else {
2384
2802
  this.gfx.drawRect(cbX, cbY, 16, 16, node.fg);
@@ -2397,7 +2815,8 @@ export class PreviewUIRuntime {
2397
2815
  const radioOff = Math.max(0, ((node.box.h - 16) / 2) | 0);
2398
2816
  const cbY = drawY + radioOff;
2399
2817
  if (node.value) {
2400
- this.gfx.fillCircle(cbX + 8, cbY + 8, 7, node.fg);
2818
+ // The selected ring carries the :checked background (primary).
2819
+ this.gfx.fillCircle(cbX + 8, cbY + 8, 7, node.checkedBg >= 0 ? node.checkedBg : node.fg);
2401
2820
  this.gfx.fillCircle(cbX + 8, cbY + 8, 3, node.hasBg ? node.bg : node.clearColor);
2402
2821
  }
2403
2822
  else {
@@ -2436,7 +2855,7 @@ export class PreviewUIRuntime {
2436
2855
  const bh = node.box.h;
2437
2856
  const fgCol = node.fg;
2438
2857
  const bgCol = node.hasBg ? node.bg : node.clearColor;
2439
- const dimFg = (fgCol >> 1) & 0x7bef;
2858
+ const dimFg = dimRuntimeColor(fgCol);
2440
2859
  const trackY = by + Math.trunc(bh / 2);
2441
2860
  const rangeMin = node.rangeMin;
2442
2861
  const rangeMax = node.rangeMax;
@@ -2477,13 +2896,16 @@ export class PreviewUIRuntime {
2477
2896
  const by = drawY;
2478
2897
  const bw = node.box.w;
2479
2898
  const bh = node.box.h;
2480
- const bgCol = node.hasBg ? node.bg : node.clearColor;
2481
- const fgCol = node.fg;
2482
- const border = node.borderColor || fgCol;
2899
+ // HTML disabled: halve the drawn colors (web-like faded control;
2900
+ // device parity: NODE_INPUT does the same).
2901
+ const dis = node.disabled === true;
2902
+ const bgCol = node.hasBg ? (dis ? dimDisabledColor(node.bg) : node.bg) : (dis ? dimDisabledColor(node.clearColor) : node.clearColor);
2903
+ const fgCol = dis ? dimDisabledColor(node.fg) : node.fg;
2904
+ const border = dis ? dimDisabledColor(node.borderColor || node.fg) : (node.borderColor || fgCol);
2483
2905
  const borderStyle = node.borderStyle || 1;
2484
2906
  const borderWidth = node.borderWidth || 1;
2485
2907
  const displayText = node.textBuffer || node.placeholder || node.text || "";
2486
- const textColor = node.textBuffer ? fgCol : ((fgCol >> 1) & 0x7bef);
2908
+ const textColor = node.textBuffer ? fgCol : dimRuntimeColor(fgCol);
2487
2909
  const ts = this.nodeTextSize(node);
2488
2910
  const clippedText = this.clipTextToWidth(displayText, Math.max(0, bw - 8), ts, node.fontFace, node.letterSpacing);
2489
2911
  if (node.borderRadius > 0)
@@ -2494,13 +2916,25 @@ export class PreviewUIRuntime {
2494
2916
  this.drawText(clippedText, bx + 4, by + Math.trunc((bh - this.textHeight(ts, node.fontFace)) / 2), textColor, bgCol, ts, node.fontAntialias, node.fontFace, node.letterSpacing);
2495
2917
  }
2496
2918
  hitTest(tx, ty) {
2497
- let best = -1;
2919
+ // Phase 1 — the event target: the TOPMOST node containing the tap
2920
+ // (stacking order), regardless of handlers. Filtering for
2921
+ // handler-bearing nodes here let a tap fall through SIBLING layers — a
2922
+ // modal card (no handler) over a click-to-close scrim (handler) sent
2923
+ // every card tap to the scrim, so any tap dismissed the dialog. The DOM
2924
+ // rule: the click targets the topmost element, then bubbles to
2925
+ // ANCESTORS only — never sideways to a covered sibling.
2926
+ let target = -1;
2498
2927
  for (let i = 0; i < this.nodes.length; i++) {
2499
2928
  const node = this.nodes[i];
2500
2929
  if (!this.isEffectivelyVisible(node))
2501
2930
  continue;
2502
2931
  if (!this.isActiveNode(node))
2503
2932
  continue;
2933
+ if (this.insideClosedDrawer(i))
2934
+ continue;
2935
+ // HTML disabled: not a tap target (device parity: ui_hit_test skips it).
2936
+ if (node.disabled)
2937
+ continue;
2504
2938
  const drawX = this.drawXForNode(i);
2505
2939
  const drawY = this.drawYForNode(i);
2506
2940
  if (tx >= drawX && tx < drawX + node.box.w && ty >= drawY && ty < drawY + node.box.h) {
@@ -2511,11 +2945,23 @@ export class PreviewUIRuntime {
2511
2945
  const clip = this.scrollClipForNode(i);
2512
2946
  if (clip && (tx < clip.x || tx >= clip.x + clip.w || ty < clip.y || ty >= clip.y + clip.h))
2513
2947
  continue;
2514
- if (this.hasAnyHandler(i) && (best < 0 || this.drawsBefore(this.nodes[best], node)))
2515
- best = i;
2948
+ if (target < 0 || this.drawsBefore(this.nodes[target], node))
2949
+ target = i;
2516
2950
  }
2517
2951
  }
2518
- return best;
2952
+ // Phase 2 — bubbling: from the target up through its ancestors.
2953
+ // Interactive kinds match even with no JS handler wired (:pressed/
2954
+ // transition feedback); otherwise the first handler-bearing ancestor.
2955
+ let n = target;
2956
+ while (n >= 0) {
2957
+ const node = this.nodes[n];
2958
+ if (this.hasAnyHandler(n))
2959
+ return n;
2960
+ if (node.parentIndex < 0 || node.parentIndex >= this.nodes.length)
2961
+ break;
2962
+ n = node.parentIndex;
2963
+ }
2964
+ return -1;
2519
2965
  }
2520
2966
  // Unified scroll hit-scan: one pass over scrollable nodes. Lists are
2521
2967
  // scrollable (UA rule) and their contentHeight is seeded on the node by
@@ -2712,6 +3158,16 @@ export class PreviewUIRuntime {
2712
3158
  this.lastTouchTime = now;
2713
3159
  return;
2714
3160
  }
3161
+ // Modal <select> list: route taps to the option rows; swallow normal
3162
+ // hit-testing/scrolling while open (device parity: ui_touch_up).
3163
+ if (this.selectMenuNode >= 0) {
3164
+ if (this.touchState === 0 && now - this.lastTouchTime >= UI_TOUCH_DEBOUNCE_MS) {
3165
+ this.touchState = 1;
3166
+ this.touchDownTime = now;
3167
+ }
3168
+ this.lastTouchTime = now;
3169
+ return;
3170
+ }
2715
3171
  if (this.touchState === 0) {
2716
3172
  if (now - this.lastTouchTime < UI_TOUCH_DEBOUNCE_MS)
2717
3173
  return;
@@ -2788,6 +3244,16 @@ export class PreviewUIRuntime {
2788
3244
  this.lastReleaseTime = now;
2789
3245
  return;
2790
3246
  }
3247
+ if (this.selectMenuNode >= 0) {
3248
+ this.selectMenuHandleTouch(this.lastTouchX, this.lastTouchY);
3249
+ this.touchState = 0;
3250
+ this.touchNode = -1;
3251
+ this.isDragging = false;
3252
+ this.scrollNode = -1;
3253
+ this.rangeNode = -1;
3254
+ this.lastReleaseTime = now;
3255
+ return;
3256
+ }
2791
3257
  const elapsed = now - this.touchDownTime;
2792
3258
  const node = this.touchNode;
2793
3259
  // Release the scroll owner: arm a bounded settle (bounce-back / edge-snap).
@@ -2813,6 +3279,19 @@ export class PreviewUIRuntime {
2813
3279
  this.setPressed(node, false);
2814
3280
  this.markDirty(node);
2815
3281
  }
3282
+ // Open drawers close on outside taps (shadcn drawer behavior). Taps
3283
+ // inside an open drawer hit its controls normally.
3284
+ for (const [idx, state] of this.drawerStates) {
3285
+ if (!state.open || state.progress < 1)
3286
+ continue;
3287
+ const drawer = this.nodes[idx];
3288
+ const dx = this.drawXForNode(idx);
3289
+ const dy = this.drawYForNode(idx);
3290
+ if (this.lastTouchX < dx || this.lastTouchX >= dx + drawer.box.w ||
3291
+ this.lastTouchY < dy || this.lastTouchY >= dy + drawer.box.h) {
3292
+ state.open = false;
3293
+ }
3294
+ }
2816
3295
  // Resume any `await ui.onTap()` awaiter. Runs for EVERY completed tap —
2817
3296
  // including empty-space taps (node == -1) and holds released above — so
2818
3297
  // "wake on any touch" works. After the click/release dispatch so onClick
@@ -2833,9 +3312,9 @@ export class PreviewUIRuntime {
2833
3312
  this.markDirty(nodeIndex);
2834
3313
  }
2835
3314
  else if (node.tag === "select") {
2836
- const count = Math.max(node.options?.length ?? 0, 1);
2837
- node.value = (node.value + 1) % count;
2838
- this.markDirty(nodeIndex);
3315
+ // Tap opens a modal option list (device parity: ui_select_menu_open);
3316
+ // selecting a row in the modal sets the value.
3317
+ this.openSelectMenu(nodeIndex);
2839
3318
  }
2840
3319
  else if (node.tag === "radio") {
2841
3320
  for (const candidate of this.nodes) {
@@ -2861,6 +3340,317 @@ export class PreviewUIRuntime {
2861
3340
  }
2862
3341
  return node.inputType === "number" ? DEFAULT_NUMBER_KEYBOARD : DEFAULT_ALPHA_KEYBOARD;
2863
3342
  }
3343
+ // ── <drawer>: slide-in panel modals ─────────────────────────────────────
3344
+ // Drawers are author-styled absolute panels (position/left/top/width/height
3345
+ // in CSS); the runtime slides the subtree in from the configured edge via
3346
+ // per-node transform offsets, hides the subtree while closed, and closes on
3347
+ // outside taps. Sliding mutates offsets + dirties the subtree exactly like
3348
+ // the keyframe transform path, so all existing clear/repair machinery runs.
3349
+ /** node index -> { open, progress } for every <drawer>; absent = closed. */
3350
+ drawerStates = new Map();
3351
+ // ── Debug overlay data (preview-only; the client canvas draws the boxes) ──
3352
+ /** True while the client wants per-frame debug geometry + paint rects. */
3353
+ debugCapture = false;
3354
+ /** Node paint rects drawn during this tick (cleared at tick start). */
3355
+ debugPaintedRects = [];
3356
+ /** Drawer animation step per tick (~180ms full slide). */
3357
+ /** <toast> auto-close windows, keyed by drawer slot node index. */
3358
+ toastElapsed = new Map();
3359
+ applyDrawers(deltaMs) {
3360
+ // <toast duration>: once fully open, count up and auto-close.
3361
+ for (const [idx, state] of this.drawerStates) {
3362
+ const node = this.nodes[idx];
3363
+ const duration = node.toastDuration ?? 0;
3364
+ if (duration > 0 && state.open && state.progress === 1) {
3365
+ const elapsed = (this.toastElapsed.get(idx) ?? 0) + deltaMs;
3366
+ if (elapsed >= duration) {
3367
+ state.open = false;
3368
+ this.toastElapsed.delete(idx);
3369
+ }
3370
+ else {
3371
+ this.toastElapsed.set(idx, elapsed);
3372
+ }
3373
+ }
3374
+ else if (!state.open) {
3375
+ this.toastElapsed.delete(idx);
3376
+ }
3377
+ }
3378
+ for (const [idx, state] of this.drawerStates) {
3379
+ const target = state.open ? 1 : 0;
3380
+ if (state.progress === target)
3381
+ continue;
3382
+ const step = Math.min(1, deltaMs / 180);
3383
+ const next = state.progress + Math.sign(target - state.progress) * Math.min(step, Math.abs(target - state.progress));
3384
+ this.setDrawerProgress(idx, next);
3385
+ if (next === 0 && !state.open)
3386
+ this.drawerStates.delete(idx);
3387
+ }
3388
+ }
3389
+ drawerNodeById(id) {
3390
+ return this.nodes.find((n) => (n.drawerSide ?? -1) >= 0 && n.id === id);
3391
+ }
3392
+ drawerOpen(id) {
3393
+ const node = this.drawerNodeById(id);
3394
+ if (!node)
3395
+ return;
3396
+ this.toastElapsed.delete(node.index); // <toast>: restart the auto-close window
3397
+ if (!this.drawerStates.has(node.index)) {
3398
+ this.drawerStates.set(node.index, { open: true, progress: 0 });
3399
+ this.setDrawerProgress(node.index, 0);
3400
+ }
3401
+ else {
3402
+ this.drawerStates.get(node.index).open = true;
3403
+ }
3404
+ }
3405
+ drawerClose(id) {
3406
+ if (id !== undefined) {
3407
+ const node = this.drawerNodeById(id);
3408
+ if (node) {
3409
+ const st = this.drawerStates.get(node.index);
3410
+ if (st)
3411
+ st.open = false;
3412
+ }
3413
+ return;
3414
+ }
3415
+ for (const st of this.drawerStates.values())
3416
+ st.open = false;
3417
+ }
3418
+ /** Slide extent for a drawer or toast: the panel must clear the display
3419
+ * edge completely — its own height parks a bottom:0 panel's top row at
3420
+ * the fold (a visible sliver after close). Centered dialogs don't slide. */
3421
+ drawerExtent(node) {
3422
+ const n = node;
3423
+ const side = n.drawerSide ?? 0;
3424
+ const extent = side === 2 || side === 3 ? node.box.w : node.box.h;
3425
+ if (side === 4)
3426
+ return 0;
3427
+ // Rest position (box minus ancestor scroll; transform offsets hold the
3428
+ // current slide), then clear the display edge along the travel axis.
3429
+ let restX = node.box.x;
3430
+ let restY = node.box.y;
3431
+ let parent = node.parentIndex;
3432
+ while (parent >= 0 && this.nodes[parent]) {
3433
+ const pn = this.nodes[parent];
3434
+ if (pn.scrollable)
3435
+ restY -= pn.scrollY;
3436
+ parent = pn.parentIndex;
3437
+ }
3438
+ const need = side === 0 ? this.snapshot.program.height - restY
3439
+ : side === 1 ? restY + node.box.h
3440
+ : side === 2 ? restX + node.box.w
3441
+ : this.snapshot.program.width - restX;
3442
+ return Math.max(extent, need);
3443
+ }
3444
+ /** Apply a slide progress to the drawer subtree: offsets shift from the
3445
+ * edge; the subtree is hidden at 0. Erases the previous frame's region via
3446
+ * the standard subtree clear + marks descendants dirty (same contract as
3447
+ * animated transform changes). */
3448
+ setDrawerProgress(nodeIndex, progress) {
3449
+ const state = this.drawerStates.get(nodeIndex);
3450
+ if (!state)
3451
+ return;
3452
+ const node = this.nodes[nodeIndex];
3453
+ const side = node.drawerSide ?? 0;
3454
+ const travel = Math.round((1 - progress) * this.drawerExtent(node));
3455
+ const dx = side === 2 ? -travel : side === 3 ? travel : 0;
3456
+ const dy = side === 1 ? -travel : side === 0 ? travel : 0;
3457
+ // Erase the subtree's current paint before moving it. The clear fills
3458
+ // with the parent background only — everything the drawer COVERED
3459
+ // (siblings underneath) must repaint too, so mark every active-screen
3460
+ // node outside the drawer subtree dirty. Full-screen redraw per slide
3461
+ // frame is the same contract as the select modal's close path.
3462
+ const rect = this.currentSubtreePaintRect(node);
3463
+ if (rect)
3464
+ this.clearNodePaintRect(node, rect);
3465
+ for (const n of this.nodes) {
3466
+ if (!this.isActiveNode(n) || n.index >= node.index && n.index < node.subtreeEnd)
3467
+ continue;
3468
+ if (this.insideClosedDrawer(n.index))
3469
+ continue;
3470
+ n.dirty = true;
3471
+ }
3472
+ for (let i = node.index; i < node.subtreeEnd; i++) {
3473
+ const n = this.nodes[i];
3474
+ if (!this.isActiveNode(n))
3475
+ continue;
3476
+ n.transformOffsetX = i === node.index ? dx : n.transformOffsetX;
3477
+ n.transformOffsetY = i === node.index ? dy : n.transformOffsetY;
3478
+ if (i !== node.index) {
3479
+ // Descendants keep a private offset delta (they don't inherit the
3480
+ // drawer's transformOffset), so store the slide delta per node.
3481
+ n.__drawerDx = dx;
3482
+ n.__drawerDy = dy;
3483
+ }
3484
+ n.dirty = true;
3485
+ }
3486
+ state.progress = progress;
3487
+ this.directFrameChanged = true;
3488
+ }
3489
+ /** True while the node sits inside a fully-closed drawer (skip in draws and
3490
+ * hit tests). Drawers with no state entry are closed. */
3491
+ insideClosedDrawer(nodeIndex) {
3492
+ // Centered overlay roots (<dialog>, side 4) hide via the closed-state
3493
+ // gate: their travel is 0, so offsets can't move them off-panel. Edge
3494
+ // drawer/toast roots keep offset-only hiding (the bottom peek).
3495
+ const self = this.nodes[nodeIndex];
3496
+ if ((self.drawerSide ?? -1) === 4) {
3497
+ const st = this.drawerStates.get(nodeIndex);
3498
+ return !st || (!st.open && st.progress === 0);
3499
+ }
3500
+ for (const [idx, state] of this.drawerStates) {
3501
+ const drawer = this.nodes[idx];
3502
+ if (nodeIndex >= drawer.index && nodeIndex < drawer.subtreeEnd && state.progress === 0 && !state.open)
3503
+ return true;
3504
+ }
3505
+ return this.drawerAncestorClosed(nodeIndex);
3506
+ }
3507
+ /** Walk ancestors: any drawer ancestor without an open/animating state. */
3508
+ drawerAncestorClosed(nodeIndex) {
3509
+ let parent = this.nodes[nodeIndex]?.parentIndex ?? -1;
3510
+ while (parent >= 0 && this.nodes[parent]) {
3511
+ const n = this.nodes[parent];
3512
+ if ((n.drawerSide ?? -1) >= 0) {
3513
+ const st = this.drawerStates.get(parent);
3514
+ return !st || (!st.open && st.progress === 0);
3515
+ }
3516
+ parent = this.nodes[parent].parentIndex;
3517
+ }
3518
+ return false;
3519
+ }
3520
+ /** Apply the closed slide offsets to every drawer subtree (initial seed and
3521
+ * post-navigate reset) so closed drawers never paint at rest position. */
3522
+ seedDrawersClosed() {
3523
+ for (const n of this.nodes) {
3524
+ const node = n;
3525
+ if ((node.drawerSide ?? -1) < 0)
3526
+ continue;
3527
+ if (this.drawerStates.has(node.index))
3528
+ continue;
3529
+ const side = node.drawerSide ?? 0;
3530
+ const travel = this.drawerExtent(node);
3531
+ const dx = side === 2 ? -travel : side === 3 ? travel : 0;
3532
+ const dy = side === 1 ? -travel : side === 0 ? travel : 0;
3533
+ node.transformOffsetX = (node.transformOffsetX ?? 0) + dx;
3534
+ node.transformOffsetY = (node.transformOffsetY ?? 0) + dy;
3535
+ for (let i = node.index + 1; i < node.subtreeEnd; i++) {
3536
+ const d = this.nodes[i];
3537
+ d.__drawerDx = dx;
3538
+ d.__drawerDy = dy;
3539
+ d.dirty = false;
3540
+ }
3541
+ }
3542
+ }
3543
+ /** Shared geometry of the select modal: a centered list panel sized to
3544
+ * the options (capped to ~60% of the screen height). Rows are option
3545
+ * text, the current one inverted (fill = select bg, text = inverted). */
3546
+ selectMenuGeometry() {
3547
+ const node = this.nodes[this.selectMenuNode];
3548
+ const options = node.options ?? [];
3549
+ const rowH = 22;
3550
+ const rows = options.length;
3551
+ // Anchor to the select itself: same left edge and width, so the modal
3552
+ // reads as the control's own dropdown (never overhangs toward a
3553
+ // scrollbar). Clamp to the screen for selects that extend past it.
3554
+ let x = node.box.x;
3555
+ let w = Math.max(node.box.w, 120);
3556
+ if (x + w > this.gfx.width)
3557
+ w = this.gfx.width - x;
3558
+ if (x < 0)
3559
+ x = 0;
3560
+ const h = Math.min(Math.trunc(this.gfx.height * 0.6), rows * rowH + 8);
3561
+ const y = Math.trunc((this.gfx.height - h) / 2);
3562
+ return { x, y, w, rowH, rows };
3563
+ }
3564
+ openSelectMenu(nodeIndex) {
3565
+ const node = this.nodes[nodeIndex];
3566
+ if (!node.options || node.options.length === 0)
3567
+ return;
3568
+ this.selectMenuNode = nodeIndex;
3569
+ this.selectMenuDirty = true;
3570
+ }
3571
+ /** Close the modal. When `repaint`, mark the whole tree dirty so the
3572
+ * overlay is erased by a full redraw (navigate() clears the screen
3573
+ * itself, so it passes false). */
3574
+ closeSelectMenu(repaint) {
3575
+ if (this.selectMenuNode < 0)
3576
+ return;
3577
+ this.selectMenuNode = -1;
3578
+ this.selectMenuDirty = false;
3579
+ if (repaint) {
3580
+ for (const node of this.nodes)
3581
+ node.dirty = true;
3582
+ }
3583
+ }
3584
+ /** Stamp the modal overlay on top of the current framebuffer. Idempotent
3585
+ * per frame — the close path full-repaints beneath it. */
3586
+ drawSelectMenu() {
3587
+ if (this.selectMenuNode < 0)
3588
+ return;
3589
+ const node = this.nodes[this.selectMenuNode];
3590
+ if (!node.options || node.options.length === 0)
3591
+ return;
3592
+ const { x, y, w, rowH, rows } = this.selectMenuGeometry();
3593
+ const h = Math.min(Math.trunc(this.gfx.height * 0.6), rows * rowH + 8);
3594
+ // Themed panel: the select's own radius/border/bg — the modal reads as
3595
+ // the control's popover, matching the surrounding UI (shadcn tokens on
3596
+ // the .select class flow through: radius, border color, background).
3597
+ const radius = Math.min(node.borderRadius || 6, Math.trunc(Math.min(w, h) / 2));
3598
+ const panel = this.selectMenuPanelColor();
3599
+ const borderCol = node.borderColor || node.fg;
3600
+ this.gfx.fillRoundRect(x, y, w, h, radius, panel);
3601
+ this.gfx.drawRoundRect(x, y, w, h, radius, borderCol);
3602
+ const ts = this.nodeTextSize(node);
3603
+ const rowInset = Math.max(2, Math.trunc(radius / 2));
3604
+ // The selected row carries the :checked pair (the kit wires it to
3605
+ // --accent/--accent-foreground, shadcn's SelectItem selected state).
3606
+ const rowBg = node.checkedBg >= 0 ? node.checkedBg : node.fg;
3607
+ const rowFg = node.checkedFg >= 0 ? node.checkedFg : panel;
3608
+ for (let r = 0; r < rows; r++) {
3609
+ const ry = y + 4 + r * rowH;
3610
+ const current = r === node.value;
3611
+ if (current) {
3612
+ this.gfx.fillRoundRect(x + rowInset, ry, w - 2 * rowInset, rowH, Math.min(radius, 6), rowBg);
3613
+ }
3614
+ const text = clampText(node.options[r]?.text ?? "");
3615
+ const fg = current ? rowFg : node.fg;
3616
+ this.drawText(text, x + 22, ry + Math.trunc((rowH - 8 * ts) / 2), fg, panel, ts, node.fontAntialias, node.fontFace, 0);
3617
+ if (current) {
3618
+ // Check mark on the current row (the checked pair's color).
3619
+ const cx = x + 7;
3620
+ const cy = ry + Math.trunc(rowH / 2);
3621
+ this.gfx.drawLine(cx, cy, cx + 3, cy + 3, rowFg);
3622
+ this.gfx.drawLine(cx + 3, cy + 3, cx + 8, cy - 4, rowFg);
3623
+ }
3624
+ }
3625
+ this.selectMenuDirty = false;
3626
+ }
3627
+ /** Panel fill: the select's effective background, falling back to a
3628
+ * near-black card on unstyled selects. */
3629
+ selectMenuPanelColor() {
3630
+ const node = this.nodes[this.selectMenuNode];
3631
+ return node.hasBg ? node.bg : node.clearColor;
3632
+ }
3633
+ /** Route a tap while the modal is open: a row selects + closes; anything
3634
+ * else just closes (dismiss on outside tap). */
3635
+ selectMenuHandleTouch(tx, ty) {
3636
+ const node = this.nodes[this.selectMenuNode];
3637
+ if (!node.options) {
3638
+ this.closeSelectMenu(true);
3639
+ return;
3640
+ }
3641
+ const { x, y, w, rowH, rows } = this.selectMenuGeometry();
3642
+ const h = Math.min(Math.trunc(this.gfx.height * 0.6), rows * rowH + 8);
3643
+ if (tx >= x && tx < x + w && ty >= y && ty < y + h) {
3644
+ const row = Math.trunc((ty - y - 4) / rowH);
3645
+ if (row >= 0 && row < rows) {
3646
+ node.value = row;
3647
+ node.hasTextBinding = true;
3648
+ node.textBuffer = clampText(node.options[row]?.text ?? "");
3649
+ this.markDirty(node.index);
3650
+ }
3651
+ }
3652
+ this.closeSelectMenu(true);
3653
+ }
2864
3654
  keyboardOpen(nodeIndex) {
2865
3655
  const node = this.nodes[nodeIndex];
2866
3656
  const template = this.keyboardTemplateForNode(node);
@@ -3183,15 +3973,80 @@ export class PreviewUIRuntime {
3183
3973
  // Rewrite bare references to module-scoped variables into moduleScope.NAME
3184
3974
  // so reads/writes hit the shared mutable binding (mirrors the device hoisting
3185
3975
  // them to globals). Skip property accesses (foo.bar) so `screen.gauge.value`
3186
- // etc. are untouched. Identifiers are the exact, finite set of module vars.
3976
+ // etc. are untouched and skip STRING/TEMPLATE LITERAL CONTENTS: prose like
3977
+ // "taps inside:" inside a template literal contains the identifier `taps`
3978
+ // as a whole word and must not be rewritten (only ${...} segments of
3979
+ // templates are code).
3187
3980
  const names = this.moduleVarNames();
3188
3981
  if (names.length) {
3189
3982
  const alt = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
3190
3983
  const re = new RegExp(`(?<![\\w.$])\\b(${alt})\\b(?!\\s*:)`, "g");
3191
- out = out.replace(re, "moduleScope.$1");
3984
+ out = this.rewriteOutsideLiterals(out, re);
3192
3985
  }
3193
3986
  return out;
3194
3987
  }
3988
+ /** Apply `re` to code segments only: skip '..'/".." literal contents, and
3989
+ * inside `..` templates recurse into ${...} interpolations (their
3990
+ * expressions are code; the literal text between them is not). */
3991
+ rewriteOutsideLiterals(text, re) {
3992
+ let out = "";
3993
+ let code = "";
3994
+ let i = 0;
3995
+ const flush = () => { out += code.replace(re, "moduleScope.$1"); code = ""; };
3996
+ while (i < text.length) {
3997
+ const ch = text[i];
3998
+ if (ch === "'" || ch === '"') {
3999
+ flush();
4000
+ let j = i + 1;
4001
+ while (j < text.length && text[j] !== ch) {
4002
+ if (text[j] === "\\")
4003
+ j++;
4004
+ j++;
4005
+ }
4006
+ out += text.slice(i, Math.min(j + 1, text.length));
4007
+ i = j + 1;
4008
+ continue;
4009
+ }
4010
+ if (ch === "`") {
4011
+ flush();
4012
+ // Emit the template piece by piece: literal runs verbatim (never
4013
+ // rewritten), ${...} interpolations recursed as code.
4014
+ out += "`";
4015
+ let j = i + 1;
4016
+ while (j < text.length && text[j] !== "`") {
4017
+ if (text[j] === "\\") {
4018
+ out += text.slice(j, j + 2);
4019
+ j += 2;
4020
+ continue;
4021
+ }
4022
+ if (text[j] === "$" && text[j + 1] === "{") {
4023
+ let depth = 1;
4024
+ let k = j + 2;
4025
+ while (k < text.length && depth > 0) {
4026
+ if (text[k] === "{")
4027
+ depth++;
4028
+ else if (text[k] === "}")
4029
+ depth--;
4030
+ k++;
4031
+ }
4032
+ out += "${" + this.rewriteOutsideLiterals(text.slice(j + 2, k - 1), re) + "}";
4033
+ j = k;
4034
+ continue;
4035
+ }
4036
+ out += text[j];
4037
+ j++;
4038
+ }
4039
+ if (j < text.length)
4040
+ out += "`";
4041
+ i = j + 1;
4042
+ continue;
4043
+ }
4044
+ code += ch;
4045
+ i++;
4046
+ }
4047
+ flush();
4048
+ return out;
4049
+ }
3195
4050
  /** Rewrite signal getter/setter syntax to plain variable reads/writes. See
3196
4051
  * normalizeScript for the lowering contract; this is the preview counterpart
3197
4052
  * of ui-callback-lowering.ts's rules 2 and 3. */
@@ -3276,6 +4131,32 @@ export class PreviewUIRuntime {
3276
4131
  return fn;
3277
4132
  },
3278
4133
  navigate: (screenIdx) => this.navigate(screenIdx),
4134
+ // Device parity with the SDL-only ui.window.* lowering
4135
+ // (ui_window_set_title / ui_window_set_icon). In the preview, setTitle
4136
+ // updates the browser tab title when a DOM is present (Node tests have
4137
+ // none); setIcon has no browser equivalent and no-ops, matching the
4138
+ // hardware targets' no-op.
4139
+ window: {
4140
+ setTitle: (title) => {
4141
+ const doc = globalThis.document;
4142
+ if (doc)
4143
+ doc.title = String(title);
4144
+ },
4145
+ setIcon: (_path) => { },
4146
+ },
4147
+ // <drawer> controls: open by element id, close by id or all open
4148
+ // drawers. Device parity with ui_drawer_open / ui_drawer_close.
4149
+ drawer: {
4150
+ open: (id) => this.drawerOpen(id),
4151
+ close: (id) => this.drawerClose(id),
4152
+ },
4153
+ // <dialog>/<toast> controls: same slot machinery as drawers (a dialog
4154
+ // is a centered drawer, a toast auto-closes via its duration attr).
4155
+ dialog: {
4156
+ open: (id) => this.drawerOpen(id),
4157
+ close: (id) => this.drawerClose(id),
4158
+ },
4159
+ toast: (id) => this.drawerOpen(id),
3279
4160
  };
3280
4161
  }
3281
4162
  }