@octane-xplat/macos-renderer 0.11.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,21 +2125,201 @@ 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 {
@@ -1855,20 +2329,20 @@ function applyStyle(node: ElementNode, style: PropBag) {
1855
2329
  // NSImageScaling: 0 ProportionallyDown, 1 AxesIndependently,
1856
2330
  // 2 None (centered, unscaled), 3 ProportionallyUpOrDown.
1857
2331
  // 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
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
1859
2340
  } 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
- )
2341
+ node.styleFontSize = value
2342
+ syncNodeFont(node)
1867
2343
  } 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)
2344
+ node.styleFontFamily = String(value)
2345
+ syncNodeFont(node)
1872
2346
  } else if (name === 'color' && ['label', 'textfield', 'textview'].includes(node.type)) {
1873
2347
  node.styleColor = String(value)
1874
2348
  node.view!.textColor = nativeColor(value)
@@ -1876,31 +2350,28 @@ function applyStyle(node: ElementNode, style: PropBag) {
1876
2350
  // syncText applies this to each paragraph; it is not the label's fixed height.
1877
2351
  continue
1878
2352
  } else if (name === 'padding' && node.type === 'flexboxlayout') {
1879
- node.view!.edgeInsets = parseEdgeInsets(value)
2353
+ node.stylePaddingBox = parseEdgeInsets(value)
2354
+ syncEdgeInsets(node)
1880
2355
  } else if (EDGE_INSET_PROPS.has(name) && node.type === 'flexboxlayout') {
1881
- const insets = { ...node.view!.edgeInsets }
2356
+ const edges = (node.stylePaddingEdges ??= {})
1882
2357
  for (const edge of EDGE_INSET_PROPS.get(name)!) {
1883
- insets[edge] = Number(value) || 0
2358
+ edges[edge] = Number(value) || 0
1884
2359
  }
1885
2360
 
1886
- node.view!.edgeInsets = insets
2361
+ syncEdgeInsets(node)
1887
2362
  } else if (name === 'backgroundColor' && node.view) {
1888
2363
  node.styleBg = String(value)
2364
+ node.bgApplied = true
1889
2365
  node.view.wantsLayer = true
1890
2366
  node.view.layer.backgroundColor = nativeColor(value).CGColor
1891
2367
  } else if (name === 'borderRadius' && node.view) {
1892
- node.view.wantsLayer = true
1893
- node.view.layer.cornerRadius = Number(value)
1894
- node.view.layer.masksToBounds = true
2368
+ node.styleRadius = value
2369
+ syncCornerRadius(node)
1895
2370
  } else if ((name === 'width' || name === 'height') && node.view) {
1896
2371
  setSizeConstraint(node, name, value)
1897
2372
  } 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)
2373
+ if (node.parent?.type === 'gridlayout' || node.parent?.type === 'absolutelayout') {
2374
+ queueLayoutReconcile(node.container, node.parent)
1904
2375
  }
1905
2376
  } else if ((name === 'translateX' || name === 'translateY' || name === 'zIndex') && node.view) {
1906
2377
  node.view.wantsLayer = true
@@ -1917,12 +2388,8 @@ function applyStyle(node: ElementNode, style: PropBag) {
1917
2388
  } else if (name === 'opacity' && node.view) {
1918
2389
  node.view.alphaValue = Number(value)
1919
2390
  } 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
- )
2391
+ node.styleFontWeight = String(value)
2392
+ syncNodeFont(node)
1926
2393
  } else if (name === 'textAlign' && node.type === 'label') {
1927
2394
  node.view!.alignment =
1928
2395
  value === 'left'
@@ -1942,6 +2409,9 @@ function applyStyle(node: ElementNode, style: PropBag) {
1942
2409
  console.warn('[macos-style] ignored unsupported style.' + name + ' on <' + node.type + '>')
1943
2410
  }
1944
2411
  }
2412
+
2413
+ node.appliedStyle = next
2414
+ syncStackLayout(node)
1945
2415
  }
1946
2416
 
