@typecad/ui 1.0.0-alpha.11 → 1.0.0-alpha.13

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