@octane-xplat/macos-renderer 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { showWindowLayer } from './layer.mjs'
1
+ import { showWindowLayer, type WindowLayerOptions } from './layer'
2
2
  import '@nativescript/macos-node-api'
3
- import { loadImage } from './image'
4
- import { installPresentationBridge } from './presentation.mjs'
3
+ import { disposeImage, updateImage } from './image'
4
+ import { installPresentationBridge } from './presentation'
5
+ import { installAppearanceBridge } from './appearance'
5
6
  import { makeWebView, updateWebView, disposeWebView } from './webview'
6
7
  import { createUniversalRoot } from 'octane/universal/native'
7
8
  import type {
@@ -43,8 +44,10 @@ export interface ElementNode {
43
44
  scrollObserverInstalled: boolean
44
45
  text: string
45
46
  // Per-kind extras: placeholderView, marginHost, marginConstraints,
46
- // crossAxisConstraint, sizeConstraints, styleBg, bgSlot, layoutObserver,
47
- // scrollObserver, scheme, textRuns, appliedClassName, ...
47
+ // crossAxisConstraint, sizeConstraints, placementPins, styleBg, bgSlot,
48
+ // layoutObserver, scrollObserver, scheme, textRuns, appliedStyle,
49
+ // styleFont*/classFont*/propFontSize, stylePaddingBox/Edges, classInsets,
50
+ // styleRadius, classRadius, classSpacing, classOrientation, ...
48
51
  [key: string]: any
49
52
  }
50
53
 
@@ -92,6 +95,9 @@ interface RootContainer {
92
95
  nodes: Map<number, ElementNode>
93
96
  children: ElementNode[]
94
97
  root: UniversalRoot | null
98
+ /** Parents needing a sibling-wide layout pass; non-null only while a
99
+ * command batch is applying. */
100
+ layoutDirty: Set<ElementNode> | null
95
101
  }
96
102
 
97
103
  const actionHandlers = new Map<number, (() => void) | null>()
@@ -122,6 +128,8 @@ class ButtonActionTarget extends NSObject {
122
128
  viewPressed: { params: [NSObject], returns: interop.types.void },
123
129
  viewPanned: { params: [NSPanGestureRecognizer], returns: interop.types.void },
124
130
  controlChanged: { params: [NSObject], returns: interop.types.void },
131
+ controlTextDidChange: { params: [NSNotification], returns: interop.types.void },
132
+ textFieldSubmitted: { params: [NSObject], returns: interop.types.void },
125
133
  textDidChange: { params: [NSNotification], returns: interop.types.void },
126
134
  }
127
135
 
@@ -133,14 +141,28 @@ class ButtonActionTarget extends NSObject {
133
141
  invokeAction(sender.tag)
134
142
  }
135
143
 
136
- viewPressed(sender: NSObject) {
144
+ viewPressed(sender: NSClickGestureRecognizer) {
137
145
  invokeAction(actionIdsByView.get(sender.view))
138
146
  }
139
147
 
140
- controlChanged(sender: NSObject) {
148
+ controlChanged(sender: NSControl) {
141
149
  invokeAction(sender.tag ?? actionIdsByView.get(sender))
142
150
  }
143
151
 
152
+ // NSControlTextEditingDelegate: fires on every edit while a single-line
153
+ // field is being edited — the per-edit source for onTextChange.
154
+ controlTextDidChange(notification: NSNotification) {
155
+ const node = textNodesByView.get(notification.object)
156
+ invokeAction(node?.actionId ?? actionIdsByView.get(notification.object))
157
+ }
158
+
159
+ // Single-line fields keep target/action for submission (Return). The
160
+ // change handler lives on controlTextDidChange, so the action never
161
+ // masquerades as a value-change event.
162
+ textFieldSubmitted(sender: NSObject) {
163
+ textNodesByView.get(sender)?.submitHandler?.()
164
+ }
165
+
144
166
  viewPanned(sender: NSPanGestureRecognizer) {
145
167
  const handler = panHandlersByView.get(sender.view)
146
168
  if (!handler) {
@@ -190,6 +212,16 @@ class ContentAlignedTextField extends NSTextField {
190
212
  }
191
213
  }
192
214
 
215
+ class ContentAlignedSecureTextField extends NSSecureTextField {
216
+ static {
217
+ NativeClass(this)
218
+ }
219
+
220
+ alignmentRectInsets() {
221
+ return { top: 0, left: 0, bottom: 0, right: 0 }
222
+ }
223
+ }
224
+
193
225
  const inputTransparentViews = new WeakSet<object>()
194
226
 
195
227
  class AccessibleStackView extends NSStackView {
@@ -349,7 +381,7 @@ class GridLayoutView extends NSView {
349
381
  }
350
382
 
351
383
  // Absolute's host — children position via their own left/top/right/bottom
352
- // layout-child props; the layout() pass sets frames directly.
384
+ // layout-child props; the layout() pass updates parent-anchored constraints.
353
385
  class AbsoluteLayoutView extends NSView {
354
386
  static {
355
387
  NativeClass(this)
@@ -400,12 +432,74 @@ function stackAlignmentAttribute(view: any, value: string) {
400
432
  return horizontal ? NSLayoutAttribute.Top : NSLayoutAttribute.Left
401
433
  }
402
434
 
435
+ // Container layout follows the same source order as fonts and padding. Resolve
436
+ // whole channels so an axis-specific gap never overrides a higher-source gap.
437
+ function syncStackLayout(node: ElementNode) {
438
+ if (node.type !== 'flexboxlayout' && node.type !== 'stack') {
439
+ return
440
+ }
441
+
442
+ const stack = node.view!
443
+ const style = node.styleLayout ?? {}
444
+ const previousOrientation = stack.orientation
445
+ stack.orientation =
446
+ style.flexDirection != null
447
+ ? style.flexDirection === 'row'
448
+ ? NSUserInterfaceLayoutOrientation.Horizontal
449
+ : NSUserInterfaceLayoutOrientation.Vertical
450
+ : (node.classOrientation ??
451
+ (node.props.flexDirection === 'row'
452
+ ? NSUserInterfaceLayoutOrientation.Horizontal
453
+ : NSUserInterfaceLayoutOrientation.Vertical))
454
+
455
+ const gapKey =
456
+ stack.orientation === NSUserInterfaceLayoutOrientation.Horizontal ? 'columnGap' : 'rowGap'
457
+
458
+ stack.spacing = Number(
459
+ style[gapKey] ??
460
+ style.gap ??
461
+ node.classSpacing ??
462
+ node.props[gapKey] ??
463
+ node.props.gap ??
464
+ node.props.spacing ??
465
+ 0,
466
+ )
467
+
468
+ stack.alignment = stackAlignmentAttribute(stack, stackAlignItems(node))
469
+ const justifyContent = stackJustifyContent(node)
470
+ if (stack.orientation !== previousOrientation || justifyContent !== node.appliedStackJustify) {
471
+ moveStackChildren(node)
472
+ }
473
+
474
+ node.appliedStackJustify = justifyContent
475
+ updateStackDistribution(node)
476
+ updateCrossAxisConstraints(node)
477
+ }
478
+
479
+ function isStackLayoutInput(name: string) {
480
+ return ['flexDirection', 'gap', 'rowGap', 'columnGap', 'alignItems', 'justifyContent'].includes(
481
+ name,
482
+ )
483
+ }
484
+
403
485
  function stackAlignItems(node: ElementNode) {
486
+ if (node.styleLayout?.alignItems != null) {
487
+ return node.styleLayout.alignItems
488
+ }
489
+
404
490
  const classes = nodeClasses(node)
405
491
  if (classes.includes('vx-button') || classes.includes('items-center')) {
406
492
  return 'center'
407
493
  }
408
494
 
495
+ if (classes.includes('items-stretch')) {
496
+ return 'stretch'
497
+ }
498
+
499
+ if (classes.includes('items-baseline')) {
500
+ return 'baseline'
501
+ }
502
+
409
503
  if (classes.includes('items-start')) {
410
504
  return 'start'
411
505
  }
@@ -418,6 +512,23 @@ function stackAlignItems(node: ElementNode) {
418
512
  }
419
513
 
420
514
  function stackJustifyContent(node: ElementNode) {
515
+ if (node.styleLayout?.justifyContent != null) {
516
+ return node.styleLayout.justifyContent
517
+ }
518
+
519
+ const classes = nodeClasses(node)
520
+ if (classes.includes('justify-center')) {
521
+ return 'center'
522
+ }
523
+
524
+ if (classes.includes('justify-end')) {
525
+ return 'end'
526
+ }
527
+
528
+ if (classes.includes('justify-start')) {
529
+ return 'start'
530
+ }
531
+
421
532
  if (nodeClasses(node).includes('vx-button')) {
422
533
  return 'center'
423
534
  }
@@ -489,7 +600,7 @@ function makeMarginHost(node: ElementNode) {
489
600
  })
490
601
 
491
602
  host.translatesAutoresizingMaskIntoConstraints = false
492
- host.addSubview(node.view)
603
+ host.addSubview(node.view!)
493
604
  const insets = marginInsetsOf(node)
494
605
  const constraints = [
495
606
  node.view!.leadingAnchor.constraintEqualToAnchorConstant(host.leadingAnchor, insets.left),
@@ -526,29 +637,24 @@ function syncMarginHost(node: ElementNode) {
526
637
  }
527
638
 
528
639
  const insets = marginInsetsOf(node)
529
- const index = Math.max(parent.children.indexOf(node), 0)
530
640
  if (insets && !node.marginHost) {
531
641
  const gravity = stackGravity(parent, node)
642
+ const index = gravityInsertIndex(parent, node, gravity)
532
643
  stack.removeArrangedSubview(node.view)
533
644
  node.view.removeFromSuperview()
534
645
  const host = makeMarginHost(node)
535
646
  stack.insertViewAtIndexInGravity(host, index, gravity)
536
- updateCrossAxisConstraints(parent)
537
- } else if (!insets && node.marginHost) {
538
- const gravity = stackGravity(parent, node)
539
- stack.removeArrangedSubview(node.marginHost)
540
- node.view.removeFromSuperview()
541
- node.marginHost.removeFromSuperview()
542
- node.marginHost = null
543
- node.marginConstraints = null
544
- stack.insertViewAtIndexInGravity(node.view, index, gravity)
545
- updateCrossAxisConstraints(parent)
546
- } else if (insets && node.marginHost) {
647
+ queueLayoutReconcile(node.container, parent)
648
+ setLayoutAction(node, node.props?.onLayoutChanged)
649
+ } else if (node.marginHost) {
650
+ // Once wrapped, the host stays arranged for the node's lifetime —
651
+ // unwrapping would detach a first-responder ancestor and end live
652
+ // editing. Zeroed constants collapse it to the child's frame.
547
653
  const [leading, trailing, top, bottom] = node.marginConstraints
548
- leading.constant = insets.left
549
- trailing.constant = -insets.right
550
- top.constant = insets.top
551
- bottom.constant = -insets.bottom
654
+ leading.constant = insets?.left ?? 0
655
+ trailing.constant = 0 - (insets?.right ?? 0)
656
+ top.constant = insets?.top ?? 0
657
+ bottom.constant = 0 - (insets?.bottom ?? 0)
552
658
  }
553
659
  }
554
660
 
@@ -577,7 +683,7 @@ function setMarginStyle(node: ElementNode, name: string, value: any) {
577
683
 
578
684
  if (node.parent?.type === 'gridlayout') {
579
685
  // Grids lay out children manually from props.style — no wrapper needed.
580
- layoutGridChildren(node.parent)
686
+ queueLayoutReconcile(node.container, node.parent)
581
687
  return
582
688
  }
583
689
 
@@ -614,24 +720,111 @@ function stackGravity(parent: ElementNode, child: ElementNode) {
614
720
  return leading
615
721
  }
616
722
 
723
+ /** AppKit insertion indices are local to a gravity area — count the
724
+ * same-gravity siblings ahead of `node`, not its logical child index.
725
+ * Shared by stack insertion and margin-host reinsertion. */
726
+ function gravityInsertIndex(parent: ElementNode, node: ElementNode, gravity: number) {
727
+ let index = 0
728
+ for (const sibling of parent.children) {
729
+ if (sibling === node) {
730
+ break
731
+ }
732
+
733
+ if (sibling.view && stackGravity(parent, sibling) === gravity) {
734
+ index++
735
+ }
736
+ }
737
+
738
+ return index
739
+ }
740
+
741
+ /** Snapshot one gravity area's arranged views. The bridge returns an
742
+ * NSArray; `count` may be a method or a property depending on interop. */
743
+ function stackViewsInGravity(stack: any, gravity: number) {
744
+ const views = typeof stack?.viewsInGravity === 'function' ? stack.viewsInGravity(gravity) : null
745
+ const count = typeof views?.count === 'function' ? views.count() : (views?.count ?? 0)
746
+ const arrangedViews: any[] = []
747
+ for (let index = 0; index < count; index++) {
748
+ arrangedViews.push(views.objectAtIndex(index))
749
+ }
750
+
751
+ return arrangedViews
752
+ }
753
+
754
+ /** Reconcile one gravity area with the children's desired views. Only
755
+ * re-parented children land in `moved` — detaching a view that is (or
756
+ * contains) the first responder ends the active editing session. */
757
+ function syncStackGravityArea(
758
+ parent: ElementNode,
759
+ stack: any,
760
+ gravity: number,
761
+ moved: Set<ElementNode>,
762
+ ) {
763
+ const desired: ElementNode[] = []
764
+ for (const child of parent.children) {
765
+ if (child.view && stackGravity(parent, child) === gravity) {
766
+ desired.push(child)
767
+ }
768
+ }
769
+
770
+ const keep = new Set(desired.map((child) => arrangedView(child)))
771
+ const arrangedViews = stackViewsInGravity(stack, gravity)
772
+ for (let index = arrangedViews.length - 1; index >= 0; index--) {
773
+ if (!keep.has(arrangedViews[index])) {
774
+ stack.removeArrangedSubview(arrangedViews[index])
775
+ arrangedViews[index].removeFromSuperview()
776
+ arrangedViews.splice(index, 1)
777
+ }
778
+ }
779
+
780
+ for (let index = 0; index < desired.length; index++) {
781
+ const child = desired[index]
782
+ const view = arrangedView(child)
783
+ if (arrangedViews[index] === view) {
784
+ continue
785
+ }
786
+
787
+ const from = arrangedViews.indexOf(view)
788
+ if (from >= 0) {
789
+ stack.removeArrangedSubview(view)
790
+ view.removeFromSuperview()
791
+ arrangedViews.splice(from, 1)
792
+ }
793
+
794
+ stack.insertViewAtIndexInGravity(view, index, gravity)
795
+ arrangedViews.splice(index, 0, view)
796
+ moved.add(child)
797
+ }
798
+ }
799
+
617
800
  function moveStackChildren(parent: ElementNode) {
618
801
  const stack = parent.childHost ?? parent.view
619
802
  if (typeof stack?.addViewInGravity !== 'function') {
620
803
  return
621
804
  }
622
805
 
623
- for (const child of parent.children) {
624
- if (!child.view) {
625
- continue
626
- }
806
+ // Diff each gravity area against the live arrangement so unchanged
807
+ // children are never re-parented — AppKit's endEditingFor: fires when a
808
+ // first-responder ancestor leaves its superview. Orientation flips keep
809
+ // identical gravity values (Leading == Top, Trailing == Bottom), so a
810
+ // pure orientation change is a no-op here.
811
+ const moved = new Set<ElementNode>()
812
+ for (const gravity of [
813
+ NSStackViewGravity.Leading,
814
+ NSStackViewGravity.Center,
815
+ NSStackViewGravity.Trailing,
816
+ ]) {
817
+ syncStackGravityArea(parent, stack, gravity, moved)
818
+ }
627
819
 
628
- const arranged = arrangedView(child)
629
- stack.removeArrangedSubview(arranged)
630
- arranged.removeFromSuperview()
631
- stack.addViewInGravity(arranged, stackGravity(parent, child))
820
+ for (const child of moved) {
821
+ // Removing a view drops constraints to its old superview, including
822
+ // percentage dimensions. Reinstall them after reattaching.
823
+ applySizeConstraints(child)
632
824
  setStackChildPriorities(parent, child)
633
825
  }
634
826
 
827
+ updateStackDistribution(parent)
635
828
  updateCrossAxisConstraints(parent)
636
829
  }
637
830
 
@@ -672,8 +865,6 @@ function setStackChildPriorities(parent: ElementNode, child: ElementNode) {
672
865
  target.setContentHuggingPriorityForOrientation(priority, orientation)
673
866
  target.setContentCompressionResistancePriorityForOrientation(750, orientation)
674
867
  }
675
-
676
- updateStackDistribution(parent)
677
868
  }
678
869
 
679
870
  function makeFlexbox(props: PropBag) {
@@ -684,11 +875,12 @@ function makeFlexbox(props: PropBag) {
684
875
  : undefined
685
876
 
686
877
  if (actionId !== undefined) {
687
- actionIdsByView.set(stack, actionId)
688
- actionHandlers.set(actionId, null)
689
878
  stack.addGestureRecognizer(
690
- NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed'),
879
+ NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed:'),
691
880
  )
881
+
882
+ actionIdsByView.set(stack, actionId)
883
+ actionHandlers.set(actionId, null)
692
884
  }
693
885
 
694
886
  return { view: stack, actionId }
@@ -717,7 +909,7 @@ function makeButton(props: PropBag) {
717
909
  const button = NSButton.buttonWithTitleTargetAction(
718
910
  String(props.title ?? 'Button'),
719
911
  buttonActionTarget,
720
- 'buttonPressed',
912
+ 'buttonPressed:',
721
913
  )
722
914
 
723
915
  button.bezelStyle = NSBezelStyle.Rounded
@@ -810,6 +1002,22 @@ function setScrollAction(node: ElementNode, handler: ((event: any) => void) | un
810
1002
 
811
1003
  const layoutHandlers = new WeakMap<object, () => void>()
812
1004
 
1005
+ /** Margin changes swap a node's arranged view without touching the node.
1006
+ * Drop the observer and handler bound to the stale target — the caller
1007
+ * re-runs `setLayoutAction`, which rebuilds both against the current
1008
+ * arranged view so events keep reporting that view's bounds. */
1009
+ function syncLayoutObserver(node: ElementNode) {
1010
+ const observed = node.layoutObservedView
1011
+ if (!observed || observed === arrangedView(node)) {
1012
+ return
1013
+ }
1014
+
1015
+ layoutHandlers.delete(observed)
1016
+ NSNotificationCenter.defaultCenter.removeObserver(node.layoutObserver)
1017
+ node.layoutObserver = null
1018
+ node.layoutObservedView = null
1019
+ }
1020
+
813
1021
  /** `onLayoutChanged` prop: NSViewFrameDidChangeNotification on the arranged
814
1022
  * view (margin host when present) — the windowed VirtualList leaf measures
815
1023
  * rows through it. */
@@ -819,6 +1027,7 @@ function setLayoutAction(node: ElementNode, handler: ((event: any) => void) | un
819
1027
  return
820
1028
  }
821
1029
 
1030
+ syncLayoutObserver(node)
822
1031
  if (typeof handler !== 'function') {
823
1032
  layoutHandlers.delete(target)
824
1033
  return
@@ -835,6 +1044,8 @@ function setLayoutAction(node: ElementNode, handler: ((event: any) => void) | un
835
1044
  null,
836
1045
  () => layoutHandlers.get(target)?.(),
837
1046
  )
1047
+
1048
+ node.layoutObservedView = target
838
1049
  }
839
1050
 
840
1051
  layoutHandlers.set(target, () => {
@@ -878,10 +1089,12 @@ function setTextFieldPlaceholder(field: NSTextField, props: PropBag, scheme = 'l
878
1089
  function makeTextField(props: PropBag, multiline = false) {
879
1090
  const field = multiline
880
1091
  ? NSTextView.alloc().initWithFrame({ origin: { x: 0, y: 0 }, size: { width: 320, height: 72 } })
881
- : ContentAlignedTextField.alloc().initWithFrame({
882
- origin: { x: 0, y: 0 },
883
- size: { width: 320, height: 28 },
884
- })
1092
+ : (props.secure ? ContentAlignedSecureTextField : ContentAlignedTextField)
1093
+ .alloc()
1094
+ .initWithFrame({
1095
+ origin: { x: 0, y: 0 },
1096
+ size: { width: 320, height: 28 },
1097
+ })
885
1098
 
886
1099
  field.translatesAutoresizingMaskIntoConstraints = false
887
1100
  field.font = fontForStyle(14)
@@ -892,7 +1105,7 @@ function makeTextField(props: PropBag, multiline = false) {
892
1105
  field.editable = true
893
1106
  field.selectable = true
894
1107
  field.sendsActionOnEndEditing = false
895
- setTextFieldPlaceholder(field, props)
1108
+ setTextFieldPlaceholder(field as NSTextField, props)
896
1109
  } else {
897
1110
  field.editable = true
898
1111
  field.selectable = true
@@ -911,7 +1124,7 @@ function makeTextField(props: PropBag, multiline = false) {
911
1124
  }
912
1125
 
913
1126
  function makeSwitch(props: PropBag) {
914
- const button = NSButton.buttonWithTitleTargetAction('', buttonActionTarget, 'controlChanged')
1127
+ const button = NSButton.buttonWithTitleTargetAction('', buttonActionTarget, 'controlChanged:')
915
1128
  button.setButtonType(NSButtonType.Switch)
916
1129
  button.state = props.checked ? 1 : 0
917
1130
  button.translatesAutoresizingMaskIntoConstraints = false
@@ -931,7 +1144,7 @@ function makeSlider(props: PropBag) {
931
1144
  slider.maxValue = Number(props.maxValue ?? 1)
932
1145
  slider.doubleValue = Number(props.value ?? 0)
933
1146
  slider.target = buttonActionTarget
934
- slider.action = 'controlChanged'
1147
+ slider.action = 'controlChanged:'
935
1148
  slider.translatesAutoresizingMaskIntoConstraints = false
936
1149
  const actionId = nextActionId++
937
1150
  slider.tag = actionId
@@ -1217,6 +1430,82 @@ function trackOffset(sizes: number[], index: number) {
1217
1430
  return offset
1218
1431
  }
1219
1432
 
1433
+ /** Pin a grid/absolute child to the rect its parent's layout pass computed.
1434
+ * Children are translates=false views, so Auto Layout owns their frames —
1435
+ * an imperative frame write only holds until the next solve shrinks the
1436
+ * child back to fitting size. Each axis gets one edge pin against the
1437
+ * parent plus one size pin, so parent geometry stays an input; a pin set
1438
+ * that also implied the parent's size would outrank the window's
1439
+ * 500-priority WindowSizeStayPut hold and lock the window at the content's
1440
+ * fitting size. */
1441
+ function pinLayoutChild(
1442
+ child: ElementNode,
1443
+ parentView: any,
1444
+ x: number,
1445
+ top: number,
1446
+ width: number,
1447
+ height: number,
1448
+ ) {
1449
+ const pins = (child.placementPins ??= {})
1450
+ if (!pins.leading) {
1451
+ pins.leading = child.view!.leadingAnchor.constraintEqualToAnchorConstant(
1452
+ parentView.leadingAnchor,
1453
+ 0,
1454
+ )
1455
+
1456
+ pins.top = child.view!.topAnchor.constraintEqualToAnchorConstant(parentView.topAnchor, 0)
1457
+ pins.width = child.view!.widthAnchor.constraintEqualToConstant(0)
1458
+ pins.height = child.view!.heightAnchor.constraintEqualToConstant(0)
1459
+ for (const pin of [pins.leading, pins.top, pins.width, pins.height]) {
1460
+ pin.active = true
1461
+ }
1462
+ }
1463
+
1464
+ const same = (a: number, b: number) => a === b || (Number.isNaN(a) && Number.isNaN(b))
1465
+ if (
1466
+ same(Number(pins.leading.constant), x) &&
1467
+ same(Number(pins.top.constant), top) &&
1468
+ same(Number(pins.width.constant), width) &&
1469
+ same(Number(pins.height.constant), height)
1470
+ ) {
1471
+ return
1472
+ }
1473
+
1474
+ pins.leading.constant = x
1475
+ pins.top.constant = top
1476
+ pins.width.constant = width
1477
+ pins.height.constant = height
1478
+ // Constant writes inside a layout() pass apply only on the next solve, and
1479
+ // a needsLayout mark made now is cleared when that pass ends. Defer the
1480
+ // mark so the next layoutSubtreeIfNeeded/display cycle applies the pins —
1481
+ // without it, real-window resizes leave children at stale frames. The bare
1482
+ // vm slices in tests have no microtask queue, so fall back to a direct
1483
+ // mark there.
1484
+ const mark = () => {
1485
+ parentView.needsLayout = true
1486
+ }
1487
+ if (typeof queueMicrotask === 'function') {
1488
+ queueMicrotask(mark)
1489
+ } else {
1490
+ mark()
1491
+ }
1492
+ }
1493
+
1494
+ /** Drop the pins a grid/absolute parent installed — a constraint that keeps
1495
+ * referencing the old parent view is invalid once the child reparents. */
1496
+ function releasePlacementPins(node: ElementNode) {
1497
+ const pins = node.placementPins
1498
+ if (!pins) {
1499
+ return
1500
+ }
1501
+
1502
+ for (const pin of Object.values(pins) as { active: boolean }[]) {
1503
+ pin.active = false
1504
+ }
1505
+
1506
+ node.placementPins = null
1507
+ }
1508
+
1220
1509
  function layoutGridChildren(parent: ElementNode | undefined) {
1221
1510
  if (!parent?.view) {
1222
1511
  return
@@ -1302,10 +1591,7 @@ function layoutGridChildren(parent: ElementNode | undefined) {
1302
1591
  }
1303
1592
 
1304
1593
  top += layoutLength(style.top, cellHeight, 0) + Number(style.marginTop ?? 0)
1305
- child.view!.frame = {
1306
- origin: { x, y: height - top - childHeight },
1307
- size: { width: childWidth, height: childHeight },
1308
- }
1594
+ pinLayoutChild(child, parent.view, x, top, childWidth, childHeight)
1309
1595
  }
1310
1596
 
1311
1597
  return
@@ -1346,10 +1632,7 @@ function layoutGridChildren(parent: ElementNode | undefined) {
1346
1632
  y = height - childHeight - Number(style.marginTop ?? 0)
1347
1633
  }
1348
1634
 
1349
- child.view!.frame = {
1350
- origin: { x, y },
1351
- size: { width: childWidth, height: childHeight },
1352
- }
1635
+ pinLayoutChild(child, parent.view, x, height - y - childHeight, childWidth, childHeight)
1353
1636
  }
1354
1637
  }
1355
1638
 
@@ -1394,10 +1677,7 @@ function layoutAbsoluteChildren(parent: ElementNode | undefined) {
1394
1677
  ? height - bottom - childHeight
1395
1678
  : 0
1396
1679
 
1397
- child.view.frame = {
1398
- origin: { x, y: height - offsetTop - childHeight },
1399
- size: { width: childWidth, height: childHeight },
1400
- }
1680
+ pinLayoutChild(child, parent.view, x, offsetTop, childWidth, childHeight)
1401
1681
  }
1402
1682
  }
1403
1683
 
@@ -1433,140 +1713,236 @@ function makeImageView(props: PropBag) {
1433
1713
  })
1434
1714
 
1435
1715
  image.translatesAutoresizingMaskIntoConstraints = false
1436
- image.image = loadImage(props.src)
1716
+ updateImage(image, props.src)
1437
1717
 
1438
1718
  return image
1439
1719
  }
1440
1720
 
1441
- function makeNode(container: RootContainer, id: number, type: string, props: PropBag) {
1442
- let view
1443
- let actionId
1444
- let childHost
1445
- switch (type) {
1446
- case 'stack':
1447
- view = makeStack(props)
1448
- break
1449
- case 'flexboxlayout': {
1450
- const flexbox = makeFlexbox(props)
1451
- view = flexbox.view
1452
- actionId = flexbox.actionId
1453
- break
1454
- }
1455
- case 'gridlayout':
1456
- view = makeGridLayout()
1457
- break
1458
- case 'absolutelayout':
1459
- view = makeAbsoluteLayout()
1460
- break
1461
- case 'label':
1462
- view = makeLabel()
1463
- break
1464
- case '#text':
1465
- view = null
1466
- break
1467
- case 'button': {
1468
- const button = makeButton(props)
1469
- view = button.view
1470
- actionId = button.actionId
1471
- break
1721
+ /** Types whose views arrange child views; mirrors the check in insert(). */
1722
+ const VIEW_PARENT_TYPES = new Set([
1723
+ 'stack',
1724
+ 'flexboxlayout',
1725
+ 'scrollview',
1726
+ 'gridlayout',
1727
+ 'absolutelayout',
1728
+ ])
1729
+
1730
+ /** Every type the factory switch below can build. */
1731
+ const SUPPORTED_TYPES = new Set([
1732
+ ...VIEW_PARENT_TYPES,
1733
+ 'label',
1734
+ '#text',
1735
+ 'button',
1736
+ 'textfield',
1737
+ 'textview',
1738
+ 'switch',
1739
+ 'slider',
1740
+ 'webview',
1741
+ 'image',
1742
+ 'span',
1743
+ ])
1744
+
1745
+ function typeHasView(type: string) {
1746
+ return type !== '#text' && type !== 'span'
1747
+ }
1748
+
1749
+ function assertSupportedType(type: string) {
1750
+ if (!SUPPORTED_TYPES.has(type)) {
1751
+ throw new Error('AppKit spike does not support <' + type + '>')
1752
+ }
1753
+ }
1754
+
1755
+ /** Deterministic prop failures that would otherwise throw after views and
1756
+ * registrations exist. `baseProps` supplies already-applied props for
1757
+ * update commands so merged-prop rules (e.g. placeholder color) stay exact. */
1758
+ function validateNodeProps(type: string, props: PropBag | undefined, baseProps?: PropBag) {
1759
+ if (type === '#text' || type === 'span') {
1760
+ return
1761
+ }
1762
+
1763
+ const style = props?.style
1764
+ if (style != null) {
1765
+ if (typeof style !== 'object') {
1766
+ throw new Error('AppKit spike expects style to be an object')
1472
1767
  }
1473
- case 'scrollview': {
1474
- const scroll = makeScrollView()
1475
- view = scroll.view
1476
- childHost = scroll.childHost
1477
- break
1768
+
1769
+ // applyStyle feeds these straight into nativeColor.
1770
+ if (style.backgroundColor != null) {
1771
+ nativeColor(style.backgroundColor)
1478
1772
  }
1479
- case 'textfield':
1480
- view = makeTextField(props)
1481
- break
1482
- case 'textview':
1483
- view = makeTextField(props, true)
1484
- break
1485
- case 'switch': {
1486
- const control = makeSwitch(props)
1487
- view = control.view
1488
- actionId = control.actionId
1489
- break
1773
+
1774
+ if (style.borderColor != null) {
1775
+ nativeColor(style.borderColor)
1490
1776
  }
1491
- case 'slider': {
1492
- const control = makeSlider(props)
1493
- view = control.view
1494
- actionId = control.actionId
1495
- break
1777
+
1778
+ if (['label', 'textfield', 'textview'].includes(type) && style.color != null) {
1779
+ nativeColor(style.color)
1496
1780
  }
1497
- case 'webview':
1498
- view = makeWebView()
1499
- break
1500
- case 'image':
1501
- view = makeImageView(props)
1502
- break
1503
- case 'span':
1504
- view = null
1505
- break
1506
- default:
1507
- throw new Error('AppKit spike does not support <' + type + '>')
1508
1781
  }
1509
1782
 
1510
- if (type === 'textfield' || type === 'textview') {
1511
- actionId = nextActionId++
1512
- actionIdsByView.set(view, actionId)
1513
- actionHandlers.set(actionId, null)
1514
- if (type === 'textfield') {
1515
- view.tag = actionId
1516
- view.target = buttonActionTarget
1517
- view.action = 'controlChanged'
1518
- } else {
1519
- view.delegate = buttonActionTarget
1783
+ if (type === 'gridlayout') {
1784
+ // layoutGridChildren parses the track lists on every apply.
1785
+ parseGridTracks(props?.rows)
1786
+ parseGridTracks(props?.columns)
1787
+ }
1788
+
1789
+ if (type === 'textfield' && props?.placeholderTextColor != null) {
1790
+ // setTextFieldPlaceholder only reaches the color when text exists.
1791
+ const placeholder = props.placeholder ?? baseProps?.placeholder
1792
+ if (String(placeholder ?? '') !== '') {
1793
+ nativeColor(props.placeholderTextColor)
1520
1794
  }
1521
1795
  }
1796
+ }
1797
+
1798
+ function makeNode(container: RootContainer, id: number, type: string, props: PropBag) {
1799
+ // Reject malformed props before factories allocate views or register
1800
+ // actions — the node only reaches container.nodes after this returns, so
1801
+ // a throwing prop below must unwind whatever was already acquired.
1802
+ assertSupportedType(type)
1803
+ validateNodeProps(type, props)
1522
1804
 
1523
1805
  const node: ElementNode = {
1524
1806
  id,
1525
1807
  type,
1526
- view,
1527
- childHost,
1808
+ view: null,
1528
1809
  props: {},
1529
1810
  parent: null,
1530
1811
  children: [],
1531
1812
  container,
1532
1813
  appliedFontFamily: container.fontFamily,
1533
- actionId,
1534
1814
  scrollObserverInstalled: false,
1535
1815
  text: '',
1816
+ // The native class is fixed at creation; post-mount `secure` updates
1817
+ // are rejected against this in applyProps.
1818
+ secure: type === 'textfield' && !!props.secure,
1536
1819
  }
1537
1820
 
1538
- if (['label', 'textfield', 'textview'].includes(type) && view.font) {
1539
- view.font = fontForFamilyStyle(view.font.pointSize, 400, container.fontFamily)
1540
- }
1821
+ try {
1822
+ switch (type) {
1823
+ case 'stack':
1824
+ node.view = makeStack(props)
1825
+ break
1826
+ case 'flexboxlayout': {
1827
+ const flexbox = makeFlexbox(props)
1828
+ node.view = flexbox.view
1829
+ node.actionId = flexbox.actionId
1830
+ break
1831
+ }
1832
+ case 'gridlayout':
1833
+ node.view = makeGridLayout()
1834
+ break
1835
+ case 'absolutelayout':
1836
+ node.view = makeAbsoluteLayout()
1837
+ break
1838
+ case 'label':
1839
+ node.view = makeLabel()
1840
+ break
1841
+ case '#text':
1842
+ break
1843
+ case 'button': {
1844
+ const button = makeButton(props)
1845
+ node.view = button.view
1846
+ node.actionId = button.actionId
1847
+ break
1848
+ }
1849
+ case 'scrollview': {
1850
+ const scroll = makeScrollView()
1851
+ node.view = scroll.view
1852
+ node.childHost = scroll.childHost
1853
+ break
1854
+ }
1855
+ case 'textfield':
1856
+ node.view = makeTextField(props)
1857
+ break
1858
+ case 'textview':
1859
+ node.view = makeTextField(props, true)
1860
+ break
1861
+ case 'switch': {
1862
+ const control = makeSwitch(props)
1863
+ node.view = control.view
1864
+ node.actionId = control.actionId
1865
+ break
1866
+ }
1867
+ case 'slider': {
1868
+ const control = makeSlider(props)
1869
+ node.view = control.view
1870
+ node.actionId = control.actionId
1871
+ break
1872
+ }
1873
+ case 'webview':
1874
+ node.view = makeWebView()
1875
+ break
1876
+ case 'image':
1877
+ node.view = makeImageView(props)
1878
+ break
1879
+ case 'span':
1880
+ break
1881
+ default:
1882
+ throw new Error('AppKit spike does not support <' + type + '>')
1883
+ }
1884
+
1885
+ if (type === 'textfield' || type === 'textview') {
1886
+ node.actionId = nextActionId++
1887
+ actionIdsByView.set(node.view!, node.actionId)
1888
+ actionHandlers.set(node.actionId, null)
1889
+ if (type === 'textfield') {
1890
+ // Per-edit events arrive through controlTextDidChange (delegate);
1891
+ // the target/action is reserved for submission on Return.
1892
+ node.view!.delegate = buttonActionTarget
1893
+ node.view!.target = buttonActionTarget
1894
+ node.view!.action = 'textFieldSubmitted:'
1895
+ } else {
1896
+ node.view!.delegate = buttonActionTarget
1897
+ }
1898
+ }
1541
1899
 
1542
- if (type === 'gridlayout') {
1543
- gridLayoutNodesByView.set(view, node)
1544
- }
1900
+ if (['label', 'textfield', 'textview'].includes(type) && node.view!.font) {
1901
+ node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 400, container.fontFamily)
1902
+ }
1545
1903
 
1546
- if (type === 'absolutelayout') {
1547
- absoluteLayoutNodesByView.set(view, node)
1548
- }
1904
+ if (type === 'gridlayout') {
1905
+ gridLayoutNodesByView.set(node.view!, node)
1906
+ }
1549
1907
 
1550
- if (type === 'textview') {
1551
- const placeholder = makeLabel()
1552
- placeholder.font = fontForFamilyStyle(14, 400, node.appliedFontFamily)
1553
- placeholder.textColor = nativeColor('#666666')
1554
- placeholder.stringValue = String(props.placeholder ?? '')
1555
- placeholder.translatesAutoresizingMaskIntoConstraints = false
1556
- placeholder.heightAnchor.constraintEqualToConstant(
1557
- Math.ceil(14 * DEFAULT_TEXT_LINE_HEIGHT_RATIO),
1558
- ).active = true
1908
+ if (type === 'absolutelayout') {
1909
+ absoluteLayoutNodesByView.set(node.view!, node)
1910
+ }
1559
1911
 
1560
- placeholder.hidden = String(props.value ?? '').length > 0
1561
- view.addSubview(placeholder)
1562
- placeholder.leadingAnchor.constraintEqualToAnchorConstant(view.leadingAnchor, 0).active = true
1563
- placeholder.topAnchor.constraintEqualToAnchor(view.topAnchor).active = true
1564
- node.placeholderView = placeholder
1565
- textNodesByView.set(view, node)
1566
- }
1912
+ if (type === 'textfield') {
1913
+ textNodesByView.set(node.view!, node)
1914
+ }
1915
+
1916
+ if (type === 'textview') {
1917
+ const placeholder = makeLabel()
1918
+ placeholder.font = fontForFamilyStyle(14, 400, node.appliedFontFamily)
1919
+ placeholder.textColor = nativeColor('#666666')
1920
+ placeholder.stringValue = String(props.placeholder ?? '')
1921
+ placeholder.translatesAutoresizingMaskIntoConstraints = false
1922
+ placeholder.heightAnchor.constraintEqualToConstant(
1923
+ Math.ceil(14 * DEFAULT_TEXT_LINE_HEIGHT_RATIO),
1924
+ ).active = true
1925
+
1926
+ placeholder.hidden = String(props.value ?? '').length > 0
1927
+ node.view!.addSubview(placeholder)
1928
+ placeholder.leadingAnchor.constraintEqualToAnchorConstant(
1929
+ node.view!.leadingAnchor,
1930
+ 0,
1931
+ ).active = true
1932
+
1933
+ placeholder.topAnchor.constraintEqualToAnchor(node.view!.topAnchor).active = true
1934
+ node.placeholderView = placeholder
1935
+ textNodesByView.set(node.view!, node)
1936
+ }
1567
1937
 
1568
- applyProps(node, props)
1569
- return node
1938
+ applyProps(node, props)
1939
+ return node
1940
+ } catch (error) {
1941
+ // Release whatever the failed creation registered — action slots,
1942
+ // observers, WebView delegates — since no caller can destroy it.
1943
+ destroy(node)
1944
+ throw error
1945
+ }
1570
1946
  }
1571
1947
 
1572
1948
  const EDGE_INSET_PROPS = new Map([
@@ -1694,16 +2070,20 @@ function applyThemeColors(node: ElementNode) {
1694
2070
  }
1695
2071
 
1696
2072
  if (node.type === 'textfield') {
1697
- setTextFieldPlaceholder(view, node.props, node.scheme)
2073
+ setTextFieldPlaceholder(view as NSTextField, node.props, node.scheme)
1698
2074
  }
1699
2075
 
1700
2076
  if (node.type === 'textview' && node.placeholderView) {
1701
2077
  node.placeholderView.textColor = nativeColor(colors.placeholder)
1702
2078
  }
1703
2079
 
1704
- if (node.bgSlot) {
2080
+ if (node.bgSlot || node.styleBg != null) {
2081
+ node.bgApplied = true
1705
2082
  view.wantsLayer = true
1706
2083
  view.layer.backgroundColor = nativeColor(node.styleBg ?? colors[node.bgSlot]).CGColor
2084
+ } else if (node.bgApplied) {
2085
+ node.bgApplied = false
2086
+ view.layer.backgroundColor = null
1707
2087
  }
1708
2088
  }
1709
2089
 
@@ -1729,28 +2109,25 @@ function setSizeConstraint(node: ElementNode, name: string, value: any) {
1729
2109
  const specs = (node.sizeConstraintSpecs ??= {})
1730
2110
  if (!spec) {
1731
2111
  delete specs[name]
1732
- console.warn(
1733
- '[macos-style] ignored unsupported style.' + name + ' value ' + JSON.stringify(value),
1734
- )
2112
+ // null/undefined is an intentional clear — only malformed values warn.
2113
+ if (value != null) {
2114
+ console.warn(
2115
+ '[macos-style] ignored unsupported style.' + name + ' value ' + JSON.stringify(value),
2116
+ )
2117
+ }
1735
2118
  } else {
1736
2119
  specs[name] = spec
1737
2120
  }
1738
2121
 
1739
- if (node.parent?.type === 'gridlayout') {
2122
+ if (node.parent?.type === 'gridlayout' || node.parent?.type === 'absolutelayout') {
1740
2123
  deactivateSizeConstraints(node)
1741
- layoutGridChildren(node.parent)
1742
- return
1743
- }
1744
-
1745
- if (node.parent?.type === 'absolutelayout') {
1746
- deactivateSizeConstraints(node)
1747
- layoutAbsoluteChildren(node.parent)
2124
+ queueLayoutReconcile(node.container, node.parent)
1748
2125
  return
1749
2126
  }
1750
2127
 
1751
2128
  applySizeConstraint(node, name)
1752
2129
  if (node.parent) {
1753
- updateCrossAxisConstraints(node.parent)
2130
+ queueLayoutReconcile(node.container, node.parent)
1754
2131
  }
1755
2132
  }
1756
2133
 
@@ -1831,21 +2208,201 @@ function deactivateSizeConstraints(node: ElementNode) {
1831
2208
  }
1832
2209
  }
1833
2210
 
1834
- function applyStyle(node: ElementNode, style: PropBag) {
1835
- if (style == null) {
2211
+ // Style, class, and (for labels) prop sources all write one native font.
2212
+ // Each source is tracked on the node so removing one re-resolves the rest —
2213
+ // explicit style wins over classes, which win over the element defaults.
2214
+ function syncNodeFont(node: ElementNode) {
2215
+ const size =
2216
+ node.styleFontSize ??
2217
+ node.propFontSize ??
2218
+ node.classFontSize ??
2219
+ (node.type === 'label' ? 16 : 14)
2220
+
2221
+ const weight = node.styleFontWeight ?? node.classFontWeight ?? 400
2222
+ node.appliedFontWeight = String(weight)
2223
+ node.appliedFontFamily = node.styleFontFamily ?? node.container.fontFamily
2224
+ node.view!.font = fontForFamilyStyle(size, weight, node.appliedFontFamily)
2225
+ }
2226
+
2227
+ // NSStackView edgeInsets blend padding styles over class padding over zero.
2228
+ // Edges a style never set fall through to the class contribution.
2229
+ function syncEdgeInsets(node: ElementNode) {
2230
+ const view = node.view
2231
+ if (!view) {
2232
+ return
2233
+ }
2234
+
2235
+ const edges = node.stylePaddingEdges
2236
+ const box = node.stylePaddingBox
2237
+ const klass = node.classInsets
2238
+ if (!edges && !box && !klass && !node.insetsApplied) {
1836
2239
  return
1837
2240
  }
1838
2241
 
1839
- if (typeof style !== 'object') {
2242
+ node.insetsApplied = true
2243
+ view.edgeInsets = {
2244
+ top: edges?.top ?? box?.top ?? klass?.top ?? 0,
2245
+ right: edges?.right ?? box?.right ?? klass?.right ?? 0,
2246
+ bottom: edges?.bottom ?? box?.bottom ?? klass?.bottom ?? 0,
2247
+ left: edges?.left ?? box?.left ?? klass?.left ?? 0,
2248
+ }
2249
+ }
2250
+
2251
+ // borderRadius (style) and rounded-* (class) share cornerRadius; either source
2252
+ // keeps masksToBounds on only while a positive radius is in effect.
2253
+ function syncCornerRadius(node: ElementNode) {
2254
+ const view = node.view
2255
+ if (!view) {
2256
+ return
2257
+ }
2258
+
2259
+ const radius = Number(node.styleRadius ?? node.classRadius ?? 0) || 0
2260
+ if (radius <= 0 && !node.radiusApplied) {
2261
+ return
2262
+ }
2263
+
2264
+ node.radiusApplied = radius > 0
2265
+ view.wantsLayer = true
2266
+ view.layer.cornerRadius = Math.max(0, radius)
2267
+ view.layer.masksToBounds = radius > 0
2268
+ }
2269
+
2270
+ // Zero the margin sides a removed style owned, then let the stack unwrap or
2271
+ // the grid relayout — marginInsetsOf() returns null once every side is zero.
2272
+ function clearMarginStyle(node: ElementNode, name: string) {
2273
+ const insets = node.marginInsets
2274
+ if (!insets) {
2275
+ return
2276
+ }
2277
+
2278
+ if (name === 'margin') {
2279
+ insets.top = insets.right = insets.bottom = insets.left = 0
2280
+ } else {
2281
+ insets[MARGIN_SIDES[name]] = 0
2282
+ }
2283
+
2284
+ if (node.parent?.type === 'gridlayout') {
2285
+ layoutGridChildren(node.parent)
2286
+ return
2287
+ }
2288
+
2289
+ syncMarginHost(node)
2290
+ }
2291
+
2292
+ // A style key present in the last bag but absent or nulled in the new one must
2293
+ // put back the native state it installed: constraints deactivate, hit-testing
2294
+ // reverts, and shared properties resolve their class/default source again.
2295
+ function resetStyle(node: ElementNode, name: string) {
2296
+ const view = node.view
2297
+ if (!view) {
2298
+ return
2299
+ }
2300
+
2301
+ const textControl = ['label', 'textfield', 'textview'].includes(node.type)
2302
+ if (name === 'pointerEvents') {
2303
+ inputTransparentViews.delete(view)
2304
+ } else if (name === 'objectFit' && node.type === 'image') {
2305
+ // NSImageScaleProportionallyDown — the makeImageView/AppKit default.
2306
+ view.imageScaling = 0
2307
+ } else if (name === 'fontSize' && textControl) {
2308
+ node.styleFontSize = undefined
2309
+ syncNodeFont(node)
2310
+ } else if (name === 'fontFamily' && textControl) {
2311
+ node.styleFontFamily = undefined
2312
+ syncNodeFont(node)
2313
+ } else if (name === 'fontWeight' && textControl) {
2314
+ node.styleFontWeight = undefined
2315
+ syncNodeFont(node)
2316
+ } else if (name === 'color' && textControl) {
2317
+ node.styleColor = undefined
2318
+ applyThemeColors(node)
2319
+ } else if (name === 'padding' && node.type === 'flexboxlayout') {
2320
+ node.stylePaddingBox = undefined
2321
+ syncEdgeInsets(node)
2322
+ } else if (EDGE_INSET_PROPS.has(name) && node.type === 'flexboxlayout') {
2323
+ for (const edge of EDGE_INSET_PROPS.get(name)!) {
2324
+ delete node.stylePaddingEdges?.[edge]
2325
+ }
2326
+
2327
+ syncEdgeInsets(node)
2328
+ } else if (name === 'backgroundColor') {
2329
+ node.styleBg = undefined
2330
+ applyThemeColors(node)
2331
+ } else if (name === 'borderRadius') {
2332
+ node.styleRadius = undefined
2333
+ syncCornerRadius(node)
2334
+ } else if (name === 'width' || name === 'height') {
2335
+ setSizeConstraint(node, name, undefined)
2336
+ } else if (name === 'left' || name === 'top' || name === 'right' || name === 'bottom') {
2337
+ if (node.parent?.type === 'gridlayout') {
2338
+ layoutGridChildren(node.parent)
2339
+ }
2340
+
2341
+ if (node.parent?.type === 'absolutelayout') {
2342
+ layoutAbsoluteChildren(node.parent)
2343
+ }
2344
+ } else if (name === 'translateX' || name === 'translateY') {
2345
+ if (typeof view.layer?.setValueForKeyPath === 'function') {
2346
+ view.layer.setValueForKeyPath(
2347
+ 0,
2348
+ name === 'translateY' ? 'transform.translation.y' : 'transform.translation.x',
2349
+ )
2350
+ }
2351
+ } else if (name === 'zIndex') {
2352
+ if (view.layer) {
2353
+ view.layer.zPosition = 0
2354
+ }
2355
+ } else if (name === 'opacity') {
2356
+ view.alphaValue = 1
2357
+ } else if (name === 'textAlign' && node.type === 'label') {
2358
+ view.alignment = nodeClasses(node).includes('vx-sheet-grabber')
2359
+ ? NSTextAlignment.Center
2360
+ : NSTextAlignment.Left
2361
+ } else if (name === 'borderWidth') {
2362
+ if (view.layer) {
2363
+ view.layer.borderWidth = 0
2364
+ }
2365
+ } else if (name === 'borderColor') {
2366
+ if (view.layer) {
2367
+ view.layer.borderColor = null
2368
+ }
2369
+ } else if (MARGIN_SIDES[name] || name === 'margin') {
2370
+ clearMarginStyle(node, name)
2371
+ }
2372
+ // lineHeight folds into setLabelText from props.style — no native reset.
2373
+ }
2374
+
2375
+ function applyStyle(node: ElementNode, style: PropBag) {
2376
+ if (style != null && typeof style !== 'object') {
1840
2377
  throw new Error('AppKit spike expects style to be an object')
1841
2378
  }
1842
2379
 
1843
- for (const [name, value] of Object.entries(style)) {
2380
+ // The style prop replaces wholesale, so keys dropped or nulled since the
2381
+ // last apply must reset the native state they installed before the current
2382
+ // values go on — otherwise removed styles linger on the view.
2383
+ const next: PropBag = style ?? {}
2384
+ if (node.type === 'flexboxlayout' || node.type === 'stack') {
2385
+ node.styleLayout = Object.fromEntries(
2386
+ Object.entries(next).filter(([name]) => isStackLayoutInput(name)),
2387
+ )
2388
+ }
2389
+
2390
+ const prev: PropBag = node.appliedStyle ?? {}
2391
+ for (const name of Object.keys(prev)) {
2392
+ if (prev[name] != null && next[name] == null) {
2393
+ resetStyle(node, name)
2394
+ }
2395
+ }
2396
+
2397
+ for (const [name, value] of Object.entries(next)) {
1844
2398
  if (value == null) {
1845
2399
  continue
1846
2400
  }
1847
2401
 
1848
- if (name === 'pointerEvents') {
2402
+ if ((node.type === 'flexboxlayout' || node.type === 'stack') && isStackLayoutInput(name)) {
2403
+ // Applied together below, after all style inputs have reconciled.
2404
+ continue
2405
+ } else if (name === 'pointerEvents') {
1849
2406
  if (value === 'none') {
1850
2407
  inputTransparentViews.add(node.view!)
1851
2408
  } else {
@@ -1855,20 +2412,20 @@ function applyStyle(node: ElementNode, style: PropBag) {
1855
2412
  // NSImageScaling: 0 ProportionallyDown, 1 AxesIndependently,
1856
2413
  // 2 None (centered, unscaled), 3 ProportionallyUpOrDown.
1857
2414
  // AppKit has no cover mode — 'cover' degrades to scale-down.
1858
- node.view!.imageScaling = { contain: 3, 'scale-down': 0, fill: 1, none: 2 }[value] ?? 0
2415
+ const imageScaling: Record<string, number> = {
2416
+ contain: 3,
2417
+ 'scale-down': 0,
2418
+ fill: 1,
2419
+ none: 2,
2420
+ }
2421
+
2422
+ node.view!.imageScaling = imageScaling[String(value)] ?? 0
1859
2423
  } else if (name === 'fontSize' && ['label', 'textfield', 'textview'].includes(node.type)) {
1860
- const weight = style.fontWeight ?? node.appliedFontWeight ?? 400
1861
- node.appliedFontWeight = String(weight)
1862
- node.view!.font = fontForFamilyStyle(
1863
- value,
1864
- weight,
1865
- style.fontFamily ?? node.appliedFontFamily,
1866
- )
2424
+ node.styleFontSize = value
2425
+ syncNodeFont(node)
1867
2426
  } else if (name === 'fontFamily' && ['label', 'textfield', 'textview'].includes(node.type)) {
1868
- node.appliedFontFamily = String(value)
1869
- const size = style.fontSize ?? node.view!.font.pointSize
1870
- const weight = style.fontWeight ?? node.appliedFontWeight ?? 400
1871
- node.view!.font = fontForFamilyStyle(size, weight, value)
2427
+ node.styleFontFamily = String(value)
2428
+ syncNodeFont(node)
1872
2429
  } else if (name === 'color' && ['label', 'textfield', 'textview'].includes(node.type)) {
1873
2430
  node.styleColor = String(value)
1874
2431
  node.view!.textColor = nativeColor(value)
@@ -1876,31 +2433,28 @@ function applyStyle(node: ElementNode, style: PropBag) {
1876
2433
  // syncText applies this to each paragraph; it is not the label's fixed height.
1877
2434
  continue
1878
2435
  } else if (name === 'padding' && node.type === 'flexboxlayout') {
1879
- node.view!.edgeInsets = parseEdgeInsets(value)
2436
+ node.stylePaddingBox = parseEdgeInsets(value)
2437
+ syncEdgeInsets(node)
1880
2438
  } else if (EDGE_INSET_PROPS.has(name) && node.type === 'flexboxlayout') {
1881
- const insets = { ...node.view!.edgeInsets }
2439
+ const edges = (node.stylePaddingEdges ??= {})
1882
2440
  for (const edge of EDGE_INSET_PROPS.get(name)!) {
1883
- insets[edge] = Number(value) || 0
2441
+ edges[edge] = Number(value) || 0
1884
2442
  }
1885
2443
 
1886
- node.view!.edgeInsets = insets
2444
+ syncEdgeInsets(node)
1887
2445
  } else if (name === 'backgroundColor' && node.view) {
1888
2446
  node.styleBg = String(value)
2447
+ node.bgApplied = true
1889
2448
  node.view.wantsLayer = true
1890
2449
  node.view.layer.backgroundColor = nativeColor(value).CGColor
1891
2450
  } else if (name === 'borderRadius' && node.view) {
1892
- node.view.wantsLayer = true
1893
- node.view.layer.cornerRadius = Number(value)
1894
- node.view.layer.masksToBounds = true
2451
+ node.styleRadius = value
2452
+ syncCornerRadius(node)
1895
2453
  } else if ((name === 'width' || name === 'height') && node.view) {
1896
2454
  setSizeConstraint(node, name, value)
1897
2455
  } else if (name === 'left' && node.view) {
1898
- if (node.parent?.type === 'gridlayout') {
1899
- layoutGridChildren(node.parent)
1900
- }
1901
-
1902
- if (node.parent?.type === 'absolutelayout') {
1903
- layoutAbsoluteChildren(node.parent)
2456
+ if (node.parent?.type === 'gridlayout' || node.parent?.type === 'absolutelayout') {
2457
+ queueLayoutReconcile(node.container, node.parent)
1904
2458
  }
1905
2459
  } else if ((name === 'translateX' || name === 'translateY' || name === 'zIndex') && node.view) {
1906
2460
  node.view.wantsLayer = true
@@ -1917,12 +2471,8 @@ function applyStyle(node: ElementNode, style: PropBag) {
1917
2471
  } else if (name === 'opacity' && node.view) {
1918
2472
  node.view.alphaValue = Number(value)
1919
2473
  } else if (name === 'fontWeight' && ['label', 'textfield', 'textview'].includes(node.type)) {
1920
- node.appliedFontWeight = String(value)
1921
- node.view!.font = fontForFamilyStyle(
1922
- node.view!.font.pointSize,
1923
- value,
1924
- style.fontFamily ?? node.appliedFontFamily,
1925
- )
2474
+ node.styleFontWeight = String(value)
2475
+ syncNodeFont(node)
1926
2476
  } else if (name === 'textAlign' && node.type === 'label') {
1927
2477
  node.view!.alignment =
1928
2478
  value === 'left'
@@ -1942,6 +2492,9 @@ function applyStyle(node: ElementNode, style: PropBag) {
1942
2492
  console.warn('[macos-style] ignored unsupported style.' + name + ' on <' + node.type + '>')
1943
2493
  }
1944
2494
  }
2495
+
2496
+ node.appliedStyle = next
2497
+ syncStackLayout(node)
1945
2498
  }
1946
2499
 
1947
2500
  function applyClassName(node: ElementNode, value: any) {
@@ -1949,11 +2502,23 @@ function applyClassName(node: ElementNode, value: any) {
1949
2502
  .split(/\s+/)
1950
2503
  .filter(Boolean)
1951
2504
 
2505
+ // Class effects re-derive from scratch on every apply: the class-owned
2506
+ // fields reset here and re-populate below, so removing a class drops the
2507
+ // native state it installed instead of leaving stale values behind.
2508
+ node.classFontSize = undefined
2509
+ node.classFontWeight = undefined
2510
+ node.classInsets = undefined
2511
+ node.classRadius = undefined
2512
+ node.classSpacing = undefined
2513
+ node.classOrientation = undefined
2514
+ node.colorSlot = undefined
2515
+ node.bgSlot = undefined
2516
+
1952
2517
  if (node.type === 'label') {
1953
- if (classes.includes('vx-sheet-grabber')) {
2518
+ const grabber = classes.includes('vx-sheet-grabber')
2519
+ if (grabber) {
1954
2520
  node.classLineHeight = 24
1955
2521
  setSizeConstraint(node, 'height', 24)
1956
- node.view!.alignment = NSTextAlignment.Center
1957
2522
  }
1958
2523
 
1959
2524
  const sizes: Record<string, number> = {
@@ -1986,18 +2551,14 @@ function applyClassName(node: ElementNode, value: any) {
1986
2551
  for (const name of classes) {
1987
2552
  const heading = headingMetrics[name]
1988
2553
  if (heading) {
1989
- node.appliedFontWeight = '700'
2554
+ node.classFontWeight = '700'
2555
+ node.classFontSize = heading.size
1990
2556
  node.headingDefaultHeight = heading.height
1991
- node.view!.font = fontForFamilyStyle(heading.size, 700, node.appliedFontFamily)
1992
2557
  }
1993
2558
 
1994
2559
  if (sizes[name]) {
2560
+ node.classFontSize = sizes[name]
1995
2561
  node.headingDefaultHeight = undefined
1996
- node.view!.font = fontForFamilyStyle(
1997
- sizes[name],
1998
- node.appliedFontWeight ?? 400,
1999
- node.appliedFontFamily,
2000
- )
2001
2562
  }
2002
2563
 
2003
2564
  if (lineHeights[name] != null) {
@@ -2005,13 +2566,11 @@ function applyClassName(node: ElementNode, value: any) {
2005
2566
  }
2006
2567
 
2007
2568
  if (name === 'font-semibold') {
2008
- node.appliedFontWeight = '600'
2009
- node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 600, node.appliedFontFamily)
2569
+ node.classFontWeight = '600'
2010
2570
  }
2011
2571
 
2012
2572
  if (name === 'font-bold') {
2013
- node.appliedFontWeight = '700'
2014
- node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 700, node.appliedFontFamily)
2573
+ node.classFontWeight = '700'
2015
2574
  }
2016
2575
 
2017
2576
  if (name === 'text-muted') {
@@ -2022,6 +2581,28 @@ function applyClassName(node: ElementNode, value: any) {
2022
2581
  node.colorSlot = 'onprimary'
2023
2582
  }
2024
2583
  }
2584
+
2585
+ syncNodeFont(node)
2586
+
2587
+ if (!grabber && node.props?.style?.height != null) {
2588
+ // The grabber's 24pt pin shares the height spec with style.height —
2589
+ // re-assert the style value once the class is gone.
2590
+ setSizeConstraint(node, 'height', node.props.style.height)
2591
+ }
2592
+
2593
+ if (grabber) {
2594
+ node.view!.alignment = NSTextAlignment.Center
2595
+ } else {
2596
+ // Without the grabber the label returns to its textAlign — or the
2597
+ // Left default when no alignment style is in effect.
2598
+ const textAlign = node.props?.style?.textAlign
2599
+ node.view!.alignment =
2600
+ textAlign == null || textAlign === 'left'
2601
+ ? NSTextAlignment.Left
2602
+ : textAlign === 'right'
2603
+ ? NSTextAlignment.Right
2604
+ : NSTextAlignment.Center
2605
+ }
2025
2606
  }
2026
2607
 
2027
2608
  if (
@@ -2040,37 +2621,27 @@ function applyClassName(node: ElementNode, value: any) {
2040
2621
 
2041
2622
  for (const name of classes) {
2042
2623
  if (gaps[name] !== undefined) {
2043
- node.view!.spacing = gaps[name]
2624
+ node.classSpacing = gaps[name]
2044
2625
  }
2045
2626
 
2046
2627
  if (name === 'flex-row') {
2047
- node.view!.orientation = NSUserInterfaceLayoutOrientation.Horizontal
2628
+ node.classOrientation = NSUserInterfaceLayoutOrientation.Horizontal
2048
2629
  }
2049
2630
 
2050
2631
  if (name === 'flex-col') {
2051
- node.view!.orientation = NSUserInterfaceLayoutOrientation.Vertical
2052
- }
2053
-
2054
- if (name === 'items-center' || name === 'items-start' || name === 'items-end') {
2055
- node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2632
+ node.classOrientation = NSUserInterfaceLayoutOrientation.Vertical
2056
2633
  }
2057
2634
 
2058
2635
  if (name === 'vx-toast' && node.type === 'flexboxlayout') {
2059
- ;(node.view as NSStackView).edgeInsets = { top: 12, left: 12, bottom: 12, right: 12 }
2636
+ node.classInsets = { top: 12, right: 12, bottom: 12, left: 12 }
2060
2637
  }
2061
2638
 
2062
2639
  if (name === 'vx-toast-viewport') {
2063
- ;(node.view as NSStackView).spacing = 8
2640
+ node.classSpacing = 8
2064
2641
  }
2065
2642
 
2066
2643
  if (name === 'vx-button') {
2067
- node.view!.orientation = NSUserInterfaceLayoutOrientation.Horizontal
2068
- node.view!.alignment = NSLayoutAttribute.CenterY
2069
- node.view!.distribution = NSStackViewDistribution.GravityAreas
2070
- }
2071
-
2072
- if (name === 'justify-between') {
2073
- node.view!.distribution = NSStackViewDistribution.EqualSpacing
2644
+ node.classOrientation = NSUserInterfaceLayoutOrientation.Horizontal
2074
2645
  }
2075
2646
 
2076
2647
  if (name === 'flex-1') {
@@ -2093,37 +2664,21 @@ function applyClassName(node: ElementNode, value: any) {
2093
2664
  }
2094
2665
 
2095
2666
  if (name === 'rounded-full' || name.startsWith('rounded-')) {
2096
- node.view!.wantsLayer = true
2097
- node.view!.layer.cornerRadius = name === 'rounded-full' ? 12 : 8
2098
- node.view!.layer.masksToBounds = true
2667
+ node.classRadius = name === 'rounded-full' ? 12 : 8
2099
2668
  }
2100
2669
 
2101
2670
  if (name === 'bg-primary' || name === 'btn') {
2102
2671
  node.bgSlot = 'primary'
2103
- node.view!.wantsLayer = true
2104
- node.view!.layer.backgroundColor = nativeColor(
2105
- SCHEME_COLORS[nodeScheme(node)].primary,
2106
- ).CGColor
2107
-
2108
- node.view!.edgeInsets = { top: 6, left: 10, bottom: 6, right: 10 }
2672
+ node.classInsets = { top: 6, right: 10, bottom: 6, left: 10 }
2109
2673
  }
2110
2674
 
2111
2675
  if (name === 'bg-danger') {
2112
2676
  node.bgSlot = 'danger'
2113
- node.view!.wantsLayer = true
2114
- node.view!.layer.backgroundColor = nativeColor(
2115
- SCHEME_COLORS[nodeScheme(node)].danger,
2116
- ).CGColor
2117
2677
  }
2118
2678
 
2119
2679
  if (name === 'btn-secondary' || name === 'chip' || name === 'chip-off') {
2120
2680
  node.bgSlot = 'secondary'
2121
- node.view!.wantsLayer = true
2122
- node.view!.layer.backgroundColor = nativeColor(
2123
- SCHEME_COLORS[nodeScheme(node)].secondary,
2124
- ).CGColor
2125
-
2126
- node.view!.edgeInsets = { top: 4, left: 8, bottom: 4, right: 8 }
2681
+ node.classInsets = { top: 4, right: 8, bottom: 4, left: 8 }
2127
2682
  }
2128
2683
 
2129
2684
  // Panels that paint the web body's --color-surface. On this host the
@@ -2136,22 +2691,18 @@ function applyClassName(node: ElementNode, value: any) {
2136
2691
  name === 'modal-panel'
2137
2692
  ) {
2138
2693
  node.bgSlot = 'surface'
2139
- node.view!.wantsLayer = true
2140
- node.view!.layer.backgroundColor = nativeColor(
2141
- SCHEME_COLORS[nodeScheme(node)].surface,
2142
- ).CGColor
2143
2694
  }
2144
2695
  }
2696
+
2697
+ syncStackLayout(node)
2698
+
2699
+ syncEdgeInsets(node)
2700
+ syncCornerRadius(node)
2145
2701
  }
2146
2702
 
2147
2703
  if (node.type === 'textfield' || node.type === 'textview') {
2148
2704
  if (classes.includes('vx-input') || classes.includes('vx-textarea')) {
2149
- node.view!.font = fontForFamilyStyle(
2150
- 14,
2151
- node.appliedFontWeight ?? 400,
2152
- node.appliedFontFamily,
2153
- )
2154
-
2705
+ node.classFontSize = 14
2155
2706
  node.colorSlot = 'text'
2156
2707
  node.view!.drawsBackground = false
2157
2708
  if (node.type === 'textfield') {
@@ -2162,6 +2713,8 @@ function applyClassName(node: ElementNode, value: any) {
2162
2713
  node.view!.textContainerInset = { width: 0, height: 0 }
2163
2714
  }
2164
2715
  }
2716
+
2717
+ syncNodeFont(node)
2165
2718
  }
2166
2719
 
2167
2720
  applyThemeColors(node)
@@ -2176,7 +2729,7 @@ function applyClassName(node: ElementNode, value: any) {
2176
2729
  }
2177
2730
 
2178
2731
  if (node.parent) {
2179
- setStackChildPriorities(node.parent, node)
2732
+ queueLayoutReconcile(node.container, node.parent)
2180
2733
  }
2181
2734
 
2182
2735
  updateStackDistribution(node)
@@ -2194,6 +2747,38 @@ function textContent(node: ElementNode): string {
2194
2747
  return node.children.map(textContent).join('')
2195
2748
  }
2196
2749
 
2750
+ // Keep the native value intact: AppKit truncates only when drawing in the frame.
2751
+ function syncLabelOverflow(node: ElementNode) {
2752
+ const { maxLines, whiteSpace, textOverflow } = node.props
2753
+ if (whiteSpace != null && !['normal', 'nowrap'].includes(whiteSpace)) {
2754
+ throw new Error('[macos-host] label whiteSpace supports only normal and nowrap')
2755
+ }
2756
+
2757
+ if (textOverflow != null && !['clip', 'ellipsis'].includes(textOverflow)) {
2758
+ throw new Error('[macos-host] label textOverflow supports only clip and ellipsis')
2759
+ }
2760
+
2761
+ if (maxLines != null && (!Number.isInteger(maxLines) || maxLines < 0)) {
2762
+ throw new Error('[macos-host] label maxLines must be a non-negative integer')
2763
+ }
2764
+
2765
+ const singleLine = whiteSpace === 'nowrap' || maxLines === 1
2766
+ const view = node.view as NSTextField
2767
+
2768
+ view.maximumNumberOfLines = singleLine ? 1 : (maxLines ?? 0)
2769
+ view.cell.wraps = !singleLine
2770
+ view.cell.scrollable = false
2771
+ view.cell.usesSingleLineMode = singleLine
2772
+ view.cell.lineBreakMode = singleLine
2773
+ ? textOverflow === 'ellipsis'
2774
+ ? NSLineBreakMode.TruncatingTail
2775
+ : NSLineBreakMode.Clipping
2776
+ : NSLineBreakMode.WordWrapping
2777
+
2778
+ view.cell.truncatesLastVisibleLine = !singleLine && textOverflow === 'ellipsis'
2779
+ view.invalidateIntrinsicContentSize()
2780
+ }
2781
+
2197
2782
  function setLabelText(node: ElementNode, text: any) {
2198
2783
  const lineHeight = Number(node.props?.style?.lineHeight)
2199
2784
  if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
@@ -2202,6 +2787,7 @@ function setLabelText(node: ElementNode, text: any) {
2202
2787
  }
2203
2788
 
2204
2789
  const paragraphStyle = NSMutableParagraphStyle.alloc().init()
2790
+ paragraphStyle.lineBreakMode = (node.view as NSTextField).cell.lineBreakMode
2205
2791
  paragraphStyle.minimumLineHeight = lineHeight
2206
2792
  paragraphStyle.maximumLineHeight = lineHeight
2207
2793
  node.view!.attributedStringValue = NSAttributedString.alloc().initWithStringAttributes(text, {
@@ -2235,7 +2821,7 @@ function setAction(node: ElementNode, value: any) {
2235
2821
  actionIdsByView.set(node.view!, node.actionId)
2236
2822
  actionHandlers.set(node.actionId, null)
2237
2823
  node.view!.addGestureRecognizer(
2238
- NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed'),
2824
+ NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed:'),
2239
2825
  )
2240
2826
  }
2241
2827
 
@@ -2257,7 +2843,7 @@ function setPanAction(node: ElementNode, value: any) {
2257
2843
  if (typeof value === 'function' && !node.panGestureRecognizer) {
2258
2844
  node.panGestureRecognizer = NSPanGestureRecognizer.alloc().initWithTargetAction(
2259
2845
  buttonActionTarget,
2260
- 'viewPanned',
2846
+ 'viewPanned:',
2261
2847
  )
2262
2848
 
2263
2849
  node.view!.addGestureRecognizer(node.panGestureRecognizer)
@@ -2296,6 +2882,35 @@ function setControlAction(node: ElementNode, value: any, readValue: () => any) {
2296
2882
  )
2297
2883
  }
2298
2884
 
2885
+ /** Return-key submission for single-line fields, invoked by the field's
2886
+ * target/action — kept distinct from per-edit `onTextChange`. */
2887
+ function setTextSubmitAction(node: ElementNode, value: any) {
2888
+ node.submitHandler =
2889
+ typeof value === 'function'
2890
+ ? () => {
2891
+ try {
2892
+ node.container.root!.eventScope('discrete', value)
2893
+ } catch (error) {
2894
+ console.error('[macos-event] text submit handler failed', error)
2895
+ }
2896
+ }
2897
+ : null
2898
+ }
2899
+
2900
+ /** `isEnabled`/`editable` → AppKit state. Disabled fields reject edits and
2901
+ * selection; read-only fields stay selectable so text can be copied. */
2902
+ function syncTextControlState(node: ElementNode) {
2903
+ const enabled = node.props.isEnabled !== false && node.props.enabled !== false
2904
+ const editable = enabled && node.props.editable !== false
2905
+ const view = node.view!
2906
+ if (node.type === 'textfield') {
2907
+ view.enabled = enabled
2908
+ }
2909
+
2910
+ view.editable = editable
2911
+ view.selectable = editable || enabled
2912
+ }
2913
+
2299
2914
  function applyAccessibility(node: ElementNode, name: string, value: any) {
2300
2915
  if (node.type === 'flexboxlayout' && node.actionId !== undefined) {
2301
2916
  const props = stackAccessibilityPropsByView.get(node.view!) ?? {}
@@ -2312,6 +2927,20 @@ function applyAccessibility(node: ElementNode, name: string, value: any) {
2312
2927
  if (node.type === 'label' && name === 'accessibilityLabel') {
2313
2928
  node.view!.setAccessibilityLabel?.(String(value ?? ''))
2314
2929
  }
2930
+
2931
+ if (node.type === 'textfield' || node.type === 'textview') {
2932
+ if (name === 'accessibilityLabel') {
2933
+ node.view!.setAccessibilityLabel?.(String(value ?? ''))
2934
+ } else if (name === 'accessibilityHint') {
2935
+ node.view!.setAccessibilityHint?.(String(value ?? ''))
2936
+ } else if (name === 'accessible') {
2937
+ node.view!.setAccessibilityElement?.(value !== false)
2938
+ } else if (name === 'accessibilityState' && node.type === 'textview') {
2939
+ // NSTextField reports AX enabled through its `enabled` flag;
2940
+ // NSTextView has no enabled state, so it is mirrored here.
2941
+ node.view!.setAccessibilityEnabled?.(value?.disabled !== true)
2942
+ }
2943
+ }
2315
2944
  }
2316
2945
 
2317
2946
  function applyProps(node: ElementNode, props: PropBag) {
@@ -2329,7 +2958,20 @@ function applyProps(node: ElementNode, props: PropBag) {
2329
2958
  return
2330
2959
  }
2331
2960
 
2961
+ const layoutChildProps: Record<string, readonly string[]> = {
2962
+ absolutelayout: ['left', 'top', 'right', 'bottom'],
2963
+ gridlayout: ['row', 'col', 'rowSpan', 'colSpan', 'horizontalAlignment', 'verticalAlignment'],
2964
+ }
2965
+
2332
2966
  for (const [name, value] of Object.entries(props)) {
2967
+ if (node.parent && layoutChildProps[node.parent.type]?.includes(name)) {
2968
+ continue
2969
+ }
2970
+
2971
+ if ((node.type === 'flexboxlayout' || node.type === 'stack') && isStackLayoutInput(name)) {
2972
+ continue
2973
+ }
2974
+
2333
2975
  if (name === 'onLayoutChanged') {
2334
2976
  setLayoutAction(node, value)
2335
2977
  continue
@@ -2338,7 +2980,7 @@ function applyProps(node: ElementNode, props: PropBag) {
2338
2980
  switch (node.type) {
2339
2981
  case 'stack':
2340
2982
  if (name === 'spacing') {
2341
- node.view!.spacing = Number(value ?? 0)
2983
+ syncStackLayout(node)
2342
2984
  } else if (name === 'style') {
2343
2985
  applyStyle(node, value)
2344
2986
  } else if (name === 'className') {
@@ -2351,33 +2993,12 @@ function applyProps(node: ElementNode, props: PropBag) {
2351
2993
 
2352
2994
  break
2353
2995
  case 'flexboxlayout':
2354
- if (name === 'gap') {
2355
- node.view!.spacing = Number(value ?? 0)
2356
- } else if (name === 'spacing') {
2357
- node.view!.spacing = Number(value ?? 0)
2358
- } else if (name === 'flexDirection') {
2359
- node.view!.orientation =
2360
- value === 'row'
2361
- ? NSUserInterfaceLayoutOrientation.Horizontal
2362
- : NSUserInterfaceLayoutOrientation.Vertical
2363
-
2364
- node.view!.distribution = NSStackViewDistribution.GravityAreas
2365
- node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2366
- moveStackChildren(node)
2367
- updateCrossAxisConstraints(node)
2368
- } else if (name === 'alignItems') {
2369
- node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2370
- updateCrossAxisConstraints(node)
2371
- } else if (name === 'justifyContent') {
2372
- node.view!.distribution = NSStackViewDistribution.GravityAreas
2373
- moveStackChildren(node)
2996
+ if (name === 'spacing') {
2997
+ syncStackLayout(node)
2374
2998
  } else if (name === 'style') {
2375
2999
  applyStyle(node, value)
2376
3000
  } else if (name === 'className') {
2377
3001
  applyClassName(node, value)
2378
- moveStackChildren(node)
2379
- node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2380
- updateCrossAxisConstraints(node)
2381
3002
  } else if (name === 'id') {
2382
3003
  continue
2383
3004
  } else if (name === 'onTap') {
@@ -2399,18 +3020,10 @@ function applyProps(node: ElementNode, props: PropBag) {
2399
3020
  'alignItems',
2400
3021
  'justifyContent',
2401
3022
  'flexWrap',
2402
- 'row',
2403
- 'col',
2404
- 'rowSpan',
2405
- 'colSpan',
2406
- 'left',
2407
- 'top',
2408
3023
  'flexGrow',
2409
3024
  'flexShrink',
2410
3025
  'alignSelf',
2411
3026
  'order',
2412
- 'horizontalAlignment',
2413
- 'verticalAlignment',
2414
3027
  ].includes(name)
2415
3028
  ) {
2416
3029
  continue
@@ -2441,7 +3054,7 @@ function applyProps(node: ElementNode, props: PropBag) {
2441
3054
  } else if (name === 'accessible' || name.startsWith('accessibility')) {
2442
3055
  applyAccessibility(node, name, value)
2443
3056
  } else if (['rows', 'columns'].includes(name)) {
2444
- layoutGridChildren(node)
3057
+ queueLayoutReconcile(node.container, node)
2445
3058
  } else {
2446
3059
  console.warn('[macos-host] ignored gridlayout prop ' + name)
2447
3060
  }
@@ -2467,11 +3080,8 @@ function applyProps(node: ElementNode, props: PropBag) {
2467
3080
  if (name === 'text') {
2468
3081
  setLabelText(node, String(value ?? ''))
2469
3082
  } else if (name === 'fontSize') {
2470
- node.view!.font = fontForFamilyStyle(
2471
- Number(value ?? 16),
2472
- node.appliedFontWeight ?? 400,
2473
- node.appliedFontFamily,
2474
- )
3083
+ node.propFontSize = Number(value ?? 16)
3084
+ syncNodeFont(node)
2475
3085
  } else if (name === 'style') {
2476
3086
  applyStyle(node, value)
2477
3087
  syncText(node)
@@ -2479,9 +3089,9 @@ function applyProps(node: ElementNode, props: PropBag) {
2479
3089
  applyClassName(node, value)
2480
3090
  } else if (name === 'id') {
2481
3091
  continue
2482
- } else if (['maxLines', 'whiteSpace', 'textOverflow', 'accessible'].includes(name)) {
3092
+ } else if (['maxLines', 'whiteSpace', 'textOverflow'].includes(name)) {
2483
3093
  continue
2484
- } else if (name.startsWith('accessibility')) {
3094
+ } else if (name === 'accessible' || name.startsWith('accessibility')) {
2485
3095
  applyAccessibility(node, name, value)
2486
3096
  } else if (name.startsWith('on') && value == null) {
2487
3097
  continue
@@ -2563,6 +3173,30 @@ function applyProps(node: ElementNode, props: PropBag) {
2563
3173
  setControlAction(node, value, () =>
2564
3174
  String(node.type === 'textview' ? node.view!.string : (node.view!.stringValue ?? '')),
2565
3175
  )
3176
+ } else if (name === 'onSubmit') {
3177
+ if (node.type === 'textfield') {
3178
+ setTextSubmitAction(node, value)
3179
+ } else if (typeof value === 'function') {
3180
+ console.warn('[macos-host] onSubmit is unsupported on multiline textview')
3181
+ }
3182
+ } else if (name === 'editable' || name === 'isEnabled' || name === 'enabled') {
3183
+ syncTextControlState(node)
3184
+ } else if (name === 'secure') {
3185
+ // The native class is chosen in makeTextField; a secure change
3186
+ // would require a new view, so it is rejected rather than
3187
+ // silently downgraded to plain text.
3188
+ if (node.type !== 'textfield') {
3189
+ if (value) {
3190
+ console.warn(
3191
+ '[macos-host] secure is unsupported on <' + node.type + '>; rendering plain text',
3192
+ )
3193
+ }
3194
+ } else if (!!value !== node.secure) {
3195
+ console.warn(
3196
+ '[macos-host] changing "secure" after mount is unsupported; the field keeps its initial secure=' +
3197
+ node.secure,
3198
+ )
3199
+ }
2566
3200
  } else if (name === 'style') {
2567
3201
  applyStyle(node, value)
2568
3202
  } else if (name === 'className') {
@@ -2571,21 +3205,11 @@ function applyProps(node: ElementNode, props: PropBag) {
2571
3205
  continue
2572
3206
  } else if (name === 'rows' && node.type === 'textview') {
2573
3207
  continue
2574
- } else if (name.startsWith('accessibility')) {
3208
+ } else if (name === 'accessible' || name.startsWith('accessibility')) {
2575
3209
  applyAccessibility(node, name, value)
2576
3210
  } else if (name.startsWith('on') && value == null) {
2577
3211
  continue
2578
- } else if (
2579
- [
2580
- 'editable',
2581
- 'enabled',
2582
- 'secure',
2583
- 'keyboardType',
2584
- 'returnKeyType',
2585
- 'autoGrow',
2586
- 'maxRows',
2587
- ].includes(name)
2588
- ) {
3212
+ } else if (['keyboardType', 'returnKeyType', 'autoGrow', 'maxRows'].includes(name)) {
2589
3213
  continue
2590
3214
  } else {
2591
3215
  console.warn('[macos-host] ignored text control prop ' + name)
@@ -2644,7 +3268,7 @@ function applyProps(node: ElementNode, props: PropBag) {
2644
3268
  break
2645
3269
  case 'image':
2646
3270
  if (name === 'src') {
2647
- node.view!.image = loadImage(value)
3271
+ updateImage(node.view as NSImageView, value)
2648
3272
  } else if (name === 'style') {
2649
3273
  applyStyle(node, value)
2650
3274
  } else if (name === 'className' || name === 'id' || name === 'alt') {
@@ -2660,6 +3284,13 @@ function applyProps(node: ElementNode, props: PropBag) {
2660
3284
  }
2661
3285
  }
2662
3286
 
3287
+ if (
3288
+ (node.type === 'flexboxlayout' || node.type === 'stack') &&
3289
+ Object.keys(props).some(isStackLayoutInput)
3290
+ ) {
3291
+ syncStackLayout(node)
3292
+ }
3293
+
2663
3294
  if (node.type === 'webview') {
2664
3295
  updateWebView(node, props, (height) => {
2665
3296
  node.props.style = { ...node.props.style, height }
@@ -2672,8 +3303,17 @@ function applyProps(node: ElementNode, props: PropBag) {
2672
3303
  }
2673
3304
 
2674
3305
  if (node.type === 'label') {
3306
+ syncLabelOverflow(node)
3307
+ syncText(node)
2675
3308
  const style = node.props.style ?? {}
2676
- if (style.lineHeight == null && style.height == null) {
3309
+ if (
3310
+ style.height == null &&
3311
+ node.props.maxLines != null &&
3312
+ node.props.maxLines !== 1 &&
3313
+ node.props.whiteSpace !== 'nowrap'
3314
+ ) {
3315
+ setSizeConstraint(node, 'height', undefined)
3316
+ } else if (style.lineHeight == null && style.height == null) {
2677
3317
  const size = Number(node.view!.font?.pointSize ?? 16)
2678
3318
  const height =
2679
3319
  style.fontSize == null && node.headingDefaultHeight != null
@@ -2700,14 +3340,10 @@ function applyProps(node: ElementNode, props: PropBag) {
2700
3340
  }
2701
3341
  }
2702
3342
 
2703
- if (node.type === 'gridlayout') {
2704
- layoutGridChildren(node)
2705
- } else if (node.type === 'absolutelayout') {
2706
- layoutAbsoluteChildren(node)
2707
- } else if (node.parent?.type === 'gridlayout') {
2708
- layoutGridChildren(node.parent)
2709
- } else if (node.parent?.type === 'absolutelayout') {
2710
- layoutAbsoluteChildren(node.parent)
3343
+ if (node.type === 'gridlayout' || node.type === 'absolutelayout') {
3344
+ queueLayoutReconcile(node.container, node)
3345
+ } else if (node.parent?.type === 'gridlayout' || node.parent?.type === 'absolutelayout') {
3346
+ queueLayoutReconcile(node.container, node.parent)
2711
3347
  }
2712
3348
  }
2713
3349
 
@@ -2725,6 +3361,7 @@ function detach(container: RootContainer, node: ElementNode) {
2725
3361
 
2726
3362
  node.crossAxisConstraint = null
2727
3363
  deactivateSizeConstraints(node)
3364
+ releasePlacementPins(node)
2728
3365
  if (node.view) {
2729
3366
  const parentView = previousParent?.childHost ?? previousParent?.view
2730
3367
  if (parentView?.removeArrangedSubview) {
@@ -2734,20 +3371,53 @@ function detach(container: RootContainer, node: ElementNode) {
2734
3371
  arrangedView(node).removeFromSuperview()
2735
3372
  }
2736
3373
 
2737
- if (previousParent?.type === 'gridlayout') {
2738
- layoutGridChildren(previousParent)
3374
+ queueLayoutReconcile(container, previousParent)
3375
+ syncText(previousParent)
3376
+ node.parent = null
3377
+ }
3378
+
3379
+ /** Mark a parent for one sibling-wide reconcile after the batch's commands.
3380
+ * Insert/remove/update each used to rescan siblings, rebuild distribution,
3381
+ * and recreate every child's cross-axis constraint on the spot — n inserts
3382
+ * into one stack made that quadratic. Structural commands queue their dirty
3383
+ * parents here; `prepareBatch.apply` drains the set once at the end. Outside
3384
+ * a batch (no `layoutDirty` set) the reconcile runs immediately. */
3385
+ function queueLayoutReconcile(container: RootContainer, parent: ElementNode | null | undefined) {
3386
+ if (!parent) {
3387
+ return
3388
+ }
3389
+
3390
+ const pending = container?.layoutDirty
3391
+ if (pending) {
3392
+ pending.add(parent)
3393
+ return
2739
3394
  }
2740
3395
 
2741
- if (previousParent?.type === 'absolutelayout') {
2742
- layoutAbsoluteChildren(previousParent)
3396
+ reconcileLayoutParent(parent)
3397
+ }
3398
+
3399
+ function reconcileLayoutParent(parent: ElementNode) {
3400
+ if (parent.type === 'gridlayout') {
3401
+ layoutGridChildren(parent)
3402
+ return
2743
3403
  }
2744
3404
 
2745
- if (previousParent) {
2746
- updateStackDistribution(previousParent)
3405
+ if (parent.type === 'absolutelayout') {
3406
+ layoutAbsoluteChildren(parent)
3407
+ return
2747
3408
  }
2748
3409
 
2749
- syncText(previousParent)
2750
- node.parent = null
3410
+ const stack = parent.childHost ?? parent.view
3411
+ if (typeof stack?.addViewInGravity !== 'function') {
3412
+ return
3413
+ }
3414
+
3415
+ for (const child of parent.children) {
3416
+ setStackChildPriorities(parent, child)
3417
+ }
3418
+
3419
+ updateStackDistribution(parent)
3420
+ updateCrossAxisConstraints(parent)
2751
3421
  }
2752
3422
 
2753
3423
  function insert(
@@ -2756,62 +3426,54 @@ function insert(
2756
3426
  node: ElementNode,
2757
3427
  beforeId: number | null,
2758
3428
  ) {
2759
- detach(container, node)
3429
+ // Validate before detaching — a rejected insert must not leave the node
3430
+ // orphaned from its current parent.
2760
3431
  const parent = (parentId === null ? null : container.nodes.get(parentId)) ?? null
2761
3432
  if (parentId !== null && !parent) {
2762
3433
  throw new Error('Unknown AppKit parent ' + parentId)
2763
3434
  }
2764
3435
 
2765
- if (
2766
- parent &&
2767
- node.view &&
2768
- parent.type !== 'stack' &&
2769
- parent.type !== 'flexboxlayout' &&
2770
- parent.type !== 'scrollview' &&
2771
- parent.type !== 'gridlayout' &&
2772
- parent.type !== 'absolutelayout'
2773
- ) {
3436
+ if (parent && node.view && !VIEW_PARENT_TYPES.has(parent.type)) {
2774
3437
  throw new Error('AppKit <' + parent.type + '> cannot contain child views')
2775
3438
  }
2776
3439
 
3440
+ const parentView = parent?.childHost ?? parent?.view ?? container.hostView
3441
+ if (node.view && !parentView) {
3442
+ throw new Error('AppKit host has no parent view for node ' + node.id)
3443
+ }
3444
+
3445
+ detach(container, node)
2777
3446
  const siblings = parent ? parent.children : container.children
2778
3447
  const beforeIndex = beforeId === null ? -1 : siblings.findIndex((child) => child.id === beforeId)
2779
3448
  const index = beforeIndex < 0 ? siblings.length : beforeIndex
2780
3449
  siblings.splice(index, 0, node)
2781
3450
  node.parent = parent
2782
3451
  if (node.view) {
2783
- const parentView = parent?.childHost ?? parent?.view ?? container.hostView
2784
- if (!parentView) {
2785
- throw new Error('AppKit host has no parent view for node ' + node.id)
2786
- }
2787
-
2788
3452
  if (parent) {
2789
3453
  if (parent.type === 'gridlayout') {
2790
3454
  parentView.addSubview(node.view)
2791
3455
  node.view.translatesAutoresizingMaskIntoConstraints = false
2792
3456
  deactivateSizeConstraints(node)
2793
- layoutGridChildren(parent)
3457
+ queueLayoutReconcile(container, parent)
2794
3458
  } else if (parent.type === 'absolutelayout') {
2795
3459
  parentView.addSubview(node.view)
2796
3460
  node.view.translatesAutoresizingMaskIntoConstraints = false
2797
3461
  deactivateSizeConstraints(node)
2798
- layoutAbsoluteChildren(parent)
3462
+ queueLayoutReconcile(container, parent)
2799
3463
  } else {
2800
- if (marginInsetsOf(node)) {
3464
+ if (marginInsetsOf(node) && !node.marginHost) {
2801
3465
  makeMarginHost(node)
3466
+ setLayoutAction(node, node.props?.onLayoutChanged)
2802
3467
  }
2803
3468
 
2804
3469
  // AppKit insertion indices are local to a gravity area. Honor the
2805
3470
  // renderer's before edge instead of appending moved keyed rows.
2806
3471
  const gravity = stackGravity(parent, node)
2807
- const arrangedIndex = siblings
2808
- .slice(0, index)
2809
- .filter((child) => child.view && stackGravity(parent, child) === gravity).length
3472
+ const arrangedIndex = gravityInsertIndex(parent, node, gravity)
2810
3473
 
2811
3474
  parentView.insertViewAtIndexInGravity(arrangedView(node), arrangedIndex, gravity)
2812
3475
  applySizeConstraints(node)
2813
- setStackChildPriorities(parent, node)
2814
- updateCrossAxisConstraints(parent)
3476
+ queueLayoutReconcile(container, parent)
2815
3477
  }
2816
3478
  } else {
2817
3479
  parentView.addSubview(node.view)
@@ -2846,6 +3508,7 @@ function remove(container: RootContainer, parentId: number | null, node: Element
2846
3508
 
2847
3509
  node.crossAxisConstraint = null
2848
3510
  deactivateSizeConstraints(node)
3511
+ releasePlacementPins(node)
2849
3512
  if (node.view) {
2850
3513
  const parentView = expectedParent?.childHost ?? expectedParent?.view
2851
3514
  if (parentView?.removeArrangedSubview) {
@@ -2856,31 +3519,27 @@ function remove(container: RootContainer, parentId: number | null, node: Element
2856
3519
  node.marginHost?.removeFromSuperview()
2857
3520
  node.marginHost = null
2858
3521
  node.marginConstraints = null
3522
+ setLayoutAction(node, node.props?.onLayoutChanged)
2859
3523
  }
2860
3524
 
2861
- if (expectedParent?.type === 'gridlayout') {
2862
- layoutGridChildren(expectedParent)
2863
- }
2864
-
2865
- if (expectedParent?.type === 'absolutelayout') {
2866
- layoutAbsoluteChildren(expectedParent)
2867
- }
2868
-
2869
- if (expectedParent) {
2870
- updateStackDistribution(expectedParent)
2871
- }
2872
-
3525
+ queueLayoutReconcile(container, expectedParent)
2873
3526
  node.parent = null
2874
3527
  syncText(expectedParent)
2875
3528
  }
2876
3529
 
2877
3530
  function destroy(node: ElementNode) {
2878
- if (node.type === 'webview') {
2879
- disposeWebView(node.view!)
3531
+ // Nodes destroyed by a failed makeNode may have a null view — every
3532
+ // branch below must tolerate partial construction.
3533
+ if (node.type === 'webview' && node.view) {
3534
+ disposeWebView(node.view as WKWebView)
2880
3535
  }
2881
3536
 
2882
- if (node.type === 'scrollview' && node.scrollObserverInstalled) {
2883
- const clipView = node.view!.contentView
3537
+ if (node.type === 'image' && node.view) {
3538
+ disposeImage(node.view as NSImageView)
3539
+ }
3540
+
3541
+ if (node.type === 'scrollview' && node.scrollObserverInstalled && node.view) {
3542
+ const clipView = node.view.contentView
2884
3543
  scrollHandlers.delete(clipView)
2885
3544
  NSNotificationCenter.defaultCenter.removeObserver(node.scrollObserver)
2886
3545
  node.scrollObserver = null
@@ -2892,6 +3551,9 @@ function destroy(node: ElementNode) {
2892
3551
  node.layoutObserver = null
2893
3552
  }
2894
3553
 
3554
+ layoutHandlers.delete(node.layoutObservedView)
3555
+ node.layoutObservedView = null
3556
+
2895
3557
  if (node.actionId !== undefined) {
2896
3558
  actionHandlers.delete(node.actionId)
2897
3559
  accessibilityLabels.delete(node.actionId)
@@ -2899,18 +3561,127 @@ function destroy(node: ElementNode) {
2899
3561
  }
2900
3562
  }
2901
3563
 
3564
+ /** Pre-flight a whole batch without mutating, so a deterministic failure —
3565
+ * bad props, unknown nodes, illegal parents, unsupported ops — rejects the
3566
+ * batch before any command applies. Residual mid-apply failures are then
3567
+ * limited to unexpected native errors, and each command unwinds itself. */
3568
+ function validateBatch(container: RootContainer, commands: readonly UniversalHostCommand[]) {
3569
+ // Ids created by this batch are not in container.nodes yet — track them so
3570
+ // later commands validate against the intended tree.
3571
+ const pending = new Map<number, { type: string; props: PropBag }>()
3572
+ const destroyed = new Set<number>()
3573
+ const known = (id: number): { type: string; props: PropBag } | undefined =>
3574
+ pending.get(id) ?? (destroyed.has(id) ? undefined : container.nodes.get(id))
3575
+
3576
+ for (const command of commands) {
3577
+ switch (command.op) {
3578
+ case 'create':
3579
+ assertSupportedType(command.type)
3580
+ validateNodeProps(command.type, command.props)
3581
+ pending.set(command.id, { type: command.type, props: command.props })
3582
+ destroyed.delete(command.id)
3583
+ continue
3584
+ case 'recreate': {
3585
+ const entry = known(command.id)
3586
+ if (!entry) {
3587
+ throw new Error('Unknown AppKit node ' + command.id)
3588
+ }
3589
+
3590
+ assertSupportedType(command.type)
3591
+ validateNodeProps(command.type, command.props)
3592
+ const existing = container.nodes.get(command.id)
3593
+ if (
3594
+ existing?.parent &&
3595
+ typeHasView(command.type) &&
3596
+ !VIEW_PARENT_TYPES.has(existing.parent.type)
3597
+ ) {
3598
+ throw new Error('AppKit <' + existing.parent.type + '> cannot contain child views')
3599
+ }
3600
+
3601
+ pending.set(command.id, { type: command.type, props: command.props })
3602
+ continue
3603
+ }
3604
+ case 'update': {
3605
+ const entry = known(command.id)
3606
+ if (!entry) {
3607
+ throw new Error('Unknown AppKit node ' + command.id)
3608
+ }
3609
+
3610
+ validateNodeProps(entry.type, command.props, entry.props)
3611
+ continue
3612
+ }
3613
+ case 'insert':
3614
+ case 'move': {
3615
+ const entry = known(command.id)
3616
+ if (!entry) {
3617
+ throw new Error('Unknown AppKit node ' + command.id)
3618
+ }
3619
+
3620
+ const parentId = command.parent as number | null
3621
+ if (parentId !== null) {
3622
+ const parent = known(parentId)
3623
+ if (!parent) {
3624
+ throw new Error('Unknown AppKit parent ' + parentId)
3625
+ }
3626
+
3627
+ if (typeHasView(entry.type) && !VIEW_PARENT_TYPES.has(parent.type)) {
3628
+ throw new Error('AppKit <' + parent.type + '> cannot contain child views')
3629
+ }
3630
+ }
3631
+
3632
+ continue
3633
+ }
3634
+ case 'destroy':
3635
+ pending.delete(command.id)
3636
+ destroyed.add(command.id)
3637
+ continue
3638
+ case 'remove':
3639
+ case 'visibility':
3640
+ case 'ensure-public-instance':
3641
+ continue
3642
+ default:
3643
+ throw new Error('AppKit spike does not support host command ' + command.op)
3644
+ }
3645
+ }
3646
+ }
3647
+
2902
3648
  function applyCommand(container: RootContainer, command: UniversalHostCommand) {
2903
3649
  switch (command.op) {
2904
- case 'create':
3650
+ case 'create': {
3651
+ // A retried batch can re-create an id the earlier attempt already
3652
+ // registered — release the previous node instead of orphaning it.
3653
+ const existing = container.nodes.get(command.id)
3654
+ if (existing) {
3655
+ detach(container, existing)
3656
+ destroy(existing)
3657
+ }
3658
+
2905
3659
  container.nodes.set(command.id, makeNode(container, command.id, command.type, command.props))
2906
3660
  return
3661
+ }
2907
3662
  case 'update': {
2908
3663
  const node = container.nodes.get(command.id)
2909
3664
  if (!node) {
2910
3665
  throw new Error('Unknown AppKit node ' + command.id)
2911
3666
  }
2912
3667
 
2913
- applyProps(node, command.props)
3668
+ // node.props records what applied — restore it when a prop throws
3669
+ // mid-update so the bookkeeping doesn't claim unapplied props.
3670
+ const previousProps = node.props
3671
+ try {
3672
+ // Host updates carry the complete current prop bag. Clear omitted
3673
+ // label modes before merging so spread removals restore defaults.
3674
+ applyProps(
3675
+ node,
3676
+ node.type === 'label'
3677
+ ? { maxLines: undefined, whiteSpace: undefined, textOverflow: undefined, ...command.props }
3678
+ : command.props,
3679
+ )
3680
+ } catch (error) {
3681
+ node.props = previousProps
3682
+ throw error
3683
+ }
3684
+
2914
3685
  return
2915
3686
  }
2916
3687
  case 'insert':
@@ -2956,8 +3727,14 @@ function applyCommand(container: RootContainer, command: UniversalHostCommand) {
2956
3727
  throw new Error('Unknown AppKit node ' + command.id)
2957
3728
  }
2958
3729
 
2959
- const replacement = makeNode(container, command.id, command.type, command.props)
2960
3730
  const parent = node.parent
3731
+ if (parent && typeHasView(command.type) && !VIEW_PARENT_TYPES.has(parent.type)) {
3732
+ // insert would reject the replacement after the original was
3733
+ // already removed and destroyed — fail while it is intact.
3734
+ throw new Error('AppKit <' + parent.type + '> cannot contain child views')
3735
+ }
3736
+
3737
+ const replacement = makeNode(container, command.id, command.type, command.props)
2961
3738
  const siblings = parent ? parent.children : container.children
2962
3739
  const index = siblings.indexOf(node)
2963
3740
  const beforeId = index < 0 ? null : (siblings[index + 1]?.id ?? null)
@@ -2976,26 +3753,41 @@ const macOSDriver: UniversalHostDriver<RootContainer, any> = {
2976
3753
  id: 'macos',
2977
3754
  capabilities: { text: 'host' },
2978
3755
  prepareBatch(container, batch) {
3756
+ // Reject deterministic failures before any command mutates — a
3757
+ // partially applied batch cannot be rolled back once commit() marks
3758
+ // it accepted. Prepare acquires nothing, so abort() has no cleanup.
3759
+ validateBatch(container, batch.commands)
2979
3760
  return {
2980
3761
  apply() {
2981
- for (const command of batch.commands) {
2982
- try {
2983
- applyCommand(container, command)
2984
- } catch (error) {
2985
- // The universal root can swallow a mid-batch failure when it
2986
- // retries or aborts the attempt — log it here so an
2987
- // unsupported element cannot silently stall the mount.
2988
- console.error(
2989
- '[macos-host] command ' +
2990
- command.op +
2991
- ' failed for <' +
2992
- ((command as { type?: string }).type ?? '?') +
2993
- '> id=' +
2994
- ('id' in command ? command.id : '?'),
2995
- error,
2996
- )
2997
-
2998
- throw error
3762
+ container.layoutDirty = new Set()
3763
+ try {
3764
+ for (const command of batch.commands) {
3765
+ try {
3766
+ applyCommand(container, command)
3767
+ } catch (error) {
3768
+ // The universal root can swallow a mid-batch failure when it
3769
+ // retries or aborts the attempt — log it here so an
3770
+ // unsupported element cannot silently stall the mount.
3771
+ console.error(
3772
+ '[macos-host] command ' +
3773
+ command.op +
3774
+ ' failed for <' +
3775
+ ((command as { type?: string }).type ?? '?') +
3776
+ '> id=' +
3777
+ ('id' in command ? command.id : '?'),
3778
+ error,
3779
+ )
3780
+
3781
+ throw error
3782
+ }
3783
+ }
3784
+ } finally {
3785
+ const dirty = container.layoutDirty
3786
+ container.layoutDirty = null
3787
+ if (dirty) {
3788
+ for (const parent of dirty) {
3789
+ reconcileLayoutParent(parent)
3790
+ }
2999
3791
  }
3000
3792
  }
3001
3793
  },
@@ -3749,7 +4541,7 @@ function attachContextMenu(
3749
4541
 
3750
4542
  const menuItem = NSMenuItem.alloc().initWithTitleActionKeyEquivalent(
3751
4543
  String(item?.title ?? ''),
3752
- 'menuItemSelected',
4544
+ 'menuItemSelected:',
3753
4545
  '',
3754
4546
  )
3755
4547
 
@@ -3882,7 +4674,7 @@ function attachDatePicker(view: NSView, options: PropBag, onChange: (event: any)
3882
4674
  const actionId = nextActionId++
3883
4675
  picker.tag = actionId
3884
4676
  picker.target = buttonActionTarget
3885
- picker.action = 'controlChanged'
4677
+ picker.action = 'controlChanged:'
3886
4678
  actionHandlers.set(actionId, () => {
3887
4679
  const date = picker.dateValue
3888
4680
  const millis =
@@ -4088,10 +4880,11 @@ function presentSheet(view: NSView, options: PropBag) {
4088
4880
  }
4089
4881
 
4090
4882
  const appKitBridge = (globalThis.__xplatAppKit ??= {})
4883
+ installAppearanceBridge(appKitBridge)
4091
4884
  appKitBridge.observeHover = observeHover
4092
4885
  appKitBridge.showAnchoredPopup = showAnchoredPopup
4093
4886
  appKitBridge.showLayer = (options: PropBag) =>
4094
- showWindowLayer(options, {
4887
+ showWindowLayer(options as WindowLayerOptions, {
4095
4888
  createRoot: (view: NSView, anchor: NSView) =>
4096
4889
  createMacOSRoot(view, { fontFamily: fontFamilyForView(anchor) }),
4097
4890
  fittingSize: popupFittingSize,
@@ -4105,7 +4898,7 @@ installPresentationBridge(appKitBridge, createMacOSRoot, fontFamilyForView)
4105
4898
  const rootFontFamilies = new WeakMap<object, string | undefined>()
4106
4899
 
4107
4900
  function fontFamilyForView(view: NSView | undefined): string | undefined {
4108
- for (let current = view; current; current = current.superview) {
4901
+ for (let current: NSView | null | undefined = view; current; current = current.superview) {
4109
4902
  if (rootFontFamilies.has(current)) {
4110
4903
  return rootFontFamilies.get(current)
4111
4904
  }
@@ -4127,6 +4920,7 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
4127
4920
  nodes: new Map(),
4128
4921
  children: [],
4129
4922
  root: null,
4923
+ layoutDirty: null,
4130
4924
  }
4131
4925
 
4132
4926
  const root = createUniversalRoot(container, macOSDriver, {
@@ -4303,7 +5097,7 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
4303
5097
  try {
4304
5098
  // Row box relative to the clip's top edge, computed in doc
4305
5099
  // coordinates so it stays docH-independent like `offset`.
4306
- const inDoc = child.view.superview.convertRectToView(child.view.frame, doc)
5100
+ const inDoc = child.view.superview!.convertRectToView(child.view.frame, doc)
4307
5101
  const h = Number(inDoc.size.height)
4308
5102
  const y = Number(inDoc.origin.y)
4309
5103
  const top = flipped ? y - originY : originY + clipH - y - h
@@ -4367,7 +5161,18 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
4367
5161
  throw new Error('No AppKit node with id ' + id)
4368
5162
  }
4369
5163
 
4370
- applyStyle(node, { [name]: value })
5164
+ // Single-key writes merge into the node's style bag — applyStyle
5165
+ // diffs against the last applied bag, so a partial object here
5166
+ // would reset every other style.
5167
+ const style = { ...node.props?.style }
5168
+ if (value == null) {
5169
+ delete style[name]
5170
+ } else {
5171
+ style[name] = value
5172
+ }
5173
+
5174
+ node.props = { ...node.props, style }
5175
+ applyStyle(node, style)
4371
5176
  container.hostView.layoutSubtreeIfNeeded?.()
4372
5177
  return this.frameInWindow(id)
4373
5178
  },
@@ -4588,7 +5393,7 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
4588
5393
  buttonActionTarget.textDidChange({ object: node.view } as NSNotification)
4589
5394
  } else {
4590
5395
  node.view!.stringValue = String(value)
4591
- buttonActionTarget.controlChanged(node.view!)
5396
+ buttonActionTarget.controlTextDidChange({ object: node.view } as NSNotification)
4592
5397
  }
4593
5398
  },
4594
5399
  pressAccessibilityLabel(label: string) {