1947
2417
  function applyClassName(node: ElementNode, value: any) {
@@ -1949,11 +2419,23 @@ function applyClassName(node: ElementNode, value: any) {
1949
2419
  .split(/\s+/)
1950
2420
  .filter(Boolean)
1951
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
+
1952
2434
  if (node.type === 'label') {
1953
- if (classes.includes('vx-sheet-grabber')) {
2435
+ const grabber = classes.includes('vx-sheet-grabber')
2436
+ if (grabber) {
1954
2437
  node.classLineHeight = 24
1955
2438
  setSizeConstraint(node, 'height', 24)
1956
- node.view!.alignment = NSTextAlignment.Center
1957
2439
  }
1958
2440
 
1959
2441
  const sizes: Record<string, number> = {
@@ -1986,18 +2468,14 @@ function applyClassName(node: ElementNode, value: any) {
1986
2468
  for (const name of classes) {
1987
2469
  const heading = headingMetrics[name]
1988
2470
  if (heading) {
1989
- node.appliedFontWeight = '700'
2471
+ node.classFontWeight = '700'
2472
+ node.classFontSize = heading.size
1990
2473
  node.headingDefaultHeight = heading.height
1991
- node.view!.font = fontForFamilyStyle(heading.size, 700, node.appliedFontFamily)
1992
2474
  }
1993
2475
 
1994
2476
  if (sizes[name]) {
2477
+ node.classFontSize = sizes[name]
1995
2478
  node.headingDefaultHeight = undefined
1996
- node.view!.font = fontForFamilyStyle(
1997
- sizes[name],
1998
- node.appliedFontWeight ?? 400,
1999
- node.appliedFontFamily,
2000
- )
2001
2479
  }
2002
2480
 
2003
2481
  if (lineHeights[name] != null) {
@@ -2005,13 +2483,11 @@ function applyClassName(node: ElementNode, value: any) {
2005
2483
  }
2006
2484
 
2007
2485
  if (name === 'font-semibold') {
2008
- node.appliedFontWeight = '600'
2009
- node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 600, node.appliedFontFamily)
2486
+ node.classFontWeight = '600'
2010
2487
  }
2011
2488
 
2012
2489
  if (name === 'font-bold') {
2013
- node.appliedFontWeight = '700'
2014
- node.view!.font = fontForFamilyStyle(node.view!.font.pointSize, 700, node.appliedFontFamily)
2490
+ node.classFontWeight = '700'
2015
2491
  }
2016
2492
 
2017
2493
  if (name === 'text-muted') {
@@ -2022,6 +2498,28 @@ function applyClassName(node: ElementNode, value: any) {
2022
2498
  node.colorSlot = 'onprimary'
2023
2499
  }
2024
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
+ }
2025
2523
  }
2026
2524
 
2027
2525
  if (
@@ -2040,37 +2538,27 @@ function applyClassName(node: ElementNode, value: any) {
2040
2538
 
2041
2539
  for (const name of classes) {
2042
2540
  if (gaps[name] !== undefined) {
2043
- node.view!.spacing = gaps[name]
2541
+ node.classSpacing = gaps[name]
2044
2542
  }
2045
2543
 
2046
2544
  if (name === 'flex-row') {
2047
- node.view!.orientation = NSUserInterfaceLayoutOrientation.Horizontal
2545
+ node.classOrientation = NSUserInterfaceLayoutOrientation.Horizontal
2048
2546
  }
2049
2547
 
2050
2548
  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))
2549
+ node.classOrientation = NSUserInterfaceLayoutOrientation.Vertical
2056
2550
  }
2057
2551
 
2058
2552
  if (name === 'vx-toast' && node.type === 'flexboxlayout') {
2059
- ;(node.view as NSStackView).edgeInsets = { top: 12, left: 12, bottom: 12, right: 12 }
2553
+ node.classInsets = { top: 12, right: 12, bottom: 12, left: 12 }
2060
2554
  }
2061
2555
 
2062
2556
  if (name === 'vx-toast-viewport') {
2063
- ;(node.view as NSStackView).spacing = 8
2557
+ node.classSpacing = 8
2064
2558
  }
2065
2559
 
2066
2560
  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
2561
+ node.classOrientation = NSUserInterfaceLayoutOrientation.Horizontal
2074
2562
  }
2075
2563
 
2076
2564
  if (name === 'flex-1') {
@@ -2093,37 +2581,21 @@ function applyClassName(node: ElementNode, value: any) {
2093
2581
  }
2094
2582
 
2095
2583
  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
2584
+ node.classRadius = name === 'rounded-full' ? 12 : 8
2099
2585
  }
2100
2586
 
2101
2587
  if (name === 'bg-primary' || name === 'btn') {
2102
2588
  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 }
2589
+ node.classInsets = { top: 6, right: 10, bottom: 6, left: 10 }
2109
2590
  }
2110
2591
 
2111
2592
  if (name === 'bg-danger') {
2112
2593
  node.bgSlot = 'danger'
2113
- node.view!.wantsLayer = true
2114
- node.view!.layer.backgroundColor = nativeColor(
2115
- SCHEME_COLORS[nodeScheme(node)].danger,
2116
- ).CGColor
2117
2594
  }
2118
2595
 
2119
2596
  if (name === 'btn-secondary' || name === 'chip' || name === 'chip-off') {
2120
2597
  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 }
2598
+ node.classInsets = { top: 4, right: 8, bottom: 4, left: 8 }
2127
2599
  }
2128
2600
 
2129
2601
  // Panels that paint the web body's --color-surface. On this host the
@@ -2136,22 +2608,18 @@ function applyClassName(node: ElementNode, value: any) {
2136
2608
  name === 'modal-panel'
2137
2609
  ) {
2138
2610
  node.bgSlot = 'surface'
2139
- node.view!.wantsLayer = true
2140
- node.view!.layer.backgroundColor = nativeColor(
2141
- SCHEME_COLORS[nodeScheme(node)].surface,
2142
- ).CGColor
2143
2611
  }
2144
2612
  }
2613
+
2614
+ syncStackLayout(node)
2615
+
2616
+ syncEdgeInsets(node)
2617
+ syncCornerRadius(node)
2145
2618
  }
2146
2619
 
2147
2620
  if (node.type === 'textfield' || node.type === 'textview') {
2148
2621
  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
-
2622
+ node.classFontSize = 14
2155
2623
  node.colorSlot = 'text'
2156
2624
  node.view!.drawsBackground = false
2157
2625
  if (node.type === 'textfield') {
@@ -2162,6 +2630,8 @@ function applyClassName(node: ElementNode, value: any) {
2162
2630
  node.view!.textContainerInset = { width: 0, height: 0 }
2163
2631
  }
2164
2632
  }
2633
+
2634
+ syncNodeFont(node)
2165
2635
  }
2166
2636
 
2167
2637
  applyThemeColors(node)
@@ -2176,7 +2646,7 @@ function applyClassName(node: ElementNode, value: any) {
2176
2646
  }
2177
2647
 
2178
2648
  if (node.parent) {
2179
- setStackChildPriorities(node.parent, node)
2649
+ queueLayoutReconcile(node.container, node.parent)
2180
2650
  }
2181
2651
 
2182
2652
  updateStackDistribution(node)
@@ -2194,6 +2664,38 @@ function textContent(node: ElementNode): string {
2194
2664
  return node.children.map(textContent).join('')
2195
2665
  }
2196
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
+
2197
2699
  function setLabelText(node: ElementNode, text: any) {
2198
2700
  const lineHeight = Number(node.props?.style?.lineHeight)
2199
2701
  if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
@@ -2202,6 +2704,7 @@ function setLabelText(node: ElementNode, text: any) {
2202
2704
  }
2203
2705
 
2204
2706
  const paragraphStyle = NSMutableParagraphStyle.alloc().init()
2707
+ paragraphStyle.lineBreakMode = (node.view as NSTextField).cell.lineBreakMode
2205
2708
  paragraphStyle.minimumLineHeight = lineHeight
2206
2709
  paragraphStyle.maximumLineHeight = lineHeight
2207
2710
  node.view!.attributedStringValue = NSAttributedString.alloc().initWithStringAttributes(text, {
@@ -2235,7 +2738,7 @@ function setAction(node: ElementNode, value: any) {
2235
2738
  actionIdsByView.set(node.view!, node.actionId)
2236
2739
  actionHandlers.set(node.actionId, null)
2237
2740
  node.view!.addGestureRecognizer(
2238
- NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed'),
2741
+ NSClickGestureRecognizer.alloc().initWithTargetAction(buttonActionTarget, 'viewPressed:'),
2239
2742
  )
2240
2743
  }
2241
2744
 
@@ -2257,7 +2760,7 @@ function setPanAction(node: ElementNode, value: any) {
2257
2760
  if (typeof value === 'function' && !node.panGestureRecognizer) {
2258
2761
  node.panGestureRecognizer = NSPanGestureRecognizer.alloc().initWithTargetAction(
2259
2762
  buttonActionTarget,
2260
- 'viewPanned',
2763
+ 'viewPanned:',
2261
2764
  )
2262
2765
 
2263
2766
  node.view!.addGestureRecognizer(node.panGestureRecognizer)
@@ -2296,6 +2799,35 @@ function setControlAction(node: ElementNode, value: any, readValue: () => any) {
2296
2799
  )
2297
2800
  }
2298
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
+
2299
2831
  function applyAccessibility(node: ElementNode, name: string, value: any) {
2300
2832
  if (node.type === 'flexboxlayout' && node.actionId !== undefined) {
2301
2833
  const props = stackAccessibilityPropsByView.get(node.view!) ?? {}
@@ -2312,6 +2844,20 @@ function applyAccessibility(node: ElementNode, name: string, value: any) {
2312
2844
  if (node.type === 'label' && name === 'accessibilityLabel') {
2313
2845
  node.view!.setAccessibilityLabel?.(String(value ?? ''))
2314
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
+ }
2315
2861
  }
2316
2862
 
2317
2863
  function applyProps(node: ElementNode, props: PropBag) {
@@ -2329,7 +2875,20 @@ function applyProps(node: ElementNode, props: PropBag) {
2329
2875
  return
2330
2876
  }
2331
2877
 
2878
+ const layoutChildProps: Record<string, readonly string[]> = {
2879
+ absolutelayout: ['left', 'top', 'right', 'bottom'],
2880
+ gridlayout: ['row', 'col', 'rowSpan', 'colSpan', 'horizontalAlignment', 'verticalAlignment'],
2881
+ }
2882
+
2332
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
+
2333
2892
  if (name === 'onLayoutChanged') {
2334
2893
  setLayoutAction(node, value)
2335
2894
  continue
@@ -2338,7 +2897,7 @@ function applyProps(node: ElementNode, props: PropBag) {
2338
2897
  switch (node.type) {
2339
2898
  case 'stack':
2340
2899
  if (name === 'spacing') {
2341
- node.view!.spacing = Number(value ?? 0)
2900
+ syncStackLayout(node)
2342
2901
  } else if (name === 'style') {
2343
2902
  applyStyle(node, value)
2344
2903
  } else if (name === 'className') {
@@ -2351,33 +2910,12 @@ function applyProps(node: ElementNode, props: PropBag) {
2351
2910
 
2352
2911
  break
2353
2912
  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)
2913
+ if (name === 'spacing') {
2914
+ syncStackLayout(node)
2374
2915
  } else if (name === 'style') {
2375
2916
  applyStyle(node, value)
2376
2917
  } else if (name === 'className') {
2377
2918
  applyClassName(node, value)
2378
- moveStackChildren(node)
2379
- node.view!.alignment = stackAlignmentAttribute(node.view, stackAlignItems(node))
2380
- updateCrossAxisConstraints(node)
2381
2919
  } else if (name === 'id') {
2382
2920
  continue
2383
2921
  } else if (name === 'onTap') {
@@ -2399,18 +2937,10 @@ function applyProps(node: ElementNode, props: PropBag) {
2399
2937
  'alignItems',
2400
2938
  'justifyContent',
2401
2939
  'flexWrap',
2402
- 'row',
2403
- 'col',
2404
- 'rowSpan',
2405
- 'colSpan',
2406
- 'left',
2407
- 'top',
2408
2940
  'flexGrow',
2409
2941
  'flexShrink',
2410
2942
  'alignSelf',
2411
2943
  'order',
2412
- 'horizontalAlignment',
2413
- 'verticalAlignment',
2414
2944
  ].includes(name)
2415
2945
  ) {
2416
2946
  continue
@@ -2441,7 +2971,7 @@ function applyProps(node: ElementNode, props: PropBag) {
2441
2971
  } else if (name === 'accessible' || name.startsWith('accessibility')) {
2442
2972
  applyAccessibility(node, name, value)
2443
2973
  } else if (['rows', 'columns'].includes(name)) {
2444
- layoutGridChildren(node)
2974
+ queueLayoutReconcile(node.container, node)
2445
2975
  } else {
2446
2976
  console.warn('[macos-host] ignored gridlayout prop ' + name)
2447
2977
  }
@@ -2467,11 +2997,8 @@ function applyProps(node: ElementNode, props: PropBag) {
2467
2997
  if (name === 'text') {
2468
2998
  setLabelText(node, String(value ?? ''))
2469
2999
  } else if (name === 'fontSize') {
2470
- node.view!.font = fontForFamilyStyle(
2471
- Number(value ?? 16),
2472
- node.appliedFontWeight ?? 400,
2473
- node.appliedFontFamily,
2474
- )
3000
+ node.propFontSize = Number(value ?? 16)
3001
+ syncNodeFont(node)
2475
3002
  } else if (name === 'style') {
2476
3003
  applyStyle(node, value)
2477
3004
  syncText(node)
@@ -2479,9 +3006,9 @@ function applyProps(node: ElementNode, props: PropBag) {
2479
3006
  applyClassName(node, value)
2480
3007
  } else if (name === 'id') {
2481
3008
  continue
2482
- } else if (['maxLines', 'whiteSpace', 'textOverflow', 'accessible'].includes(name)) {
3009
+ } else if (['maxLines', 'whiteSpace', 'textOverflow'].includes(name)) {
2483
3010
  continue
2484
- } else if (name.startsWith('accessibility')) {
3011
+ } else if (name === 'accessible' || name.startsWith('accessibility')) {
2485
3012
  applyAccessibility(node, name, value)
2486
3013
  } else if (name.startsWith('on') && value == null) {
2487
3014
  continue
@@ -2563,6 +3090,30 @@ function applyProps(node: ElementNode, props: PropBag) {
2563
3090
  setControlAction(node, value, () =>
2564
3091
  String(node.type === 'textview' ? node.view!.string : (node.view!.stringValue ?? '')),
2565
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
+ }
2566
3117
  } else if (name === 'style') {
2567
3118
  applyStyle(node, value)
2568
3119
  } else if (name === 'className') {
@@ -2571,21 +3122,11 @@ function applyProps(node: ElementNode, props: PropBag) {
2571
3122
  continue
2572
3123
  } else if (name === 'rows' && node.type === 'textview') {
2573
3124
  continue
2574
- } else if (name.startsWith('accessibility')) {
3125
+ } else if (name === 'accessible' || name.startsWith('accessibility')) {
2575
3126
  applyAccessibility(node, name, value)
2576
3127
  } else if (name.startsWith('on') && value == null) {
2577
3128
  continue
2578
- } else if (
2579
- [
2580
- 'editable',
2581
- 'enabled',
2582
- 'secure',
2583
- 'keyboardType',
2584
- 'returnKeyType',
2585
- 'autoGrow',
2586
- 'maxRows',
2587
- ].includes(name)
2588
- ) {
3129
+ } else if (['keyboardType', 'returnKeyType', 'autoGrow', 'maxRows'].includes(name)) {
2589
3130
  continue
2590
3131
  } else {
2591
3132
  console.warn('[macos-host] ignored text control prop ' + name)
@@ -2644,7 +3185,7 @@ function applyProps(node: ElementNode, props: PropBag) {
2644
3185
  break
2645
3186
  case 'image':
2646
3187
  if (name === 'src') {
2647
- node.view!.image = loadImage(value)
3188
+ updateImage(node.view as NSImageView, value)
2648
3189
  } else if (name === 'style') {
2649
3190
  applyStyle(node, value)
2650
3191
  } else if (name === 'className' || name === 'id' || name === 'alt') {
@@ -2660,6 +3201,13 @@ function applyProps(node: ElementNode, props: PropBag) {
2660
3201
  }
2661
3202
  }
2662
3203
 
3204
+ if (
3205
+ (node.type === 'flexboxlayout' || node.type === 'stack') &&
3206
+ Object.keys(props).some(isStackLayoutInput)
3207
+ ) {
3208
+ syncStackLayout(node)
3209
+ }
3210
+
2663
3211
  if (node.type === 'webview') {
2664
3212
  updateWebView(node, props, (height) => {
2665
3213
  node.props.style = { ...node.props.style, height }
@@ -2672,8 +3220,17 @@ function applyProps(node: ElementNode, props: PropBag) {
2672
3220
  }
2673
3221
 
2674
3222
  if (node.type === 'label') {
3223
+ syncLabelOverflow(node)
3224
+ syncText(node)
2675
3225
  const style = node.props.style ?? {}
2676
- 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) {
2677
3234
  const size = Number(node.view!.font?.pointSize ?? 16)
2678
3235
  const height =
2679
3236
  style.fontSize == null && node.headingDefaultHeight != null
@@ -2700,14 +3257,10 @@ function applyProps(node: ElementNode, props: PropBag) {
2700
3257
  }
2701
3258
  }
2702
3259
 
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)
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)
2711
3264
  }
2712
3265
  }
2713
3266
 
@@ -2725,6 +3278,7 @@ function detach(container: RootContainer, node: ElementNode) {
2725
3278
 
2726
3279
  node.crossAxisConstraint = null
2727
3280
  deactivateSizeConstraints(node)
3281
+ releasePlacementPins(node)
2728
3282
  if (node.view) {
2729
3283
  const parentView = previousParent?.childHost ?? previousParent?.view
2730
3284
  if (parentView?.removeArrangedSubview) {
@@ -2734,20 +3288,53 @@ function detach(container: RootContainer, node: ElementNode) {
2734
3288
  arrangedView(node).removeFromSuperview()
2735
3289
  }
2736
3290
 
2737
- if (previousParent?.type === 'gridlayout') {
2738
- 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
2739
3305
  }
2740
3306
 
2741
- if (previousParent?.type === 'absolutelayout') {
2742
- layoutAbsoluteChildren(previousParent)
3307
+ const pending = container?.layoutDirty
3308
+ if (pending) {
3309
+ pending.add(parent)
3310
+ return
2743
3311
  }
2744
3312
 
2745
- if (previousParent) {
2746
- updateStackDistribution(previousParent)
3313
+ reconcileLayoutParent(parent)
3314
+ }
3315
+
3316
+ function reconcileLayoutParent(parent: ElementNode) {
3317
+ if (parent.type === 'gridlayout') {
3318
+ layoutGridChildren(parent)
3319
+ return
2747
3320
  }
2748
3321
 
2749
- syncText(previousParent)
2750
- 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)
2751
3338
  }
2752
3339
 
2753
3340
  function insert(
@@ -2756,62 +3343,54 @@ function insert(
2756
3343
  node: ElementNode,
2757
3344
  beforeId: number | null,
2758
3345
  ) {
2759
- detach(container, node)
3346
+ // Validate before detaching — a rejected insert must not leave the node
3347
+ // orphaned from its current parent.
2760
3348
  const parent = (parentId === null ? null : container.nodes.get(parentId)) ?? null
2761
3349
  if (parentId !== null && !parent) {
2762
3350
  throw new Error('Unknown AppKit parent ' + parentId)
2763
3351
  }
2764
3352
 
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
- ) {
3353
+ if (parent && node.view && !VIEW_PARENT_TYPES.has(parent.type)) {
2774
3354
  throw new Error('AppKit <' + parent.type + '> cannot contain child views')
2775
3355
  }
2776
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)
2777
3363
  const siblings = parent ? parent.children : container.children
2778
3364
  const beforeIndex = beforeId === null ? -1 : siblings.findIndex((child) => child.id === beforeId)
2779
3365
  const index = beforeIndex < 0 ? siblings.length : beforeIndex
2780
3366
  siblings.splice(index, 0, node)
2781
3367
  node.parent = parent
2782
3368
  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
3369
  if (parent) {
2789
3370
  if (parent.type === 'gridlayout') {
2790
3371
  parentView.addSubview(node.view)
2791
3372
  node.view.translatesAutoresizingMaskIntoConstraints = false
2792
3373
  deactivateSizeConstraints(node)
2793
- layoutGridChildren(parent)
3374
+ queueLayoutReconcile(container, parent)
2794
3375
  } else if (parent.type === 'absolutelayout') {
2795
3376
  parentView.addSubview(node.view)
2796
3377
  node.view.translatesAutoresizingMaskIntoConstraints = false
2797
3378
  deactivateSizeConstraints(node)
2798
- layoutAbsoluteChildren(parent)
3379
+ queueLayoutReconcile(container, parent)
2799
3380
  } else {
2800
3381
  if (marginInsetsOf(node)) {
2801
3382
  makeMarginHost(node)
3383
+ setLayoutAction(node, node.props?.onLayoutChanged)
2802
3384
  }
2803
3385
 
2804
3386
  // AppKit insertion indices are local to a gravity area. Honor the
2805
3387
  // renderer's before edge instead of appending moved keyed rows.
2806
3388
  const gravity = stackGravity(parent, node)
2807
- const arrangedIndex = siblings
2808
- .slice(0, index)
2809
- .filter((child) => child.view && stackGravity(parent, child) === gravity).length
3389
+ const arrangedIndex = gravityInsertIndex(parent, node, gravity)
2810
3390
 
2811
3391
  parentView.insertViewAtIndexInGravity(arrangedView(node), arrangedIndex, gravity)
2812
3392
  applySizeConstraints(node)
2813
- setStackChildPriorities(parent, node)
2814
- updateCrossAxisConstraints(parent)
3393
+ queueLayoutReconcile(container, parent)
2815
3394
  }
2816
3395
  } else {
2817
3396
  parentView.addSubview(node.view)
@@ -2846,6 +3425,7 @@ function remove(container: RootContainer, parentId: number | null, node: Element
2846
3425
 
2847
3426
  node.crossAxisConstraint = null
2848
3427
  deactivateSizeConstraints(node)
3428
+ releasePlacementPins(node)
2849
3429
  if (node.view) {
2850
3430
  const parentView = expectedParent?.childHost ?? expectedParent?.view
2851
3431
  if (parentView?.removeArrangedSubview) {
@@ -2856,31 +3436,27 @@ function remove(container: RootContainer, parentId: number | null, node: Element
2856
3436
  node.marginHost?.removeFromSuperview()
2857
3437
  node.marginHost = null
2858
3438
  node.marginConstraints = null
3439
+ setLayoutAction(node, node.props?.onLayoutChanged)
2859
3440
  }
2860
3441
 
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
-
3442
+ queueLayoutReconcile(container, expectedParent)
2873
3443
  node.parent = null
2874
3444
  syncText(expectedParent)
2875
3445
  }
2876
3446
 
2877
3447
  function destroy(node: ElementNode) {
2878
- if (node.type === 'webview') {
2879
- 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)
2880
3456
  }
2881
3457
 
2882
- if (node.type === 'scrollview' && node.scrollObserverInstalled) {
2883
- const clipView = node.view!.contentView
3458
+ if (node.type === 'scrollview' && node.scrollObserverInstalled && node.view) {
3459
+ const clipView = node.view.contentView
2884
3460
  scrollHandlers.delete(clipView)
2885
3461
  NSNotificationCenter.defaultCenter.removeObserver(node.scrollObserver)
2886
3462
  node.scrollObserver = null
@@ -2892,6 +3468,9 @@ function destroy(node: ElementNode) {
2892
3468
  node.layoutObserver = null
2893
3469
  }
2894
3470
 
3471
+ layoutHandlers.delete(node.layoutObservedView)
3472
+ node.layoutObservedView = null
3473
+
2895
3474
  if (node.actionId !== undefined) {
2896
3475
  actionHandlers.delete(node.actionId)
2897
3476
  accessibilityLabels.delete(node.actionId)
@@ -2899,18 +3478,127 @@ function destroy(node: ElementNode) {
2899
3478
  }
2900
3479
  }
2901
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
+
2902
3565
  function applyCommand(container: RootContainer, command: UniversalHostCommand) {
2903
3566
  switch (command.op) {
2904
- 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
+
2905
3576
  container.nodes.set(command.id, makeNode(container, command.id, command.type, command.props))
2906
3577
  return
3578
+ }
2907
3579
  case 'update': {
2908
3580
  const node = container.nodes.get(command.id)
2909
3581
  if (!node) {
2910
3582
  throw new Error('Unknown AppKit node ' + command.id)
2911
3583
  }
2912
3584
 
2913
- 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
+
2914
3602
  return
2915
3603
  }
2916
3604
  case 'insert':
@@ -2956,8 +3644,14 @@ function applyCommand(container: RootContainer, command: UniversalHostCommand) {
2956
3644
  throw new Error('Unknown AppKit node ' + command.id)
2957
3645
  }
2958
3646
 
2959
- const replacement = makeNode(container, command.id, command.type, command.props)
2960
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)
2961
3655
  const siblings = parent ? parent.children : container.children
2962
3656
  const index = siblings.indexOf(node)
2963
3657
  const beforeId = index < 0 ? null : (siblings[index + 1]?.id ?? null)
@@ -2976,26 +3670,41 @@ const macOSDriver: UniversalHostDriver<RootContainer, any> = {
2976
3670
  id: 'macos',
2977
3671
  capabilities: { text: 'host' },
2978
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)
2979
3677
  return {
2980
3678
  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
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
+ }
2999
3708
  }
3000
3709
  }
3001
3710
  },
@@ -3749,7 +4458,7 @@ function attachContextMenu(
3749
4458
 
3750
4459
  const menuItem = NSMenuItem.alloc().initWithTitleActionKeyEquivalent(
3751
4460
  String(item?.title ?? ''),
3752
- 'menuItemSelected',
4461
+ 'menuItemSelected:',
3753
4462
  '',
3754
4463
  )
3755
4464
 
@@ -3882,7 +4591,7 @@ function attachDatePicker(view: NSView, options: PropBag, onChange: (event: any)
3882
4591
  const actionId = nextActionId++
3883
4592
  picker.tag = actionId
3884
4593
  picker.target = buttonActionTarget
3885
- picker.action = 'controlChanged'
4594
+ picker.action = 'controlChanged:'
3886
4595
  actionHandlers.set(actionId, () => {
3887
4596
  const date = picker.dateValue
3888
4597
  const millis =
@@ -4088,10 +4797,11 @@ function presentSheet(view: NSView, options: PropBag) {
4088
4797
  }
4089
4798
 
4090
4799
  const appKitBridge = (globalThis.__xplatAppKit ??= {})
4800
+ installAppearanceBridge(appKitBridge)
4091
4801
  appKitBridge.observeHover = observeHover
4092
4802
  appKitBridge.showAnchoredPopup = showAnchoredPopup
4093
4803
  appKitBridge.showLayer = (options: PropBag) =>
4094
- showWindowLayer(options, {
4804
+ showWindowLayer(options as WindowLayerOptions, {
4095
4805
  createRoot: (view: NSView, anchor: NSView) =>
4096
4806
  createMacOSRoot(view, { fontFamily: fontFamilyForView(anchor) }),
4097
4807
  fittingSize: popupFittingSize,
@@ -4105,7 +4815,7 @@ installPresentationBridge(appKitBridge, createMacOSRoot, fontFamilyForView)
4105
4815
  const rootFontFamilies = new WeakMap<object, string | undefined>()
4106
4816
 
4107
4817
  function fontFamilyForView(view: NSView | undefined): string | undefined {
4108
- for (let current = view; current; current = current.superview) {
4818
+ for (let current: NSView | null | undefined = view; current; current = current.superview) {
4109
4819
  if (rootFontFamilies.has(current)) {
4110
4820
  return rootFontFamilies.get(current)
4111
4821
  }
@@ -4127,6 +4837,7 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
4127
4837
  nodes: new Map(),
4128
4838
  children: [],
4129
4839
  root: null,
4840
+ layoutDirty: null,
4130
4841
  }
4131
4842
 
4132
4843
  const root = createUniversalRoot(container, macOSDriver, {
@@ -4303,7 +5014,7 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
4303
5014
  try {
4304
5015
  // Row box relative to the clip's top edge, computed in doc
4305
5016
  // coordinates so it stays docH-independent like `offset`.
4306
- const inDoc = child.view.superview.convertRectToView(child.view.frame, doc)
5017
+ const inDoc = child.view.superview!.convertRectToView(child.view.frame, doc)
4307
5018
  const h = Number(inDoc.size.height)
4308
5019
  const y = Number(inDoc.origin.y)
4309
5020
  const top = flipped ? y - originY : originY + clipH - y - h
@@ -4367,7 +5078,18 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
4367
5078
  throw new Error('No AppKit node with id ' + id)
4368
5079
  }
4369
5080
 
4370
- 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)
4371
5093
  container.hostView.layoutSubtreeIfNeeded?.()
4372
5094
  return this.frameInWindow(id)
4373
5095
  },
@@ -4588,7 +5310,7 @@ export function createMacOSRoot(hostView: NSView, { fontFamily }: MacOSRootOptio
4588
5310
  buttonActionTarget.textDidChange({ object: node.view } as NSNotification)
4589
5311
  } else {
4590
5312
  node.view!.stringValue = String(value)
4591
- buttonActionTarget.controlChanged(node.view!)
5313
+ buttonActionTarget.controlTextDidChange({ object: node.view } as NSNotification)
4592
5314
  }
4593
5315
  },
4594
5316
  pressAccessibilityLabel(label: string) {