@octane-xplat/macos-renderer 0.10.0 → 0.11.1

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 {
@@ -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,23 +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)
640
+ const gravity = stackGravity(parent, node)
641
+ const index = gravityInsertIndex(parent, node, gravity)
530
642
  if (insets && !node.marginHost) {
531
- const gravity = stackGravity(parent, node)
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)
647
+ queueLayoutReconcile(node.container, parent)
648
+ setLayoutAction(node, node.props?.onLayoutChanged)
537
649
  } else if (!insets && node.marginHost) {
538
- const gravity = stackGravity(parent, node)
539
650
  stack.removeArrangedSubview(node.marginHost)
540
651
  node.view.removeFromSuperview()
541
652
  node.marginHost.removeFromSuperview()
542
653
  node.marginHost = null
543
654
  node.marginConstraints = null
544
655
  stack.insertViewAtIndexInGravity(node.view, index, gravity)
545
- updateCrossAxisConstraints(parent)
656
+ queueLayoutReconcile(node.container, parent)
657
+ setLayoutAction(node, node.props?.onLayoutChanged)
546
658
  } else if (insets && node.marginHost) {
547
659
  const [leading, trailing, top, bottom] = node.marginConstraints
548
660
  leading.constant = insets.left
@@ -577,7 +689,7 @@ function setMarginStyle(node: ElementNode, name: string, value: any) {
577
689
 
578
690
  if (node.parent?.type === 'gridlayout') {
579
691
  // Grids lay out children manually from props.style — no wrapper needed.
580
- layoutGridChildren(node.parent)
692
+ queueLayoutReconcile(node.container, node.parent)
581
693
  return
582
694
  }
583
695
 
@@ -614,6 +726,24 @@ function stackGravity(parent: ElementNode, child: ElementNode) {
614
726
  return leading
615
727
  }
616
728
 
729
+ /** AppKit insertion indices are local to a gravity area — count the
730
+ * same-gravity siblings ahead of `node`, not its logical child index.
731
+ * Shared by stack insertion and margin-host reinsertion. */
732
+ function gravityInsertIndex(parent: ElementNode, node: ElementNode, gravity: number) {
733
+ let index = 0
734
+ for (const sibling of parent.children) {
735
+ if (sibling === node) {
736
+ break
737
+ }
738
+
739
+ if (sibling.view && stackGravity(parent, sibling) === gravity) {
740
+ index++
741
+ }
742
+ }
743
+
744
+ return index
745
+ }
746
+
617
747
  function moveStackChildren(parent: ElementNode) {
618
748
  const stack = parent.childHost ?? parent.view
619
749
  if (typeof stack?.addViewInGravity !== 'function') {
@@ -629,9 +759,13 @@ function moveStackChildren(parent: ElementNode) {
629
759
  stack.removeArrangedSubview(arranged)
630
760
  arranged.removeFromSuperview()
631
761
  stack.addViewInGravity(arranged, stackGravity(parent, child))
762
+ // Removing a view drops constraints to its old superview, including
763
+ // percentage dimensions. Reinstall them after reattaching.
764
+ applySizeConstraints(child)
632
765
  setStackChildPriorities(parent, child)
633
766
  }
634
767
 
768
+ updateStackDistribution(parent)
635
769
  updateCrossAxisConstraints(parent)
636
770
  }
637
771
 
@@ -672,8 +806,6 @@ function setStackChildPriorities(parent: ElementNode, child: ElementNode) {
672
806
  target.setContentHuggingPriorityForOrientation(priority, orientation)
673
807
  target.setContentCompressionResistancePriorityForOrientation(750, orientation)
674
808
  }
675
-
676
- updateStackDistribution(parent)
677
809
  }
678
810
 
679
811
  function makeFlexbox(props: PropBag) {
@@ -684,11 +816,12 @@ function makeFlexbox(props: PropBag) {
684
816
  : undefined
685
817
 
686
818
  if (actionId !== undefined) {
687
- actionIdsByView.set(stack, actionId)
688
- actionHandlers.set(actionId, null)
689
819
  stack.addGestureRecognizer(
690
- NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed'),
820
+ NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed:'),
691
821
  )
822
+
823
+ actionIdsByView.set(stack, actionId)
824
+ actionHandlers.set(actionId, null)
692
825
  }
693
826
 
694
827
  return { view: stack, actionId }
@@ -717,7 +850,7 @@ function makeButton(props: PropBag) {
717
850
  const button = NSButton.buttonWithTitleTargetAction(
718
851
  String(props.title ?? 'Button'),
719
852
  buttonActionTarget,
720
- 'buttonPressed',
853
+ 'buttonPressed:',
721
854
  )
722
855
 
723
856
  button.bezelStyle = NSBezelStyle.Rounded
@@ -810,6 +943,22 @@ function setScrollAction(node: ElementNode, handler: ((event: any) => void) | un
810
943
 
811
944
  const layoutHandlers = new WeakMap<object, () => void>()
812
945
 
946
+ /** Margin changes swap a node's arranged view without touching the node.
947
+ * Drop the observer and handler bound to the stale target — the caller
948
+ * re-runs `setLayoutAction`, which rebuilds both against the current
949
+ * arranged view so events keep reporting that view's bounds. */
950
+ function syncLayoutObserver(node: ElementNode) {
951
+ const observed = node.layoutObservedView
952
+ if (!observed || observed === arrangedView(node)) {
953
+ return
954
+ }
955
+
956
+ layoutHandlers.delete(observed)
957
+ NSNotificationCenter.defaultCenter.removeObserver(node.layoutObserver)
958
+ node.layoutObserver = null
959
+ node.layoutObservedView = null
960
+ }
961
+
813
962
  /** `onLayoutChanged` prop: NSViewFrameDidChangeNotification on the arranged
814
963
  * view (margin host when present) — the windowed VirtualList leaf measures
815
964
  * rows through it. */
@@ -819,6 +968,7 @@ function setLayoutAction(node: ElementNode, handler: ((event: any) => void) | un
819
968
  return
820
969
  }
821
970
 
971
+ syncLayoutObserver(node)
822
972
  if (typeof handler !== 'function') {
823
973
  layoutHandlers.delete(target)
824
974
  return
@@ -835,6 +985,8 @@ function setLayoutAction(node: ElementNode, handler: ((event: any) => void) | un
835
985
  null,
836
986
  () => layoutHandlers.get(target)?.(),
837
987
  )
988
+
989
+ node.layoutObservedView = target
838
990
  }
839
991
 
840
992
  layoutHandlers.set(target, () => {
@@ -878,10 +1030,12 @@ function setTextFieldPlaceholder(field: NSTextField, props: PropBag, scheme = 'l
878
1030
  function makeTextField(props: PropBag, multiline = false) {
879
1031
  const field = multiline
880
1032
  ? 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
- })
1033
+ : (props.secure ? ContentAlignedSecureTextField : ContentAlignedTextField)
1034
+ .alloc()
1035
+ .initWithFrame({
1036
+ origin: { x: 0, y: 0 },
1037
+ size: { width: 320, height: 28 },
1038
+ })
885
1039
 
886
1040
  field.translatesAutoresizingMaskIntoConstraints = false
887
1041
  field.font = fontForStyle(14)
@@ -892,7 +1046,7 @@ function makeTextField(props: PropBag, multiline = false) {
892
1046
  field.editable = true
893
1047
  field.selectable = true
894
1048
  field.sendsActionOnEndEditing = false
895
- setTextFieldPlaceholder(field, props)
1049
+ setTextFieldPlaceholder(field as NSTextField, props)
896
1050
  } else {
897
1051
  field.editable = true
898
1052
  field.selectable = true
@@ -911,7 +1065,7 @@ function makeTextField(props: PropBag, multiline = false) {
911
1065
  }
912
1066
 
913
1067
  function makeSwitch(props: PropBag) {
914
- const button = NSButton.buttonWithTitleTargetAction('', buttonActionTarget, 'controlChanged')
1068
+ const button = NSButton.buttonWithTitleTargetAction('', buttonActionTarget, 'controlChanged:')
915
1069
  button.setButtonType(NSButtonType.Switch)
916
1070
  button.state = props.checked ? 1 : 0
917
1071
  button.translatesAutoresizingMaskIntoConstraints = false
@@ -931,7 +1085,7 @@ function makeSlider(props: PropBag) {
931
1085
  slider.maxValue = Number(props.maxValue ?? 1)
932
1086
  slider.doubleValue = Number(props.value ?? 0)
933
1087
  slider.target = buttonActionTarget
934
- slider.action = 'controlChanged'
1088
+ slider.action = 'controlChanged:'
935
1089
  slider.translatesAutoresizingMaskIntoConstraints = false
936
1090
  const actionId = nextActionId++
937
1091
  slider.tag = actionId
@@ -1217,6 +1371,58 @@ function trackOffset(sizes: number[], index: number) {
1217
1371
  return offset
1218
1372
  }
1219
1373
 
1374
+ /** Pin a grid/absolute child to the rect its parent's layout pass computed.
1375
+ * Children are translates=false views, so Auto Layout owns their frames —
1376
+ * an imperative frame write only holds until the next solve shrinks the
1377
+ * child back to fitting size. Each axis gets one edge pin against the
1378
+ * parent plus one size pin, so parent geometry stays an input; a pin set
1379
+ * that also implied the parent's size would outrank the window's
1380
+ * 500-priority WindowSizeStayPut hold and lock the window at the content's
1381
+ * fitting size. */
1382
+ function pinLayoutChild(
1383
+ child: ElementNode,
1384
+ parentView: any,
1385
+ x: number,
1386
+ top: number,
1387
+ width: number,
1388
+ height: number,
1389
+ ) {
1390
+ const pins = (child.placementPins ??= {})
1391
+ if (!pins.leading) {
1392
+ pins.leading = child.view!.leadingAnchor.constraintEqualToAnchorConstant(
1393
+ parentView.leadingAnchor,
1394
+ 0,
1395
+ )
1396
+
1397
+ pins.top = child.view!.topAnchor.constraintEqualToAnchorConstant(parentView.topAnchor, 0)
1398
+ pins.width = child.view!.widthAnchor.constraintEqualToConstant(0)
1399
+ pins.height = child.view!.heightAnchor.constraintEqualToConstant(0)
1400
+ for (const pin of [pins.leading, pins.top, pins.width, pins.height]) {
1401
+ pin.active = true
1402
+ }
1403
+ }
1404
+
1405
+ pins.leading.constant = x
1406
+ pins.top.constant = top
1407
+ pins.width.constant = width
1408
+ pins.height.constant = height
1409
+ }
1410
+
1411
+ /** Drop the pins a grid/absolute parent installed — a constraint that keeps
1412
+ * referencing the old parent view is invalid once the child reparents. */
1413
+ function releasePlacementPins(node: ElementNode) {
1414
+ const pins = node.placementPins
1415
+ if (!pins) {
1416
+ return
1417
+ }
1418
+
1419
+ for (const pin of Object.values(pins) as { active: boolean }[]) {
1420
+ pin.active = false
1421
+ }
1422
+
1423
+ node.placementPins = null
1424
+ }
1425
+
1220
1426
  function layoutGridChildren(parent: ElementNode | undefined) {
1221
1427
  if (!parent?.view) {
1222
1428
  return
@@ -1302,10 +1508,7 @@ function layoutGridChildren(parent: ElementNode | undefined) {
1302
1508
  }
1303
1509
 
1304
1510
  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
- }
1511
+ pinLayoutChild(child, parent.view, x, top, childWidth, childHeight)
1309
1512
  }
1310
1513
 
1311
1514
  return
@@ -1346,10 +1549,7 @@ function layoutGridChildren(parent: ElementNode | undefined) {
1346
1549
  y = height - childHeight - Number(style.marginTop ?? 0)
1347
1550
  }
1348
1551
 
1349
- child.view!.frame = {
1350
- origin: { x, y },
1351
- size: { width: childWidth, height: childHeight },
1352
- }
1552
+ pinLayoutChild(child, parent.view, x, height - y - childHeight, childWidth, childHeight)
1353
1553
  }
1354
1554
  }
1355
1555
 
@@ -1394,10 +1594,7 @@ function layoutAbsoluteChildren(parent: ElementNode | undefined) {
1394
1594
  ? height - bottom - childHeight
1395
1595
  : 0
1396
1596
 
1397
- child.view.frame = {
1398
- origin: { x, y: height - offsetTop - childHeight },
1399
- size: { width: childWidth, height: childHeight },
1400
- }
1597
+ pinLayoutChild(child, parent.view, x, offsetTop, childWidth, childHeight)
1401
1598
  }
1402
1599
  }
1403
1600
 
@@ -1433,140 +1630,236 @@ function makeImageView(props: PropBag) {
1433
1630
  })
1434
1631
 
1435
1632
  image.translatesAutoresizingMaskIntoConstraints = false
1436
- image.image = loadImage(props.src)
1633
+ updateImage(image, props.src)
1437
1634
 
1438
1635
  return image
1439
1636
  }
1440
1637
 
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
1638
+ /** Types whose views arrange child views; mirrors the check in insert(). */
1639
+ const VIEW_PARENT_TYPES = new Set([
1640
+ 'stack',
1641
+ 'flexboxlayout',
1642
+ 'scrollview',
1643
+ 'gridlayout',
1644
+ 'absolutelayout',
1645
+ ])
1646
+
1647
+ /** Every type the factory switch below can build. */
1648
+ const SUPPORTED_TYPES = new Set([
1649
+ ...VIEW_PARENT_TYPES,
1650
+ 'label',
1651
+ '#text',
1652
+ 'button',
1653
+ 'textfield',
1654
+ 'textview',
1655
+ 'switch',
1656
+ 'slider',
1657
+ 'webview',
1658
+ 'image',
1659
+ 'span',
1660
+ ])
1661
+
1662
+ function typeHasView(type: string) {
1663
+ return type !== '#text' && type !== 'span'
1664
+ }
1665
+
1666
+ function assertSupportedType(type: string) {
1667
+ if (!SUPPORTED_TYPES.has(type)) {
1668
+ throw new Error('AppKit spike does not support <' + type + '>')
1669
+ }
1670
+ }
1671
+
1672
+ /** Deterministic prop failures that would otherwise throw after views and
1673
+ * registrations exist. `baseProps` supplies already-applied props for
1674
+ * update commands so merged-prop rules (e.g. placeholder color) stay exact. */
1675
+ function validateNodeProps(type: string, props: PropBag | undefined, baseProps?: PropBag) {
1676
+ if (type === '#text' || type === 'span') {
1677
+ return
1678
+ }
1679
+
1680
+ const style = props?.style
1681
+ if (style != null) {
1682
+ if (typeof style !== 'object') {
1683
+ throw new Error('AppKit spike expects style to be an object')
1472
1684
  }
1473
- case 'scrollview': {
1474
- const scroll = makeScrollView()
1475
- view = scroll.view
1476
- childHost = scroll.childHost
1477
- break
1685
+
1686
+ // applyStyle feeds these straight into nativeColor.
1687
+ if (style.backgroundColor != null) {
1688
+ nativeColor(style.backgroundColor)
1478
1689
  }
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
1690
+
1691
+ if (style.borderColor != null) {
1692
+ nativeColor(style.borderColor)
1490
1693
  }
1491
- case 'slider': {
1492
- const control = makeSlider(props)
1493
- view = control.view
1494
- actionId = control.actionId
1495
- break
1694
+
1695
+ if (['label', 'textfield', 'textview'].includes(type) && style.color != null) {
1696
+ nativeColor(style.color)
1496
1697
  }
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
1698
  }
1509
1699
 
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
1700
+ if (type === 'gridlayout') {
1701
+ // layoutGridChildren parses the track lists on every apply.
1702
+ parseGridTracks(props?.rows)
1703
+ parseGridTracks(props?.columns)
1704
+ }
1705
+
1706
+ if (type === 'textfield' && props?.placeholderTextColor != null) {
1707
+ // setTextFieldPlaceholder only reaches the color when text exists.
1708
+ const placeholder = props.placeholder ?? baseProps?.placeholder
1709
+ if (String(placeholder ?? '') !== '') {
1710
+ nativeColor(props.placeholderTextColor)
1520
1711
  }
1521
1712
  }
1713
+ }
1714
+
1715
+ function makeNode(container: RootContainer, id: number, type: string, props: PropBag) {
1716
+ // Reject malformed props before factories allocate views or register
1717
+ // actions — the node only reaches container.nodes after this returns, so
1718
+ // a throwing prop below must unwind whatever was already acquired.
1719
+ assertSupportedType(type)
1720
+ validateNodeProps(type, props)
1522
1721
 
1523
1722
  const node: ElementNode = {
1524
1723
  id,
1525
1724
  type,
1526
- view,
1527
- childHost,
1725
+ view: null,
1528
1726
  props: {},
1529
1727
  parent: null,
1530
1728
  children: [],
1531
1729
  container,
1532
1730
  appliedFontFamily: container.fontFamily,
1533
- actionId,
1534
1731
  scrollObserverInstalled: false,
1535
1732
  text: '',
1733
+ // The native class is fixed at creation; post-mount `secure` updates
1734
+ // are rejected against this in applyProps.
1735
+ secure: type === 'textfield' && !!props.secure,
1536
1736
  }
1537
1737
 
1538
- if (['label', 'textfield', 'textview'].includes(type) && view.font) {
1539
- view.font = fontForFamilyStyle(view.font.pointSize, 400, container.fontFamily)
1540
- }
1738
+ try {
1739
+ switch (type) {
1740
+ case 'stack':
1741
+ node.view = makeStack(props)
1742
+ break
1743
+ case 'flexboxlayout': {
1744
+ const flexbox = makeFlexbox(props)
1745
+ node.view = flexbox.view
1746
+ node.actionId = flexbox.actionId
1747
+ break
1748
+ }
1749
+ case 'gridlayout':
1750
+ node.view = makeGridLayout()
1751
+ break
1752
+ case 'absolutelayout':
1753
+ node.view = makeAbsoluteLayout()
1754
+ break
1755
+ case 'label':
1756
+ node.view = makeLabel()
1757
+ break
1758
+ case '#text':
1759
+ break
1760
+ case 'button': {
1761
+ const button = makeButton(props)
1762
+ node.view = button.view
1763
+ node.actionId = button.actionId
1764
+ break
1765
+ }
1766
+ case 'scrollview': {
1767
+ const scroll = makeScrollView()
1768
+ node.view = scroll.view
1769
+ node.childHost = scroll.childHost
1770
+ break
1771
+ }
1772
+ case 'textfield':
1773
+ node.view = makeTextField(props)
1774
+ break
1775
+ case 'textview':
1776
+ node.view = makeTextField(props, true)
1777
+ break
1778
+ case 'switch': {
1779
+ const control = makeSwitch(props)
1780
+ node.view = control.view
1781
+ node.actionId = control.actionId
1782
+ break
1783
+ }
1784
+ case 'slider': {
1785
+ const control = makeSlider(props)
1786
+ node.view = control.view
1787
+ node.actionId = control.actionId
1788
+ break
1789
+ }
1790
+ case 'webview':
1791
+ node.view = makeWebView()
1792
+ break
1793
+ case 'image':
1794
+ node.view = makeImageView(props)
1795
+ break
1796
+ case 'span':
1797
+ break
1798
+ default:
1799
+ throw new Error('AppKit spike does not support <' + type + '>')
1800
+ }
1801
+
1802
+ if (type === 'textfield' || type === 'textview') {
1803
+ node.actionId = nextActionId++
1804
+ actionIdsByView.set(node.view!, node.actionId)
1805
+ actionHandlers.set(node.actionId, null)
1806
+ if (type === 'textfield') {
1807
+ // Per-edit events arrive through controlTextDidChange (delegate);
1808
+ // the target/action is reserved for submission on Return.
1809
+ node.view!.delegate = buttonActionTarget
1810
+ node.view!.target = buttonActionTarget
1811
+ node.view!.action = 'textFieldSubmitted:'
1812
+ } else {
1813
+ node.view!.delegate = buttonActionTarget
1814
+ }
1815
+ }
1541
1816
 
1542
- if (type === 'gridlayout') {
1543
- gridLayoutNodesByView.set(view, node)
1544
- }
1817
+ if (['label', 'textfield', 'textview'].includes(type) && node.view!.font) {
1818
+ node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 400, container.fontFamily)
1819
+ }
1545
1820
 
1546
- if (type === 'absolutelayout') {
1547
- absoluteLayoutNodesByView.set(view, node)
1548
- }
1821
+ if (type === 'gridlayout') {
1822
+ gridLayoutNodesByView.set(node.view!, node)
1823
+ }
1549
1824
 
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
1825
+ if (type === 'absolutelayout') {
1826
+ absoluteLayoutNodesByView.set(node.view!, node)
1827
+ }
1559
1828
 
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
- }
1829
+ if (type === 'textfield') {
1830
+ textNodesByView.set(node.view!, node)
1831
+ }
1832
+
1833
+ if (type === 'textview') {
1834
+ const placeholder = makeLabel()
1835
+ placeholder.font = fontForFamilyStyle(14, 400, node.appliedFontFamily)
1836
+ placeholder.textColor = nativeColor('#666666')
1837
+ placeholder.stringValue = String(props.placeholder ?? '')
1838
+ placeholder.translatesAutoresizingMaskIntoConstraints = false
1839
+ placeholder.heightAnchor.constraintEqualToConstant(
1840
+ Math.ceil(14 * DEFAULT_TEXT_LINE_HEIGHT_RATIO),
1841
+ ).active = true
1842
+
1843
+ placeholder.hidden = String(props.value ?? '').length > 0
1844
+ node.view!.addSubview(placeholder)
1845
+ placeholder.leadingAnchor.constraintEqualToAnchorConstant(
1846
+ node.view!.leadingAnchor,
1847
+ 0,
1848
+ ).active = true
1849
+
1850
+ placeholder.topAnchor.constraintEqualToAnchor(node.view!.topAnchor).active = true
1851
+ node.placeholderView = placeholder
1852
+ textNodesByView.set(node.view!, node)
1853
+ }
1567
1854
 
1568
- applyProps(node, props)
1569
- return node
1855
+ applyProps(node, props)
1856
+ return node
1857
+ } catch (error) {
1858
+ // Release whatever the failed creation registered — action slots,
1859
+ // observers, WebView delegates — since no caller can destroy it.
1860
+ destroy(node)
1861
+ throw error
1862
+ }
1570
1863
  }
1571
1864
 
1572
1865
  const EDGE_INSET_PROPS = new Map([
@@ -1694,16 +1987,20 @@ function applyThemeColors(node: ElementNode) {
1694
1987
  }
1695
1988
 
1696
1989
  if (node.type === 'textfield') {
1697
- setTextFieldPlaceholder(view, node.props, node.scheme)
1990
+ setTextFieldPlaceholder(view as NSTextField, node.props, node.scheme)
1698
1991
  }
1699
1992
 
1700
1993
  if (node.type === 'textview' && node.placeholderView) {
1701
1994
  node.placeholderView.textColor = nativeColor(colors.placeholder)
1702
1995
  }
1703
1996
 
1704
- if (node.bgSlot) {
1997
+ if (node.bgSlot || node.styleBg != null) {
1998
+ node.bgApplied = true
1705
1999
  view.wantsLayer = true
1706
2000
  view.layer.backgroundColor = nativeColor(node.styleBg ?? colors[node.bgSlot]).CGColor
2001
+ } else if (node.bgApplied) {
2002
+ node.bgApplied = false
2003
+ view.layer.backgroundColor = null
1707
2004
  }
1708
2005
  }
1709
2006
 
@@ -1729,28 +2026,25 @@ function setSizeConstraint(node: ElementNode, name: string, value: any) {
1729
2026
  const specs = (node.sizeConstraintSpecs ??= {})
1730
2027
  if (!spec) {
1731
2028
  delete specs[name]
1732
- console.warn(
1733
- '[macos-style] ignored unsupported style.' + name + ' value ' + JSON.stringify(value),
1734
- )
2029
+ // null/undefined is an intentional clear — only malformed values warn.
2030
+ if (value != null) {
2031
+ console.warn(
2032
+ '[macos-style] ignored unsupported style.' + name + ' value ' + JSON.stringify(value),
2033
+ )
2034
+ }
1735
2035
  } else {
1736
2036
  specs[name] = spec
1737
2037
  }
1738
2038
 
1739
- if (node.parent?.type === 'gridlayout') {
1740
- deactivateSizeConstraints(node)
1741
- layoutGridChildren(node.parent)
1742
- return
1743
- }
1744
-
1745
- if (node.parent?.type === 'absolutelayout') {
2039
+ if (node.parent?.type === 'gridlayout' || node.parent?.type === 'absolutelayout') {
1746
2040
  deactivateSizeConstraints(node)
1747
- layoutAbsoluteChildren(node.parent)
2041
+ queueLayoutReconcile(node.container, node.parent)
1748
2042
  return
1749
2043
  }
1750
2044
 
1751
2045
  applySizeConstraint(node, name)
1752
2046
  if (node.parent) {
1753
- updateCrossAxisConstraints(node.parent)
2047
+ queueLayoutReconcile(node.container, node.parent)
1754
2048
  }
1755
2049
  }
1756
2050
 
@@ -1831,42 +2125,224 @@ function deactivateSizeConstraints(node: ElementNode) {
1831
2125
  }
1832
2126
  }
1833
2127
 
1834
- function applyStyle(node: ElementNode, style: PropBag) {
1835
- if (style == null) {
2128
+ // Style, class, and (for labels) prop sources all write one native font.
2129
+ // Each source is tracked on the node so removing one re-resolves the rest —
2130
+ // explicit style wins over classes, which win over the element defaults.
2131
+ function syncNodeFont(node: ElementNode) {
2132
+ const size =
2133
+ node.styleFontSize ??
2134
+ node.propFontSize ??
2135
+ node.classFontSize ??
2136
+ (node.type === 'label' ? 16 : 14)
2137
+
2138
+ const weight = node.styleFontWeight ?? node.classFontWeight ?? 400
2139
+ node.appliedFontWeight = String(weight)
2140
+ node.appliedFontFamily = node.styleFontFamily ?? node.container.fontFamily
2141
+ node.view!.font = fontForFamilyStyle(size, weight, node.appliedFontFamily)
2142
+ }
2143
+
2144
+ // NSStackView edgeInsets blend padding styles over class padding over zero.
2145
+ // Edges a style never set fall through to the class contribution.
2146
+ function syncEdgeInsets(node: ElementNode) {
2147
+ const view = node.view
2148
+ if (!view) {
2149
+ return
2150
+ }
2151
+
2152
+ const edges = node.stylePaddingEdges
2153
+ const box = node.stylePaddingBox
2154
+ const klass = node.classInsets
2155
+ if (!edges && !box && !klass && !node.insetsApplied) {
2156
+ return
2157
+ }
2158
+
2159
+ node.insetsApplied = true
2160
+ view.edgeInsets = {
2161
+ top: edges?.top ?? box?.top ?? klass?.top ?? 0,
2162
+ right: edges?.right ?? box?.right ?? klass?.right ?? 0,
2163
+ bottom: edges?.bottom ?? box?.bottom ?? klass?.bottom ?? 0,
2164
+ left: edges?.left ?? box?.left ?? klass?.left ?? 0,
2165
+ }
2166
+ }
2167
+
2168
+ // borderRadius (style) and rounded-* (class) share cornerRadius; either source
2169
+ // keeps masksToBounds on only while a positive radius is in effect.
2170
+ function syncCornerRadius(node: ElementNode) {
2171
+ const view = node.view
2172
+ if (!view) {
2173
+ return
2174
+ }
2175
+
2176
+ const radius = Number(node.styleRadius ?? node.classRadius ?? 0) || 0
2177
+ if (radius <= 0 && !node.radiusApplied) {
2178
+ return
2179
+ }
2180
+
2181
+ node.radiusApplied = radius > 0
2182
+ view.wantsLayer = true
2183
+ view.layer.cornerRadius = Math.max(0, radius)
2184
+ view.layer.masksToBounds = radius > 0
2185
+ }
2186
+
2187
+ // Zero the margin sides a removed style owned, then let the stack unwrap or
2188
+ // the grid relayout — marginInsetsOf() returns null once every side is zero.
2189
+ function clearMarginStyle(node: ElementNode, name: string) {
2190
+ const insets = node.marginInsets
2191
+ if (!insets) {
2192
+ return
2193
+ }
2194
+
2195
+ if (name === 'margin') {
2196
+ insets.top = insets.right = insets.bottom = insets.left = 0
2197
+ } else {
2198
+ insets[MARGIN_SIDES[name]] = 0
2199
+ }
2200
+
2201
+ if (node.parent?.type === 'gridlayout') {
2202
+ layoutGridChildren(node.parent)
1836
2203
  return
1837
2204
  }
1838
2205
 
1839
- if (typeof style !== 'object') {
2206
+ syncMarginHost(node)
2207
+ }
2208
+
2209
+ // A style key present in the last bag but absent or nulled in the new one must
2210
+ // put back the native state it installed: constraints deactivate, hit-testing
2211
+ // reverts, and shared properties resolve their class/default source again.
2212
+ function resetStyle(node: ElementNode, name: string) {
2213
+ const view = node.view
2214
+ if (!view) {
2215
+ return
2216
+ }
2217
+
2218
+ const textControl = ['label', 'textfield', 'textview'].includes(node.type)
2219
+ if (name === 'pointerEvents') {
2220
+ inputTransparentViews.delete(view)
2221
+ } else if (name === 'objectFit' && node.type === 'image') {
2222
+ // NSImageScaleProportionallyDown — the makeImageView/AppKit default.
2223
+ view.imageScaling = 0
2224
+ } else if (name === 'fontSize' && textControl) {
2225
+ node.styleFontSize = undefined
2226
+ syncNodeFont(node)
2227
+ } else if (name === 'fontFamily' && textControl) {
2228
+ node.styleFontFamily = undefined
2229
+ syncNodeFont(node)
2230
+ } else if (name === 'fontWeight' && textControl) {
2231
+ node.styleFontWeight = undefined
2232
+ syncNodeFont(node)
2233
+ } else if (name === 'color' && textControl) {
2234
+ node.styleColor = undefined
2235
+ applyThemeColors(node)
2236
+ } else if (name === 'padding' && node.type === 'flexboxlayout') {
2237
+ node.stylePaddingBox = undefined
2238
+ syncEdgeInsets(node)
2239
+ } else if (EDGE_INSET_PROPS.has(name) && node.type === 'flexboxlayout') {
2240
+ for (const edge of EDGE_INSET_PROPS.get(name)!) {
2241
+ delete node.stylePaddingEdges?.[edge]
2242
+ }
2243
+
2244
+ syncEdgeInsets(node)
2245
+ } else if (name === 'backgroundColor') {
2246
+ node.styleBg = undefined
2247
+ applyThemeColors(node)
2248
+ } else if (name === 'borderRadius') {
2249
+ node.styleRadius = undefined
2250
+ syncCornerRadius(node)
2251
+ } else if (name === 'width' || name === 'height') {
2252
+ setSizeConstraint(node, name, undefined)
2253
+ } else if (name === 'left' || name === 'top' || name === 'right' || name === 'bottom') {
2254
+ if (node.parent?.type === 'gridlayout') {
2255
+ layoutGridChildren(node.parent)
2256
+ }
2257
+
2258
+ if (node.parent?.type === 'absolutelayout') {
2259
+ layoutAbsoluteChildren(node.parent)
2260
+ }
2261
+ } else if (name === 'translateX' || name === 'translateY') {
2262
+ if (typeof view.layer?.setValueForKeyPath === 'function') {
2263
+ view.layer.setValueForKeyPath(
2264
+ 0,
2265
+ name === 'translateY' ? 'transform.translation.y' : 'transform.translation.x',
2266
+ )
2267
+ }
2268
+ } else if (name === 'zIndex') {
2269
+ if (view.layer) {
2270
+ view.layer.zPosition = 0
2271
+ }
2272
+ } else if (name === 'opacity') {
2273
+ view.alphaValue = 1
2274
+ } else if (name === 'textAlign' && node.type === 'label') {
2275
+ view.alignment = nodeClasses(node).includes('vx-sheet-grabber')
2276
+ ? NSTextAlignment.Center
2277
+ : NSTextAlignment.Left
2278
+ } else if (name === 'borderWidth') {
2279
+ if (view.layer) {
2280
+ view.layer.borderWidth = 0
2281
+ }
2282
+ } else if (name === 'borderColor') {
2283
+ if (view.layer) {
2284
+ view.layer.borderColor = null
2285
+ }
2286
+ } else if (MARGIN_SIDES[name] || name === 'margin') {
2287
+ clearMarginStyle(node, name)
2288
+ }
2289
+ // lineHeight folds into setLabelText from props.style — no native reset.
2290
+ }
2291
+
2292
+ function applyStyle(node: ElementNode, style: PropBag) {
2293
+ if (style != null && typeof style !== 'object') {
1840
2294
  throw new Error('AppKit spike expects style to be an object')
1841
2295
  }
1842
2296
 
1843
- for (const [name, value] of Object.entries(style)) {
2297
+ // The style prop replaces wholesale, so keys dropped or nulled since the
2298
+ // last apply must reset the native state they installed before the current
2299
+ // values go on — otherwise removed styles linger on the view.
2300
+ const next: PropBag = style ?? {}
2301
+ if (node.type === 'flexboxlayout' || node.type === 'stack') {
2302
+ node.styleLayout = Object.fromEntries(
2303
+ Object.entries(next).filter(([name]) => isStackLayoutInput(name)),
2304
+ )
2305
+ }
2306
+
2307
+ const prev: PropBag = node.appliedStyle ?? {}
2308
+ for (const name of Object.keys(prev)) {
2309
+ if (prev[name] != null && next[name] == null) {
2310
+ resetStyle(node, name)
2311
+ }
2312
+ }
2313
+
2314
+ for (const [name, value] of Object.entries(next)) {
1844
2315
  if (value == null) {
1845
2316
  continue
1846
2317
  }
1847
2318
 
1848
- if (name === 'pointerEvents') {
2319
+ if ((node.type === 'flexboxlayout' || node.type === 'stack') && isStackLayoutInput(name)) {
2320
+ // Applied together below, after all style inputs have reconciled.
2321
+ continue
2322
+ } else if (name === 'pointerEvents') {
1849
2323
  if (value === 'none') {
1850
2324
  inputTransparentViews.add(node.view!)
1851
2325
  } else {
1852
2326
  inputTransparentViews.delete(node.view!)
1853
2327
  }
1854
2328
  } else if (name === 'objectFit' && node.type === 'image') {
1855
- // NSImageScaleProportionallyUpOrDown / NSImageScaleProportionallyDown.
1856
- node.view!.imageScaling = value === 'contain' ? 3 : 0
2329
+ // NSImageScaling: 0 ProportionallyDown, 1 AxesIndependently,
2330
+ // 2 None (centered, unscaled), 3 ProportionallyUpOrDown.
2331
+ // AppKit has no cover mode — 'cover' degrades to scale-down.
2332
+ const imageScaling: Record<string, number> = {
2333
+ contain: 3,
2334
+ 'scale-down': 0,
2335
+ fill: 1,
2336
+ none: 2,
2337
+ }
2338
+
2339
+ node.view!.imageScaling = imageScaling[String(value)] ?? 0
1857
2340
  } else if (name === 'fontSize' && ['label', 'textfield', 'textview'].includes(node.type)) {
1858
- const weight = style.fontWeight ?? node.appliedFontWeight ?? 400
1859
- node.appliedFontWeight = String(weight)
1860
- node.view!.font = fontForFamilyStyle(
1861
- value,
1862
- weight,
1863
- style.fontFamily ?? node.appliedFontFamily,
1864
- )
2341
+ node.styleFontSize = value
2342
+ syncNodeFont(node)
1865
2343
  } else if (name === 'fontFamily' && ['label', 'textfield', 'textview'].includes(node.type)) {
1866
- node.appliedFontFamily = String(value)
1867
- const size = style.fontSize ?? node.view!.font.pointSize
1868
- const weight = style.fontWeight ?? node.appliedFontWeight ?? 400
1869
- node.view!.font = fontForFamilyStyle(size, weight, value)
2344
+ node.styleFontFamily = String(value)
2345
+ syncNodeFont(node)
1870
2346
  } else if (name === 'color' && ['label', 'textfield', 'textview'].includes(node.type)) {
1871
2347
  node.styleColor = String(value)
1872
2348
  node.view!.textColor = nativeColor(value)
@@ -1874,31 +2350,28 @@ function applyStyle(node: ElementNode, style: PropBag) {
1874
2350
  // syncText applies this to each paragraph; it is not the label's fixed height.
1875
2351
  continue
1876
2352
  } else if (name === 'padding' && node.type === 'flexboxlayout') {
1877
- node.view!.edgeInsets = parseEdgeInsets(value)
2353
+ node.stylePaddingBox = parseEdgeInsets(value)
2354
+ syncEdgeInsets(node)
1878
2355
  } else if (EDGE_INSET_PROPS.has(name) && node.type === 'flexboxlayout') {
1879
- const insets = { ...node.view!.edgeInsets }
2356
+ const edges = (node.stylePaddingEdges ??= {})
1880
2357
  for (const edge of EDGE_INSET_PROPS.get(name)!) {
1881
- insets[edge] = Number(value) || 0
2358
+ edges[edge] = Number(value) || 0
1882
2359
  }
1883
2360
 
1884
- node.view!.edgeInsets = insets
2361
+ syncEdgeInsets(node)
1885
2362
  } else if (name === 'backgroundColor' && node.view) {
1886
2363
  node.styleBg = String(value)
2364
+ node.bgApplied = true
1887
2365
  node.view.wantsLayer = true
1888
2366
  node.view.layer.backgroundColor = nativeColor(value).CGColor
1889
2367
  } else if (name === 'borderRadius' && node.view) {
1890
- node.view.wantsLayer = true
1891
- node.view.layer.cornerRadius = Number(value)
1892
- node.view.layer.masksToBounds = true
2368
+ node.styleRadius = value
2369
+ syncCornerRadius(node)
1893
2370
  } else if ((name === 'width' || name === 'height') && node.view) {
1894
2371
  setSizeConstraint(node, name, value)
1895
2372
  } else if (name === 'left' && node.view) {
1896
- if (node.parent?.type === 'gridlayout') {
1897
- layoutGridChildren(node.parent)
1898
- }
1899
-
1900
- if (node.parent?.type === 'absolutelayout') {
1901
- layoutAbsoluteChildren(node.parent)
2373
+ if (node.parent?.type === 'gridlayout' || node.parent?.type === 'absolutelayout') {
2374
+ queueLayoutReconcile(node.container, node.parent)
1902
2375
  }
1903
2376
  } else if ((name === 'translateX' || name === 'translateY' || name === 'zIndex') && node.view) {
1904
2377
  node.view.wantsLayer = true
@@ -1915,12 +2388,8 @@ function applyStyle(node: ElementNode, style: PropBag) {
1915
2388
  } else if (name === 'opacity' && node.view) {
1916
2389
  node.view.alphaValue = Number(value)
1917
2390
  } else if (name === 'fontWeight' && ['label', 'textfield', 'textview'].includes(node.type)) {
1918
- node.appliedFontWeight = String(value)
1919
- node.view!.font = fontForFamilyStyle(
1920
- node.view!.font.pointSize,
1921
- value,
1922
- style.fontFamily ?? node.appliedFontFamily,
1923
- )
2391
+ node.styleFontWeight = String(value)
2392
+ syncNodeFont(node)
1924
2393
  } else if (name === 'textAlign' && node.type === 'label') {
1925
2394
  node.view!.alignment =
1926
2395
  value === 'left'
@@ -1940,6 +2409,9 @@ function applyStyle(node: ElementNode, style: PropBag) {
1940
2409
  console.warn('[macos-style] ignored unsupported style.' + name + ' on <' + node.type + '>')
1941
2410
  }
1942
2411
  }
2412
+
2413
+ node.appliedStyle = next
2414
+ syncStackLayout(node)
1943
2415
  }
1944
2416
 
1945
2417
  function applyClassName(node: ElementNode, value: any) {
@@ -1947,11 +2419,23 @@ function applyClassName(node: ElementNode, value: any) {
1947
2419
  .split(/\s+/)
1948
2420
  .filter(Boolean)
1949
2421
 
2422
+ // Class effects re-derive from scratch on every apply: the class-owned
2423
+ // fields reset here and re-populate below, so removing a class drops the
2424
+ // native state it installed instead of leaving stale values behind.
2425
+ node.classFontSize = undefined
2426
+ node.classFontWeight = undefined
2427
+ node.classInsets = undefined
2428
+ node.classRadius = undefined
2429
+ node.classSpacing = undefined
2430
+ node.classOrientation = undefined
2431
+ node.colorSlot = undefined
2432
+ node.bgSlot = undefined
2433
+
1950
2434
  if (node.type === 'label') {
1951
- if (classes.includes('vx-sheet-grabber')) {
2435
+ const grabber = classes.includes('vx-sheet-grabber')
2436
+ if (grabber) {
1952
2437
  node.classLineHeight = 24
1953
2438
  setSizeConstraint(node, 'height', 24)
1954
- node.view!.alignment = NSTextAlignment.Center
1955
2439
  }
1956
2440
 
1957
2441
  const sizes: Record<string, number> = {
@@ -1984,18 +2468,14 @@ function applyClassName(node: ElementNode, value: any) {
1984
2468
  for (const name of classes) {
1985
2469
  const heading = headingMetrics[name]
1986
2470
  if (heading) {
1987
- node.appliedFontWeight = '700'
2471
+ node.classFontWeight = '700'
2472
+ node.classFontSize = heading.size
1988
2473
  node.headingDefaultHeight = heading.height
1989
- node.view!.font = fontForFamilyStyle(heading.size, 700, node.appliedFontFamily)
1990
2474
  }
1991
2475
 
1992
2476
  if (sizes[name]) {
2477
+ node.classFontSize = sizes[name]
1993
2478
  node.headingDefaultHeight = undefined
1994
- node.view!.font = fontForFamilyStyle(
1995
- sizes[name],
1996
- node.appliedFontWeight ?? 400,
1997
- node.appliedFontFamily,
1998
- )
1999
2479
  }
2000
2480
 
2001
2481
  if (lineHeights[name] != null) {
@@ -2003,13 +2483,11 @@ function applyClassName(node: ElementNode, value: any) {
2003
2483
  }
2004
2484
 
2005
2485
  if (name === 'font-semibold') {
2006
- node.appliedFontWeight = '600'
2007
- node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 600, node.appliedFontFamily)
2486
+ node.classFontWeight = '600'
2008
2487
  }
2009
2488
 
2010
2489
  if (name === 'font-bold') {
2011
- node.appliedFontWeight = '700'
2012
- node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 700, node.appliedFontFamily)
2490
+ node.classFontWeight = '700'
2013
2491
  }
2014
2492
 
2015
2493
  if (name === 'text-muted') {
@@ -2020,6 +2498,28 @@ function applyClassName(node: ElementNode, value: any) {
2020
2498
  node.colorSlot = 'onprimary'
2021
2499
  }
2022
2500
  }
2501
+
2502
+ syncNodeFont(node)
2503
+
2504
+ if (!grabber && node.props?.style?.height != null) {
2505
+ // The grabber's 24pt pin shares the height spec with style.height —
2506
+ // re-assert the style value once the class is gone.
2507
+ setSizeConstraint(node, 'height', node.props.style.height)
2508
+ }
2509
+
2510
+ if (grabber) {
2511
+ node.view!.alignment = NSTextAlignment.Center
2512
+ } else {
2513
+ // Without the grabber the label returns to its textAlign — or the
2514
+ // Left default when no alignment style is in effect.
2515
+ const textAlign = node.props?.style?.textAlign
2516
+ node.view!.alignment =
2517
+ textAlign == null || textAlign === 'left'
2518
+ ? NSTextAlignment.Left
2519
+ : textAlign === 'right'
2520
+ ? NSTextAlignment.Right
2521
+ : NSTextAlignment.Center
2522
+ }
2023
2523
  }
2024
2524
 
2025
2525
  if (
@@ -2038,37 +2538,27 @@ function applyClassName(node: ElementNode, value: any) {
2038
2538
 
2039
2539
  for (const name of classes) {
2040
2540
  if (gaps[name] !== undefined) {
2041
- node.view!.spacing = gaps[name]
2541
+ node.classSpacing = gaps[name]
2042
2542
  }
2043
2543
 
2044
2544
  if (name === 'flex-row') {
2045
- node.view!.orientation = NSUserInterfaceLayoutOrientation.Horizontal
2545
+ node.classOrientation = NSUserInterfaceLayoutOrientation.Horizontal
2046
2546
  }
2047
2547
 
2048
2548
  if (name === 'flex-col') {
2049
- node.view!.orientation = NSUserInterfaceLayoutOrientation.Vertical
2050
- }
2051
-
2052
- if (name === 'items-center' || name === 'items-start' || name === 'items-end') {
2053
- node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2549
+ node.classOrientation = NSUserInterfaceLayoutOrientation.Vertical
2054
2550
  }
2055
2551
 
2056
2552
  if (name === 'vx-toast' && node.type === 'flexboxlayout') {
2057
- ;(node.view as NSStackView).edgeInsets = { top: 12, left: 12, bottom: 12, right: 12 }
2553
+ node.classInsets = { top: 12, right: 12, bottom: 12, left: 12 }
2058
2554
  }
2059
2555
 
2060
2556
  if (name === 'vx-toast-viewport') {
2061
- ;(node.view as NSStackView).spacing = 8
2557
+ node.classSpacing = 8
2062
2558
  }
2063
2559
 
2064
2560
  if (name === 'vx-button') {
2065
- node.view!.orientation = NSUserInterfaceLayoutOrientation.Horizontal
2066
- node.view!.alignment = NSLayoutAttribute.CenterY
2067
- node.view!.distribution = NSStackViewDistribution.GravityAreas
2068
- }
2069
-
2070
- if (name === 'justify-between') {
2071
- node.view!.distribution = NSStackViewDistribution.EqualSpacing
2561
+ node.classOrientation = NSUserInterfaceLayoutOrientation.Horizontal
2072
2562
  }
2073
2563
 
2074
2564
  if (name === 'flex-1') {
@@ -2091,37 +2581,21 @@ function applyClassName(node: ElementNode, value: any) {
2091
2581
  }
2092
2582
 
2093
2583
  if (name === 'rounded-full' || name.startsWith('rounded-')) {
2094
- node.view!.wantsLayer = true
2095
- node.view!.layer.cornerRadius = name === 'rounded-full' ? 12 : 8
2096
- node.view!.layer.masksToBounds = true
2584
+ node.classRadius = name === 'rounded-full' ? 12 : 8
2097
2585
  }
2098
2586
 
2099
2587
  if (name === 'bg-primary' || name === 'btn') {
2100
2588
  node.bgSlot = 'primary'
2101
- node.view!.wantsLayer = true
2102
- node.view!.layer.backgroundColor = nativeColor(
2103
- SCHEME_COLORS[nodeScheme(node)].primary,
2104
- ).CGColor
2105
-
2106
- node.view!.edgeInsets = { top: 6, left: 10, bottom: 6, right: 10 }
2589
+ node.classInsets = { top: 6, right: 10, bottom: 6, left: 10 }
2107
2590
  }
2108
2591
 
2109
2592
  if (name === 'bg-danger') {
2110
2593
  node.bgSlot = 'danger'
2111
- node.view!.wantsLayer = true
2112
- node.view!.layer.backgroundColor = nativeColor(
2113
- SCHEME_COLORS[nodeScheme(node)].danger,
2114
- ).CGColor
2115
2594
  }
2116
2595
 
2117
2596
  if (name === 'btn-secondary' || name === 'chip' || name === 'chip-off') {
2118
2597
  node.bgSlot = 'secondary'
2119
- node.view!.wantsLayer = true
2120
- node.view!.layer.backgroundColor = nativeColor(
2121
- SCHEME_COLORS[nodeScheme(node)].secondary,
2122
- ).CGColor
2123
-
2124
- node.view!.edgeInsets = { top: 4, left: 8, bottom: 4, right: 8 }
2598
+ node.classInsets = { top: 4, right: 8, bottom: 4, left: 8 }
2125
2599
  }
2126
2600
 
2127
2601
  // Panels that paint the web body's --color-surface. On this host the
@@ -2134,22 +2608,18 @@ function applyClassName(node: ElementNode, value: any) {
2134
2608
  name === 'modal-panel'
2135
2609
  ) {
2136
2610
  node.bgSlot = 'surface'
2137
- node.view!.wantsLayer = true
2138
- node.view!.layer.backgroundColor = nativeColor(
2139
- SCHEME_COLORS[nodeScheme(node)].surface,
2140
- ).CGColor
2141
2611
  }
2142
2612
  }
2613
+
2614
+ syncStackLayout(node)
2615
+
2616
+ syncEdgeInsets(node)
2617
+ syncCornerRadius(node)
2143
2618
  }
2144
2619
 
2145
2620
  if (node.type === 'textfield' || node.type === 'textview') {
2146
2621
  if (classes.includes('vx-input') || classes.includes('vx-textarea')) {
2147
- node.view!.font = fontForFamilyStyle(
2148
- 14,
2149
- node.appliedFontWeight ?? 400,
2150
- node.appliedFontFamily,
2151
- )
2152
-
2622
+ node.classFontSize = 14
2153
2623
  node.colorSlot = 'text'
2154
2624
  node.view!.drawsBackground = false
2155
2625
  if (node.type === 'textfield') {
@@ -2160,6 +2630,8 @@ function applyClassName(node: ElementNode, value: any) {
2160
2630
  node.view!.textContainerInset = { width: 0, height: 0 }
2161
2631
  }
2162
2632
  }
2633
+
2634
+ syncNodeFont(node)
2163
2635
  }
2164
2636
 
2165
2637
  applyThemeColors(node)
@@ -2174,7 +2646,7 @@ function applyClassName(node: ElementNode, value: any) {
2174
2646
  }
2175
2647
 
2176
2648
  if (node.parent) {
2177
- setStackChildPriorities(node.parent, node)
2649
+ queueLayoutReconcile(node.container, node.parent)
2178
2650
  }
2179
2651
 
2180
2652
  updateStackDistribution(node)
@@ -2192,6 +2664,38 @@ function textContent(node: ElementNode): string {
2192
2664
  return node.children.map(textContent).join('')
2193
2665
  }
2194
2666
 
2667
+ // Keep the native value intact: AppKit truncates only when drawing in the frame.
2668
+ function syncLabelOverflow(node: ElementNode) {
2669
+ const { maxLines, whiteSpace, textOverflow } = node.props
2670
+ if (whiteSpace != null && !['normal', 'nowrap'].includes(whiteSpace)) {
2671
+ throw new Error('[macos-host] label whiteSpace supports only normal and nowrap')
2672
+ }
2673
+
2674
+ if (textOverflow != null && !['clip', 'ellipsis'].includes(textOverflow)) {
2675
+ throw new Error('[macos-host] label textOverflow supports only clip and ellipsis')
2676
+ }
2677
+
2678
+ if (maxLines != null && (!Number.isInteger(maxLines) || maxLines < 0)) {
2679
+ throw new Error('[macos-host] label maxLines must be a non-negative integer')
2680
+ }
2681
+
2682
+ const singleLine = whiteSpace === 'nowrap' || maxLines === 1
2683
+ const view = node.view as NSTextField
2684
+
2685
+ view.maximumNumberOfLines = singleLine ? 1 : (maxLines ?? 0)
2686
+ view.cell.wraps = !singleLine
2687
+ view.cell.scrollable = false
2688
+ view.cell.usesSingleLineMode = singleLine
2689
+ view.cell.lineBreakMode = singleLine
2690
+ ? textOverflow === 'ellipsis'
2691
+ ? NSLineBreakMode.TruncatingTail
2692
+ : NSLineBreakMode.Clipping
2693
+ : NSLineBreakMode.WordWrapping
2694
+
2695
+ view.cell.truncatesLastVisibleLine = !singleLine && textOverflow === 'ellipsis'
2696
+ view.invalidateIntrinsicContentSize()
2697
+ }
2698
+
2195
2699
  function setLabelText(node: ElementNode, text: any) {
2196
2700
  const lineHeight = Number(node.props?.style?.lineHeight)
2197
2701
  if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
@@ -2200,6 +2704,7 @@ function setLabelText(node: ElementNode, text: any) {
2200
2704
  }
2201
2705
 
2202
2706
  const paragraphStyle = NSMutableParagraphStyle.alloc().init()
2707
+ paragraphStyle.lineBreakMode = (node.view as NSTextField).cell.lineBreakMode
2203
2708
  paragraphStyle.minimumLineHeight = lineHeight
2204
2709
  paragraphStyle.maximumLineHeight = lineHeight
2205
2710
  node.view!.attributedStringValue = NSAttributedString.alloc().initWithStringAttributes(text, {
@@ -2233,7 +2738,7 @@ function setAction(node: ElementNode, value: any) {
2233
2738
  actionIdsByView.set(node.view!, node.actionId)
2234
2739
  actionHandlers.set(node.actionId, null)
2235
2740
  node.view!.addGestureRecognizer(
2236
- NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed'),
2741
+ NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed:'),
2237
2742
  )
2238
2743
  }
2239
2744
 
@@ -2255,7 +2760,7 @@ function setPanAction(node: ElementNode, value: any) {
2255
2760
  if (typeof value === 'function' && !node.panGestureRecognizer) {
2256
2761
  node.panGestureRecognizer = NSPanGestureRecognizer.alloc().initWithTargetAction(
2257
2762
  buttonActionTarget,
2258
- 'viewPanned',
2763
+ 'viewPanned:',
2259
2764
  )
2260
2765
 
2261
2766
  node.view!.addGestureRecognizer(node.panGestureRecognizer)
@@ -2294,6 +2799,35 @@ function setControlAction(node: ElementNode, value: any, readValue: () => any) {
2294
2799
  )
2295
2800
  }
2296
2801
 
2802
+ /** Return-key submission for single-line fields, invoked by the field's
2803
+ * target/action — kept distinct from per-edit `onTextChange`. */
2804
+ function setTextSubmitAction(node: ElementNode, value: any) {
2805
+ node.submitHandler =
2806
+ typeof value === 'function'
2807
+ ? () => {
2808
+ try {
2809
+ node.container.root!.eventScope('discrete', value)
2810
+ } catch (error) {
2811
+ console.error('[macos-event] text submit handler failed', error)
2812
+ }
2813
+ }
2814
+ : null
2815
+ }
2816
+
2817
+ /** `isEnabled`/`editable` → AppKit state. Disabled fields reject edits and
2818
+ * selection; read-only fields stay selectable so text can be copied. */
2819
+ function syncTextControlState(node: ElementNode) {
2820
+ const enabled = node.props.isEnabled !== false && node.props.enabled !== false
2821
+ const editable = enabled && node.props.editable !== false
2822
+ const view = node.view!
2823
+ if (node.type === 'textfield') {
2824
+ view.enabled = enabled
2825
+ }
2826
+
2827
+ view.editable = editable
2828
+ view.selectable = editable || enabled
2829
+ }
2830
+
2297
2831
  function applyAccessibility(node: ElementNode, name: string, value: any) {
2298
2832
  if (node.type === 'flexboxlayout' && node.actionId !== undefined) {
2299
2833
  const props = stackAccessibilityPropsByView.get(node.view!) ?? {}
@@ -2310,6 +2844,20 @@ function applyAccessibility(node: ElementNode, name: string, value: any) {
2310
2844
  if (node.type === 'label' && name === 'accessibilityLabel') {
2311
2845
  node.view!.setAccessibilityLabel?.(String(value ?? ''))
2312
2846
  }
2847
+
2848
+ if (node.type === 'textfield' || node.type === 'textview') {
2849
+ if (name === 'accessibilityLabel') {
2850
+ node.view!.setAccessibilityLabel?.(String(value ?? ''))
2851
+ } else if (name === 'accessibilityHint') {
2852
+ node.view!.setAccessibilityHint?.(String(value ?? ''))
2853
+ } else if (name === 'accessible') {
2854
+ node.view!.setAccessibilityElement?.(value !== false)
2855
+ } else if (name === 'accessibilityState' && node.type === 'textview') {
2856
+ // NSTextField reports AX enabled through its `enabled` flag;
2857
+ // NSTextView has no enabled state, so it is mirrored here.
2858
+ node.view!.setAccessibilityEnabled?.(value?.disabled !== true)
2859
+ }
2860
+ }
2313
2861
  }
2314
2862
 
2315
2863
  function applyProps(node: ElementNode, props: PropBag) {
@@ -2327,7 +2875,20 @@ function applyProps(node: ElementNode, props: PropBag) {
2327
2875
  return
2328
2876
  }
2329
2877
 
2878
+ const layoutChildProps: Record<string, readonly string[]> = {
2879
+ absolutelayout: ['left', 'top', 'right', 'bottom'],
2880
+ gridlayout: ['row', 'col', 'rowSpan', 'colSpan', 'horizontalAlignment', 'verticalAlignment'],
2881
+ }
2882
+
2330
2883
  for (const [name, value] of Object.entries(props)) {
2884
+ if (node.parent && layoutChildProps[node.parent.type]?.includes(name)) {
2885
+ continue
2886
+ }
2887
+
2888
+ if ((node.type === 'flexboxlayout' || node.type === 'stack') && isStackLayoutInput(name)) {
2889
+ continue
2890
+ }
2891
+
2331
2892
  if (name === 'onLayoutChanged') {
2332
2893
  setLayoutAction(node, value)
2333
2894
  continue
@@ -2336,7 +2897,7 @@ function applyProps(node: ElementNode, props: PropBag) {
2336
2897
  switch (node.type) {
2337
2898
  case 'stack':
2338
2899
  if (name === 'spacing') {
2339
- node.view!.spacing = Number(value ?? 0)
2900
+ syncStackLayout(node)
2340
2901
  } else if (name === 'style') {
2341
2902
  applyStyle(node, value)
2342
2903
  } else if (name === 'className') {
@@ -2349,33 +2910,12 @@ function applyProps(node: ElementNode, props: PropBag) {
2349
2910
 
2350
2911
  break
2351
2912
  case 'flexboxlayout':
2352
- if (name === 'gap') {
2353
- node.view!.spacing = Number(value ?? 0)
2354
- } else if (name === 'spacing') {
2355
- node.view!.spacing = Number(value ?? 0)
2356
- } else if (name === 'flexDirection') {
2357
- node.view!.orientation =
2358
- value === 'row'
2359
- ? NSUserInterfaceLayoutOrientation.Horizontal
2360
- : NSUserInterfaceLayoutOrientation.Vertical
2361
-
2362
- node.view!.distribution = NSStackViewDistribution.GravityAreas
2363
- node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2364
- moveStackChildren(node)
2365
- updateCrossAxisConstraints(node)
2366
- } else if (name === 'alignItems') {
2367
- node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2368
- updateCrossAxisConstraints(node)
2369
- } else if (name === 'justifyContent') {
2370
- node.view!.distribution = NSStackViewDistribution.GravityAreas
2371
- moveStackChildren(node)
2913
+ if (name === 'spacing') {
2914
+ syncStackLayout(node)
2372
2915
  } else if (name === 'style') {
2373
2916
  applyStyle(node, value)
2374
2917
  } else if (name === 'className') {
2375
2918
  applyClassName(node, value)
2376
- moveStackChildren(node)
2377
- node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2378
- updateCrossAxisConstraints(node)
2379
2919
  } else if (name === 'id') {
2380
2920
  continue
2381
2921
  } else if (name === 'onTap') {
@@ -2397,18 +2937,10 @@ function applyProps(node: ElementNode, props: PropBag) {
2397
2937
  'alignItems',
2398
2938
  'justifyContent',
2399
2939
  'flexWrap',
2400
- 'row',
2401
- 'col',
2402
- 'rowSpan',
2403
- 'colSpan',
2404
- 'left',
2405
- 'top',
2406
2940
  'flexGrow',
2407
2941
  'flexShrink',
2408
2942
  'alignSelf',
2409
2943
  'order',
2410
- 'horizontalAlignment',
2411
- 'verticalAlignment',
2412
2944
  ].includes(name)
2413
2945
  ) {
2414
2946
  continue
@@ -2439,7 +2971,7 @@ function applyProps(node: ElementNode, props: PropBag) {
2439
2971
  } else if (name === 'accessible' || name.startsWith('accessibility')) {
2440
2972
  applyAccessibility(node, name, value)
2441
2973
  } else if (['rows', 'columns'].includes(name)) {
2442
- layoutGridChildren(node)
2974
+ queueLayoutReconcile(node.container, node)
2443
2975
  } else {
2444
2976
  console.warn('[macos-host] ignored gridlayout prop ' + name)
2445
2977
  }
@@ -2465,11 +2997,8 @@ function applyProps(node: ElementNode, props: PropBag) {
2465
2997
  if (name === 'text') {
2466
2998
  setLabelText(node, String(value ?? ''))
2467
2999
  } else if (name === 'fontSize') {
2468
- node.view!.font = fontForFamilyStyle(
2469
- Number(value ?? 16),
2470
- node.appliedFontWeight ?? 400,
2471
- node.appliedFontFamily,
2472
- )
3000
+ node.propFontSize = Number(value ?? 16)
3001
+ syncNodeFont(node)
2473
3002
  } else if (name === 'style') {
2474
3003
  applyStyle(node, value)
2475
3004
  syncText(node)
@@ -2477,9 +3006,9 @@ function applyProps(node: ElementNode, props: PropBag) {
2477
3006
  applyClassName(node, value)
2478
3007
  } else if (name === 'id') {
2479
3008
  continue
2480
- } else if (['maxLines', 'whiteSpace', 'textOverflow', 'accessible'].includes(name)) {
3009
+ } else if (['maxLines', 'whiteSpace', 'textOverflow'].includes(name)) {
2481
3010
  continue
2482
- } else if (name.startsWith('accessibility')) {
3011
+ } else if (name === 'accessible' || name.startsWith('accessibility')) {
2483
3012
  applyAccessibility(node, name, value)
2484
3013
  } else if (name.startsWith('on') && value == null) {
2485
3014
  continue
@@ -2561,6 +3090,30 @@ function applyProps(node: ElementNode, props: PropBag) {
2561
3090
  setControlAction(node, value, () =>
2562
3091
  String(node.type === 'textview' ? node.view!.string : (node.view!.stringValue ?? '')),
2563
3092
  )
3093
+ } else if (name === 'onSubmit') {
3094
+ if (node.type === 'textfield') {
3095
+ setTextSubmitAction(node, value)
3096
+ } else if (typeof value === 'function') {
3097
+ console.warn('[macos-host] onSubmit is unsupported on multiline textview')
3098
+ }
3099
+ } else if (name === 'editable' || name === 'isEnabled' || name === 'enabled') {
3100
+ syncTextControlState(node)
3101
+ } else if (name === 'secure') {
3102
+ // The native class is chosen in makeTextField; a secure change
3103
+ // would require a new view, so it is rejected rather than
3104
+ // silently downgraded to plain text.
3105
+ if (node.type !== 'textfield') {
3106
+ if (value) {
3107
+ console.warn(
3108
+ '[macos-host] secure is unsupported on <' + node.type + '>; rendering plain text',
3109
+ )
3110
+ }
3111
+ } else if (!!value !== node.secure) {
3112
+ console.warn(
3113
+ '[macos-host] changing "secure" after mount is unsupported; the field keeps its initial secure=' +
3114
+ node.secure,
3115
+ )
3116
+ }
2564
3117
  } else if (name === 'style') {
2565
3118
  applyStyle(node, value)
2566
3119
  } else if (name === 'className') {
@@ -2569,21 +3122,11 @@ function applyProps(node: ElementNode, props: PropBag) {
2569
3122
  continue
2570
3123
  } else if (name === 'rows' && node.type === 'textview') {
2571
3124
  continue
2572
- } else if (name.startsWith('accessibility')) {
3125
+ } else if (name === 'accessible' || name.startsWith('accessibility')) {
2573
3126
  applyAccessibility(node, name, value)
2574
3127
  } else if (name.startsWith('on') && value == null) {
2575
3128
  continue
2576
- } else if (
2577
- [
2578
- 'editable',
2579
- 'enabled',
2580
- 'secure',
2581
- 'keyboardType',
2582
- 'returnKeyType',
2583
- 'autoGrow',
2584
- 'maxRows',
2585
- ].includes(name)
2586
- ) {
3129
+ } else if (['keyboardType', 'returnKeyType', 'autoGrow', 'maxRows'].includes(name)) {
2587
3130
  continue
2588
3131
  } else {
2589
3132
  console.warn('[macos-host] ignored text control prop ' + name)
@@ -2642,7 +3185,7 @@ function applyProps(node: ElementNode, props: PropBag) {
2642
3185
  break
2643
3186
  case 'image':
2644
3187
  if (name === 'src') {
2645
- node.view!.image = loadImage(value)
3188
+ updateImage(node.view as NSImageView, value)
2646
3189
  } else if (name === 'style') {
2647
3190
  applyStyle(node, value)
2648
3191
  } else if (name === 'className' || name === 'id' || name === 'alt') {
@@ -2658,6 +3201,13 @@ function applyProps(node: ElementNode, props: PropBag) {
2658
3201
  }
2659
3202
  }
2660
3203
 
3204
+ if (
3205
+ (node.type === 'flexboxlayout' || node.type === 'stack') &&
3206
+ Object.keys(props).some(isStackLayoutInput)
3207
+ ) {
3208
+ syncStackLayout(node)
3209
+ }
3210
+
2661
3211
  if (node.type === 'webview') {
2662
3212
  updateWebView(node, props, (height) => {
2663
3213
  node.props.style = { ...node.props.style, height }
@@ -2670,8 +3220,17 @@ function applyProps(node: ElementNode, props: PropBag) {
2670
3220
  }
2671
3221
 
2672
3222
  if (node.type === 'label') {
3223
+ syncLabelOverflow(node)
3224
+ syncText(node)
2673
3225
  const style = node.props.style ?? {}
2674
- if (style.lineHeight == null && style.height == null) {
3226
+ if (
3227
+ style.height == null &&
3228
+ node.props.maxLines != null &&
3229
+ node.props.maxLines !== 1 &&
3230
+ node.props.whiteSpace !== 'nowrap'
3231
+ ) {
3232
+ setSizeConstraint(node, 'height', undefined)
3233
+ } else if (style.lineHeight == null && style.height == null) {
2675
3234
  const size = Number(node.view!.font?.pointSize ?? 16)
2676
3235
  const height =
2677
3236
  style.fontSize == null && node.headingDefaultHeight != null
@@ -2698,14 +3257,10 @@ function applyProps(node: ElementNode, props: PropBag) {
2698
3257
  }
2699
3258
  }
2700
3259
 
2701
- if (node.type === 'gridlayout') {
2702
- layoutGridChildren(node)
2703
- } else if (node.type === 'absolutelayout') {
2704
- layoutAbsoluteChildren(node)
2705
- } else if (node.parent?.type === 'gridlayout') {
2706
- layoutGridChildren(node.parent)
2707
- } else if (node.parent?.type === 'absolutelayout') {
2708
- layoutAbsoluteChildren(node.parent)
3260
+ if (node.type === 'gridlayout' || node.type === 'absolutelayout') {
3261
+ queueLayoutReconcile(node.container, node)
3262
+ } else if (node.parent?.type === 'gridlayout' || node.parent?.type === 'absolutelayout') {
3263
+ queueLayoutReconcile(node.container, node.parent)
2709
3264
  }
2710
3265
  }
2711
3266
 
@@ -2723,6 +3278,7 @@ function detach(container: RootContainer, node: ElementNode) {
2723
3278
 
2724
3279
  node.crossAxisConstraint = null
2725
3280
  deactivateSizeConstraints(node)
3281
+ releasePlacementPins(node)
2726
3282
  if (node.view) {
2727
3283
  const parentView = previousParent?.childHost ?? previousParent?.view
2728
3284
  if (parentView?.removeArrangedSubview) {
@@ -2732,20 +3288,53 @@ function detach(container: RootContainer, node: ElementNode) {
2732
3288
  arrangedView(node).removeFromSuperview()
2733
3289
  }
2734
3290
 
2735
- if (previousParent?.type === 'gridlayout') {
2736
- layoutGridChildren(previousParent)
3291
+ queueLayoutReconcile(container, previousParent)
3292
+ syncText(previousParent)
3293
+ node.parent = null
3294
+ }
3295
+
3296
+ /** Mark a parent for one sibling-wide reconcile after the batch's commands.
3297
+ * Insert/remove/update each used to rescan siblings, rebuild distribution,
3298
+ * and recreate every child's cross-axis constraint on the spot — n inserts
3299
+ * into one stack made that quadratic. Structural commands queue their dirty
3300
+ * parents here; `prepareBatch.apply` drains the set once at the end. Outside
3301
+ * a batch (no `layoutDirty` set) the reconcile runs immediately. */
3302
+ function queueLayoutReconcile(container: RootContainer, parent: ElementNode | null | undefined) {
3303
+ if (!parent) {
3304
+ return
2737
3305
  }
2738
3306
 
2739
- if (previousParent?.type === 'absolutelayout') {
2740
- layoutAbsoluteChildren(previousParent)
3307
+ const pending = container?.layoutDirty
3308
+ if (pending) {
3309
+ pending.add(parent)
3310
+ return
2741
3311
  }
2742
3312
 
2743
- if (previousParent) {
2744
- updateStackDistribution(previousParent)
3313
+ reconcileLayoutParent(parent)
3314
+ }
3315
+
3316
+ function reconcileLayoutParent(parent: ElementNode) {
3317
+ if (parent.type === 'gridlayout') {
3318
+ layoutGridChildren(parent)
3319
+ return
2745
3320
  }
2746
3321
 
2747
- syncText(previousParent)
2748
- node.parent = null
3322
+ if (parent.type === 'absolutelayout') {
3323
+ layoutAbsoluteChildren(parent)
3324
+ return
3325
+ }
3326
+
3327
+ const stack = parent.childHost ?? parent.view
3328
+ if (typeof stack?.addViewInGravity !== 'function') {
3329
+ return
3330
+ }
3331
+
3332
+ for (const child of parent.children) {
3333
+ setStackChildPriorities(parent, child)
3334
+ }
3335
+
3336
+ updateStackDistribution(parent)
3337
+ updateCrossAxisConstraints(parent)
2749
3338
  }
2750
3339
 
2751
3340
  function insert(
@@ -2754,62 +3343,54 @@ function insert(
2754
3343
  node: ElementNode,
2755
3344
  beforeId: number | null,
2756
3345
  ) {
2757
- detach(container, node)
3346
+ // Validate before detaching — a rejected insert must not leave the node
3347
+ // orphaned from its current parent.
2758
3348
  const parent = (parentId === null ? null : container.nodes.get(parentId)) ?? null
2759
3349
  if (parentId !== null && !parent) {
2760
3350
  throw new Error('Unknown AppKit parent ' + parentId)
2761
3351
  }
2762
3352
 
2763
- if (
2764
- parent &&
2765
- node.view &&
2766
- parent.type !== 'stack' &&
2767
- parent.type !== 'flexboxlayout' &&
2768
- parent.type !== 'scrollview' &&
2769
- parent.type !== 'gridlayout' &&
2770
- parent.type !== 'absolutelayout'
2771
- ) {
3353
+ if (parent && node.view && !VIEW_PARENT_TYPES.has(parent.type)) {
2772
3354
  throw new Error('AppKit <' + parent.type + '> cannot contain child views')
2773
3355
  }
2774
3356
 
3357
+ const parentView = parent?.childHost ?? parent?.view ?? container.hostView
3358
+ if (node.view && !parentView) {
3359
+ throw new Error('AppKit host has no parent view for node ' + node.id)
3360
+ }
3361
+
3362
+ detach(container, node)
2775
3363
  const siblings = parent ? parent.children : container.children
2776
3364
  const beforeIndex = beforeId === null ? -1 : siblings.findIndex((child) => child.id === beforeId)
2777
3365
  const index = beforeIndex < 0 ? siblings.length : beforeIndex
2778
3366
  siblings.splice(index, 0, node)
2779
3367
  node.parent = parent
2780
3368
  if (node.view) {
2781
- const parentView = parent?.childHost ?? parent?.view ?? container.hostView
2782
- if (!parentView) {
2783
- throw new Error('AppKit host has no parent view for node ' + node.id)
2784
- }
2785
-
2786
3369
  if (parent) {
2787
3370
  if (parent.type === 'gridlayout') {
2788
3371
  parentView.addSubview(node.view)
2789
3372
  node.view.translatesAutoresizingMaskIntoConstraints = false
2790
3373
  deactivateSizeConstraints(node)
2791
- layoutGridChildren(parent)
3374
+ queueLayoutReconcile(container, parent)
2792
3375
  } else if (parent.type === 'absolutelayout') {
2793
3376
  parentView.addSubview(node.view)
2794
3377
  node.view.translatesAutoresizingMaskIntoConstraints = false
2795
3378
  deactivateSizeConstraints(node)
2796
- layoutAbsoluteChildren(parent)
3379
+ queueLayoutReconcile(container, parent)
2797
3380
  } else {
2798
3381
  if (marginInsetsOf(node)) {
2799
3382
  makeMarginHost(node)
3383
+ setLayoutAction(node, node.props?.onLayoutChanged)
2800
3384
  }
2801
3385
 
2802
3386
  // AppKit insertion indices are local to a gravity area. Honor the
2803
3387
  // renderer's before edge instead of appending moved keyed rows.
2804
3388
  const gravity = stackGravity(parent, node)
2805
- const arrangedIndex = siblings
2806
- .slice(0, index)
2807
- .filter((child) => child.view && stackGravity(parent, child) === gravity).length
3389
+ const arrangedIndex = gravityInsertIndex(parent, node, gravity)
2808
3390
 
2809
3391
  parentView.insertViewAtIndexInGravity(arrangedView(node), arrangedIndex, gravity)
2810
3392
  applySizeConstraints(node)
2811
- setStackChildPriorities(parent, node)
2812
- updateCrossAxisConstraints(parent)
3393
+ queueLayoutReconcile(container, parent)
2813
3394
  }
2814
3395
  } else {
2815
3396
  parentView.addSubview(node.view)
@@ -2844,6 +3425,7 @@ function remove(container: RootContainer, parentId: number | null, node: Element
2844
3425
 
2845
3426
  node.crossAxisConstraint = null
2846
3427
  deactivateSizeConstraints(node)
3428
+ releasePlacementPins(node)
2847
3429
  if (node.view) {
2848
3430
  const parentView = expectedParent?.childHost ?? expectedParent?.view
2849
3431
  if (parentView?.removeArrangedSubview) {
@@ -2854,31 +3436,27 @@ function remove(container: RootContainer, parentId: number | null, node: Element
2854
3436
  node.marginHost?.removeFromSuperview()
2855
3437
  node.marginHost = null
2856
3438
  node.marginConstraints = null
3439
+ setLayoutAction(node, node.props?.onLayoutChanged)
2857
3440
  }
2858
3441
 
2859
- if (expectedParent?.type === 'gridlayout') {
2860
- layoutGridChildren(expectedParent)
2861
- }
2862
-
2863
- if (expectedParent?.type === 'absolutelayout') {
2864
- layoutAbsoluteChildren(expectedParent)
2865
- }
2866
-
2867
- if (expectedParent) {
2868
- updateStackDistribution(expectedParent)
2869
- }
2870
-
3442
+ queueLayoutReconcile(container, expectedParent)
2871
3443
  node.parent = null
2872
3444
  syncText(expectedParent)
2873
3445
  }
2874
3446
 
2875
3447
  function destroy(node: ElementNode) {
2876
- if (node.type === 'webview') {
2877
- disposeWebView(node.view!)
3448
+ // Nodes destroyed by a failed makeNode may have a null view — every
3449
+ // branch below must tolerate partial construction.
3450
+ if (node.type === 'webview' && node.view) {
3451
+ disposeWebView(node.view as WKWebView)
3452
+ }
3453
+
3454
+ if (node.type === 'image' && node.view) {
3455
+ disposeImage(node.view as NSImageView)
2878
3456
  }
2879
3457
 
2880
- if (node.type === 'scrollview' && node.scrollObserverInstalled) {
2881
- const clipView = node.view!.contentView
3458
+ if (node.type === 'scrollview' && node.scrollObserverInstalled && node.view) {
3459
+ const clipView = node.view.contentView
2882
3460
  scrollHandlers.delete(clipView)
2883
3461
  NSNotificationCenter.defaultCenter.removeObserver(node.scrollObserver)
2884
3462
  node.scrollObserver = null
@@ -2890,6 +3468,9 @@ function destroy(node: ElementNode) {
2890
3468
  node.layoutObserver = null
2891
3469
  }
2892
3470
 
3471
+ layoutHandlers.delete(node.layoutObservedView)
3472
+ node.layoutObservedView = null
3473
+
2893
3474
  if (node.actionId !== undefined) {
2894
3475
  actionHandlers.delete(node.actionId)
2895
3476
  accessibilityLabels.delete(node.actionId)
@@ -2897,18 +3478,127 @@ function destroy(node: ElementNode) {
2897
3478
  }
2898
3479
  }
2899
3480
 
3481
+ /** Pre-flight a whole batch without mutating, so a deterministic failure —
3482
+ * bad props, unknown nodes, illegal parents, unsupported ops — rejects the
3483
+ * batch before any command applies. Residual mid-apply failures are then
3484
+ * limited to unexpected native errors, and each command unwinds itself. */
3485
+ function validateBatch(container: RootContainer, commands: readonly UniversalHostCommand[]) {
3486
+ // Ids created by this batch are not in container.nodes yet — track them so
3487
+ // later commands validate against the intended tree.
3488
+ const pending = new Map<number, { type: string; props: PropBag }>()
3489
+ const destroyed = new Set<number>()
3490
+ const known = (id: number): { type: string; props: PropBag } | undefined =>
3491
+ pending.get(id) ?? (destroyed.has(id) ? undefined : container.nodes.get(id))
3492
+
3493
+ for (const command of commands) {
3494
+ switch (command.op) {
3495
+ case 'create':
3496
+ assertSupportedType(command.type)
3497
+ validateNodeProps(command.type, command.props)
3498
+ pending.set(command.id, { type: command.type, props: command.props })
3499
+ destroyed.delete(command.id)
3500
+ continue
3501
+ case 'recreate': {
3502
+ const entry = known(command.id)
3503
+ if (!entry) {
3504
+ throw new Error('Unknown AppKit node ' + command.id)
3505
+ }
3506
+
3507
+ assertSupportedType(command.type)
3508
+ validateNodeProps(command.type, command.props)
3509
+ const existing = container.nodes.get(command.id)
3510
+ if (
3511
+ existing?.parent &&
3512
+ typeHasView(command.type) &&
3513
+ !VIEW_PARENT_TYPES.has(existing.parent.type)
3514
+ ) {
3515
+ throw new Error('AppKit <' + existing.parent.type + '> cannot contain child views')
3516
+ }
3517
+
3518
+ pending.set(command.id, { type: command.type, props: command.props })
3519
+ continue
3520
+ }
3521
+ case 'update': {
3522
+ const entry = known(command.id)
3523
+ if (!entry) {
3524
+ throw new Error('Unknown AppKit node ' + command.id)
3525
+ }
3526
+
3527
+ validateNodeProps(entry.type, command.props, entry.props)
3528
+ continue
3529
+ }
3530
+ case 'insert':
3531
+ case 'move': {
3532
+ const entry = known(command.id)
3533
+ if (!entry) {
3534
+ throw new Error('Unknown AppKit node ' + command.id)
3535
+ }
3536
+
3537
+ const parentId = command.parent as number | null
3538
+ if (parentId !== null) {
3539
+ const parent = known(parentId)
3540
+ if (!parent) {
3541
+ throw new Error('Unknown AppKit parent ' + parentId)
3542
+ }
3543
+
3544
+ if (typeHasView(entry.type) && !VIEW_PARENT_TYPES.has(parent.type)) {
3545
+ throw new Error('AppKit <' + parent.type + '> cannot contain child views')
3546
+ }
3547
+ }
3548
+
3549
+ continue
3550
+ }
3551
+ case 'destroy':
3552
+ pending.delete(command.id)
3553
+ destroyed.add(command.id)
3554
+ continue
3555
+ case 'remove':
3556
+ case 'visibility':
3557
+ case 'ensure-public-instance':
3558
+ continue
3559
+ default:
3560
+ throw new Error('AppKit spike does not support host command ' + command.op)
3561
+ }
3562
+ }
3563
+ }
3564
+
2900
3565
  function applyCommand(container: RootContainer, command: UniversalHostCommand) {
2901
3566
  switch (command.op) {
2902
- case 'create':
3567
+ case 'create': {
3568
+ // A retried batch can re-create an id the earlier attempt already
3569
+ // registered — release the previous node instead of orphaning it.
3570
+ const existing = container.nodes.get(command.id)
3571
+ if (existing) {
3572
+ detach(container, existing)
3573
+ destroy(existing)
3574
+ }
3575
+
2903
3576
  container.nodes.set(command.id, makeNode(container, command.id, command.type, command.props))
2904
3577
  return
3578
+ }
2905
3579
  case 'update': {
2906
3580
  const node = container.nodes.get(command.id)
2907
3581
  if (!node) {
2908
3582
  throw new Error('Unknown AppKit node ' + command.id)
2909
3583
  }
2910
3584
 
2911
- applyProps(node, command.props)
3585
+ // node.props records what applied — restore it when a prop throws
3586
+ // mid-update so the bookkeeping doesn't claim unapplied props.
3587
+ const previousProps = node.props
3588
+ try {
3589
+ // Host updates carry the complete current prop bag. Clear omitted
3590
+ // label modes before merging so spread removals restore defaults.
3591
+ applyProps(
3592
+ node,
3593
+ node.type === 'label'
3594
+ ? { maxLines: undefined, whiteSpace: undefined, textOverflow: undefined, ...command.props }
3595
+ : command.props,
3596
+ )
3597
+ } catch (error) {
3598
+ node.props = previousProps
3599
+ throw error
3600
+ }
3601
+
2912
3602
  return
2913
3603
  }
2914
3604
  case 'insert':
@@ -2954,8 +3644,14 @@ function applyCommand(container: RootContainer, command: UniversalHostCommand) {
2954
3644
  throw new Error('Unknown AppKit node ' + command.id)
2955
3645
  }
2956
3646
 
2957
- const replacement = makeNode(container, command.id, command.type, command.props)
2958
3647
  const parent = node.parent
3648
+ if (parent && typeHasView(command.type) && !VIEW_PARENT_TYPES.has(parent.type)) {
3649
+ // insert would reject the replacement after the original was
3650
+ // already removed and destroyed — fail while it is intact.
3651
+ throw new Error('AppKit <' + parent.type + '> cannot contain child views')
3652
+ }
3653
+
3654
+ const replacement = makeNode(container, command.id, command.type, command.props)
2959
3655
  const siblings = parent ? parent.children : container.children
2960
3656
  const index = siblings.indexOf(node)
2961
3657
  const beforeId = index < 0 ? null : (siblings[index + 1]?.id ?? null)
@@ -2974,26 +3670,41 @@ const macOSDriver: UniversalHostDriver<RootContainer, any> = {
2974
3670
  id: 'macos',
2975
3671
  capabilities: { text: 'host' },
2976
3672
  prepareBatch(container, batch) {
3673
+ // Reject deterministic failures before any command mutates — a
3674
+ // partially applied batch cannot be rolled back once commit() marks
3675
+ // it accepted. Prepare acquires nothing, so abort() has no cleanup.
3676
+ validateBatch(container, batch.commands)
2977
3677
  return {
2978
3678
  apply() {
2979
- for (const command of batch.commands) {
2980
- try {
2981
- applyCommand(container, command)
2982
- } catch (error) {
2983
- // The universal root can swallow a mid-batch failure when it
2984
- // retries or aborts the attempt — log it here so an
2985
- // unsupported element cannot silently stall the mount.
2986
- console.error(
2987
- '[macos-host] command ' +
2988
- command.op +
2989
- ' failed for <' +
2990
- ((command as { type?: string }).type ?? '?') +
2991
- '> id=' +
2992
- ('id' in command ? command.id : '?'),
2993
- error,
2994
- )
2995
-
2996
- throw error
3679
+ container.layoutDirty = new Set()
3680
+ try {
3681
+ for (const command of batch.commands) {
3682
+ try {
3683
+ applyCommand(container, command)
3684
+ } catch (error) {
3685
+ // The universal root can swallow a mid-batch failure when it
3686
+ // retries or aborts the attempt — log it here so an
3687
+ // unsupported element cannot silently stall the mount.
3688
+ console.error(
3689
+ '[macos-host] command ' +
3690
+ command.op +
3691
+ ' failed for <' +
3692
+ ((command as { type?: string }).type ?? '?') +
3693
+ '> id=' +
3694
+ ('id' in command ? command.id : '?'),
3695
+ error,
3696
+ )
3697
+
3698
+ throw error
3699
+ }
3700
+ }
3701
+ } finally {
3702
+ const dirty = container.layoutDirty
3703
+ container.layoutDirty = null
3704
+ if (dirty) {
3705
+ for (const parent of dirty) {
3706
+ reconcileLayoutParent(parent)
3707
+ }
2997
3708
  }
2998
3709
  }
2999
3710
  },
@@ -3747,7 +4458,7 @@ function attachContextMenu(
3747
4458
 
3748
4459
  const menuItem = NSMenuItem.alloc().initWithTitleActionKeyEquivalent(
3749
4460
  String(item?.title ?? ''),
3750
- 'menuItemSelected',
4461
+ 'menuItemSelected:',
3751
4462
  '',
3752
4463
  )
3753
4464
 
@@ -3880,7 +4591,7 @@ function attachDatePicker(view: NSView, options: PropBag, onChange: (event: any)
3880
4591
  const actionId = nextActionId++
3881
4592
  picker.tag = actionId
3882
4593
  picker.target = buttonActionTarget
3883
- picker.action = 'controlChanged'
4594
+ picker.action = 'controlChanged:'
3884
4595
  actionHandlers.set(actionId, () => {
3885
4596
  const date = picker.dateValue
3886
4597
  const millis =
@@ -4086,10 +4797,11 @@ function presentSheet(view: NSView, options: PropBag) {
4086
4797
  }
4087
4798
 
4088
4799
  const appKitBridge = (globalThis.__xplatAppKit ??= {})
4800
+ installAppearanceBridge(appKitBridge)
4089
4801
  appKitBridge.observeHover = observeHover
4090
4802
  appKitBridge.showAnchoredPopup = showAnchoredPopup
4091
4803
  appKitBridge.showLayer = (options: PropBag) =>
4092
- showWindowLayer(options, {
4804
+ showWindowLayer(options as WindowLayerOptions, {
4093
4805
  createRoot: (view: NSView, anchor: NSView) =>
4094
4806
  createMacOSRoot(view, { fontFamily: fontFamilyForView(anchor) }),
4095
4807
  fittingSize: popupFittingSize,
@@ -4103,7 +4815,7 @@ installPresentationBridge(appKitBridge, createMacOSRoot, fontFamilyForView)
4103
4815
  const rootFontFamilies = new WeakMap<object, string | undefined>()
4104
4816
 
4105
4817
  function fontFamilyForView(view: NSView | undefined): string | undefined {
4106
- for (let current = view; current; current = current.superview) {
4818
+ for (let current: NSView | null | undefined = view; current; current = current.superview) {
4107
4819
  if (rootFontFamilies.has(current)) {
4108
4820
  return rootFontFamilies.get(current)
4109
4821
  }
@@ -4125,6 +4837,7 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
4125
4837
  nodes: new Map(),
4126
4838
  children: [],
4127
4839
  root: null,
4840
+ layoutDirty: null,
4128
4841
  }
4129
4842
 
4130
4843
  const root = createUniversalRoot(container, macOSDriver, {
@@ -4301,7 +5014,7 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
4301
5014
  try {
4302
5015
  // Row box relative to the clip's top edge, computed in doc
4303
5016
  // coordinates so it stays docH-independent like `offset`.
4304
- const inDoc = child.view.superview.convertRectToView(child.view.frame, doc)
5017
+ const inDoc = child.view.superview!.convertRectToView(child.view.frame, doc)
4305
5018
  const h = Number(inDoc.size.height)
4306
5019
  const y = Number(inDoc.origin.y)
4307
5020
  const top = flipped ? y - originY : originY + clipH - y - h
@@ -4365,7 +5078,18 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
4365
5078
  throw new Error('No AppKit node with id ' + id)
4366
5079
  }
4367
5080
 
4368
- applyStyle(node, { [name]: value })
5081
+ // Single-key writes merge into the node's style bag — applyStyle
5082
+ // diffs against the last applied bag, so a partial object here
5083
+ // would reset every other style.
5084
+ const style = { ...node.props?.style }
5085
+ if (value == null) {
5086
+ delete style[name]
5087
+ } else {
5088
+ style[name] = value
5089
+ }
5090
+
5091
+ node.props = { ...node.props, style }
5092
+ applyStyle(node, style)
4369
5093
  container.hostView.layoutSubtreeIfNeeded?.()
4370
5094
  return this.frameInWindow(id)
4371
5095
  },
@@ -4586,7 +5310,7 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
4586
5310
  buttonActionTarget.textDidChange({ object: node.view } as NSNotification)
4587
5311
  } else {
4588
5312
  node.view!.stringValue = String(value)
4589
- buttonActionTarget.controlChanged(node.view!)
5313
+ buttonActionTarget.controlTextDidChange({ object: node.view } as NSNotification)
4590
5314
  }
4591
5315
  },
4592
5316
  pressAccessibilityLabel(label: string) {