@symbiote-native/components 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +3 -4
  2. package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
  3. package/build/behaviors/activity-indicator/index.android.js +16 -0
  4. package/build/behaviors/activity-indicator/index.d.ts +3 -0
  5. package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
  6. package/build/behaviors/activity-indicator/index.ios.js +14 -0
  7. package/build/behaviors/activity-indicator/index.js +5 -0
  8. package/build/behaviors/activity-indicator/shared.d.ts +18 -0
  9. package/build/behaviors/activity-indicator/shared.js +149 -0
  10. package/build/behaviors/button.d.ts +2 -0
  11. package/build/behaviors/button.js +328 -0
  12. package/build/behaviors/image-background.d.ts +2 -0
  13. package/build/behaviors/image-background.js +139 -0
  14. package/build/behaviors/image.d.ts +1 -1
  15. package/build/behaviors/image.js +2 -2
  16. package/build/behaviors/input-accessory-view.d.ts +1 -1
  17. package/build/behaviors/input-accessory-view.js +2 -2
  18. package/build/behaviors/pressable.d.ts +59 -1
  19. package/build/behaviors/pressable.js +93 -18
  20. package/build/behaviors/refresh-control.d.ts +2 -0
  21. package/build/behaviors/refresh-control.js +83 -0
  22. package/build/behaviors/scroll-view/index.android.d.ts +1 -0
  23. package/build/behaviors/scroll-view/index.android.js +52 -0
  24. package/build/behaviors/scroll-view/index.d.ts +2 -0
  25. package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
  26. package/build/behaviors/scroll-view/index.ios.js +10 -0
  27. package/build/behaviors/scroll-view/index.js +5 -0
  28. package/build/behaviors/scroll-view/shared.d.ts +11 -0
  29. package/build/behaviors/scroll-view/shared.js +291 -0
  30. package/build/behaviors/scroll-view/sticky.d.ts +17 -0
  31. package/build/behaviors/scroll-view/sticky.js +568 -0
  32. package/build/behaviors/switch.d.ts +1 -1
  33. package/build/behaviors/switch.js +9 -5
  34. package/build/behaviors/text-input.d.ts +2 -2
  35. package/build/behaviors/text-input.js +43 -15
  36. package/build/behaviors/touchable-highlight.d.ts +9 -0
  37. package/build/behaviors/touchable-highlight.js +192 -0
  38. package/build/behaviors/touchable-native-feedback.d.ts +20 -0
  39. package/build/behaviors/touchable-native-feedback.js +333 -0
  40. package/build/behaviors/touchable-opacity.d.ts +12 -0
  41. package/build/behaviors/touchable-opacity.js +227 -0
  42. package/build/behaviors/touchable-without-feedback.d.ts +2 -0
  43. package/build/behaviors/touchable-without-feedback.js +296 -0
  44. package/build/component-names/index.android.js +29 -19
  45. package/build/component-names/index.ios.js +31 -19
  46. package/build/component-names/shared.d.ts +2 -1
  47. package/build/component-names/shared.js +16 -6
  48. package/build/descriptor.js +4 -4
  49. package/build/fold-host-bag.js +2 -2
  50. package/build/index.d.ts +17 -11
  51. package/build/index.js +44 -8
  52. package/build/register.d.ts +1 -0
  53. package/build/register.js +55 -0
  54. package/build/scroll-view-commands.d.ts +4 -0
  55. package/build/scroll-view-commands.js +30 -31
  56. package/build/state/text-input.d.ts +4 -1
  57. package/build/view/render-button.d.ts +36 -1
  58. package/build/view/render-button.js +101 -12
  59. package/build/view/render-image/index.js +2 -2
  60. package/build/view/render-input-accessory-view.js +1 -1
  61. package/build/view/render-modal.js +3 -3
  62. package/build/view/render-pressable/index.d.ts +2 -0
  63. package/build/view/render-pressable/index.js +24 -0
  64. package/build/view/render-scroll-view.d.ts +1 -0
  65. package/build/view/render-scroll-view.js +18 -9
  66. package/build/view/render-switch.d.ts +4 -1
  67. package/build/view/render-switch.js +2 -2
  68. package/build/view/render-text-input.js +2 -2
  69. package/build/view/render-touchable-native-feedback.d.ts +19 -0
  70. package/build/view/render-touchable-native-feedback.js +19 -0
  71. package/host-primitives.cjs +202 -150
  72. package/host-primitives.d.cts +0 -2
  73. package/package.json +8 -17
  74. package/build/state-style.d.ts +0 -15
  75. package/build/state-style.js +0 -47
  76. package/build/view/render-activity-indicator.d.ts +0 -25
  77. package/build/view/render-activity-indicator.js +0 -88
  78. package/build/view/render-image-background.d.ts +0 -9
  79. package/build/view/render-image-background.js +0 -48
  80. package/lowering-fixtures.cjs +0 -259
  81. package/lowering-fixtures.d.cts +0 -17
  82. package/specialize-state-style.cjs +0 -219
  83. package/specialize-state-style.d.cts +0 -15
package/README.md CHANGED
@@ -30,8 +30,7 @@ supplies only the third:
30
30
  bridges the `Descriptor` to `React.createElement`; Vue wires it through `ref`/`watch` and
31
31
  bridges to `h()`. This is the ONLY part a new adapter has to write.
32
32
 
33
- `Switch` is the canonical reference for a full three-layer component; `ActivityIndicator` is the
34
- canonical render-only reference (no state machine needed).
33
+ `Switch` is the canonical reference for a full three-layer component.
35
34
 
36
35
  ### Install
37
36
 
@@ -86,8 +85,8 @@ host node untouched — the render function never names a framework type.
86
85
  canonical `accessibility*` transform, shared so every adapter folds identically.
87
86
  - **Components with a full state + render split** — `Switch`, `Modal` (its reducer gates the iOS
88
87
  keep-alive frame).
89
- - **Render-only components** (no state machine) — `ActivityIndicator`, `Image`,
90
- `ImageBackground`, `InputAccessoryView`.
88
+ - **Render-only components** (no state machine) — `Image`, `ImageBackground`,
89
+ `InputAccessoryView`.
91
90
  - **Pure logic/plumbing without a full `Descriptor`** — `Pressable`'s press state machine
92
91
  (`createPressHandlers` / `createPressRuntime` in `state/pressable`) plus its render-decision
93
92
  helpers (`buildPressableListeners`, `resolveDisabledAccessibilityState`, `shouldClaimResponder`,
@@ -0,0 +1 @@
1
+ export declare function registerActivityIndicatorBehavior(): void;
@@ -0,0 +1,16 @@
1
+ // ActivityIndicator's behavior on Android, where the spinner is `AndroidProgressBar` and needs two
2
+ // props RN's iOS branch never sends (`ActivityIndicator.js:106`, spread only at `:118`):
3
+ //
4
+ // styleAttr drives ProgressBar.setStyle(); without it the view throws "setStyle() not called"
5
+ // indeterminate the spinner has no determinate mode here
6
+ //
7
+ // and where the default colour is the THEME — expressed as null so the fold omits the key entirely
8
+ // rather than handing Fabric's colour parser a null it rejects.
9
+ import { registerActivityIndicatorBehaviors } from './shared.js';
10
+ const ANDROID_STYLE_ATTR = 'Normal';
11
+ export function registerActivityIndicatorBehavior() {
12
+ registerActivityIndicatorBehaviors({
13
+ defaultColor: null,
14
+ nativeExtras: { styleAttr: ANDROID_STYLE_ATTR, indeterminate: true },
15
+ });
16
+ }
@@ -0,0 +1,3 @@
1
+ export { registerActivityIndicatorBehavior } from './index.ios';
2
+ export { ACTIVITY_INDICATOR_SPINNER_TAG, ACTIVITY_INDICATOR_TAG, } from './shared';
3
+ export type { IActivityIndicatorProps, IActivityIndicatorSize } from './shared';
@@ -0,0 +1 @@
1
+ export declare function registerActivityIndicatorBehavior(): void;
@@ -0,0 +1,14 @@
1
+ // ActivityIndicator's behavior on iOS: `ActivityIndicatorView` takes the size enum and a GRAY
2
+ // default colour, and needs no extra native props. That is the entire platform half.
3
+ //
4
+ // This file is also the base the folder's `index.ts` re-exports for headless, matching every other
5
+ // platform-split module in this tree.
6
+ import { registerActivityIndicatorBehaviors } from './shared.js';
7
+ // RN's iOS default spinner colour (`ActivityIndicator.js:25`, GRAY).
8
+ const IOS_DEFAULT_COLOR = '#999999';
9
+ export function registerActivityIndicatorBehavior() {
10
+ registerActivityIndicatorBehaviors({
11
+ defaultColor: IOS_DEFAULT_COLOR,
12
+ nativeExtras: {},
13
+ });
14
+ }
@@ -0,0 +1,5 @@
1
+ // The base of the folder-as-module group: Metro picks `index.ios` / `index.android` per platform,
2
+ // and everything else (tsx, vitest, headless) lands here. iOS is the default, matching every other
3
+ // platform-split module in this tree.
4
+ export { registerActivityIndicatorBehavior } from './index.ios.js';
5
+ export { ACTIVITY_INDICATOR_SPINNER_TAG, ACTIVITY_INDICATOR_TAG, } from './shared.js';
@@ -0,0 +1,18 @@
1
+ import { type IStyleProp, type ISymbioteEvent, type IViewStyle } from '@symbiote-native/engine';
2
+ import type { IAccessibilityProps, IAriaProps } from '../../accessibility-props';
3
+ export declare const ACTIVITY_INDICATOR_TAG = "activity-indicator";
4
+ export declare const ACTIVITY_INDICATOR_SPINNER_TAG = "activity-indicator-spinner";
5
+ export type IActivityIndicatorSize = 'small' | 'large' | number;
6
+ export interface IActivityIndicatorProps extends IAccessibilityProps, IAriaProps {
7
+ animating?: boolean;
8
+ color?: string;
9
+ size?: IActivityIndicatorSize;
10
+ hidesWhenStopped?: boolean;
11
+ style?: IStyleProp<IViewStyle>;
12
+ onLayout?: (event: ISymbioteEvent) => void;
13
+ }
14
+ export type IActivityIndicatorPlatform = {
15
+ defaultColor: string | null;
16
+ nativeExtras: Readonly<Record<string, unknown>>;
17
+ };
18
+ export declare function registerActivityIndicatorBehaviors(platform: IActivityIndicatorPlatform): void;
@@ -0,0 +1,149 @@
1
+ // ActivityIndicator's host behavior: the composition and the prop fold, below the framework, so the
2
+ // primitive is a bare `activity-indicator` tag and not five wrapper components.
3
+ //
4
+ // THE TWO-NODE SHAPE IS RN'S, not ours to collapse. `ActivityIndicator.js:112` opens a centering
5
+ // `<View>` around the native spinner, so the tag is that View and `buildStructure` builds
6
+ // `activity-indicator-spinner` under it. The container cannot be folded INTO the spinner either: it
7
+ // carries `alignItems`/`justifyContent`, which centre the spinner inside the space it was given,
8
+ // and moved onto the spinner they would centre its children, of which it has none.
9
+ //
10
+ // THE PLATFORM HALF IS THE SPINNER'S DEFAULTS AND NOTHING ELSE. iOS defaults the colour to RN's GRAY
11
+ // and needs no extra native props; Android's default is the theme, which means OMITTING the key
12
+ // rather than sending null (Fabric's colour parser rejects a null), plus `styleAttr` and
13
+ // `indeterminate` — without the first, AndroidProgressBar throws "setStyle() not called".
14
+ // `index.ios` / `index.android` supply them, the same file split `behaviors/scroll-view` uses.
15
+ //
16
+ // WHERE THE APP'S PROPS GO. `slotPropsExcept` is the COMPLEMENT of a rename map: everything an app
17
+ // writes on the tag routes to the spinner under its own name except `ACTIVITY_INDICATOR_HOST_PROPS`,
18
+ // which is RN's own split (`ActivityIndicator.js:99` spreads `...restProps` onto the spinner; `:113`
19
+ // keeps `onLayout` and `style` on the View). The set that moves is OPEN — every aria alias, every
20
+ // accessibility prop, whatever an app writes next — so a name map cannot express it.
21
+ //
22
+ // THE SIZE TRANSLATION IS PLATFORM-INVARIANT and lives here beside the fold that applies it: RN maps
23
+ // 'small'/'large' to a native size enum AND a fixed box style, while a NUMBER never reaches native
24
+ // at all (it sizes the spinner through style alone).
25
+ //
26
+ // Registered by all five adapters since 2026-09-09, in the same commit that deleted the five
27
+ // wrappers — the registry is keyed by TAG, so registering while a wrapper still painted its own
28
+ // spinner would have given every indicator two.
29
+ import { appendChild, createElement, registerHostBehavior, } from '@symbiote-native/engine';
30
+ import { descriptorFor } from '../../component-names';
31
+ export const ACTIVITY_INDICATOR_TAG = 'activity-indicator';
32
+ // The NATIVE spinner — `ActivityIndicatorView` on iOS, `AndroidProgressBar` on Android. Built by
33
+ // `buildStructure` below and by nothing else; no app writes it.
34
+ export const ACTIVITY_INDICATOR_SPINNER_TAG = 'activity-indicator-spinner';
35
+ // Fixed pixel boxes RN gives the two named sizes (styles.sizeSmall/sizeLarge).
36
+ const SIZE_SMALL_PX = 20;
37
+ const SIZE_LARGE_PX = 36;
38
+ // RN's own default when the app writes no `size` (ActivityIndicator.js:72).
39
+ const DEFAULT_SIZE = 'small';
40
+ // Centering wrapper RN puts around the spinner (styles.container).
41
+ const CONTAINER_STYLE = {
42
+ alignItems: 'center',
43
+ justifyContent: 'center',
44
+ };
45
+ // The props that stay on the centering host instead of travelling to the spinner: RN's own two
46
+ // (`ActivityIndicator.js:113`) plus the spellings the ENGINE resolves against a node's own style.
47
+ const ACTIVITY_INDICATOR_HOST_PROPS = [
48
+ // A layout callback measures the box the spinner is centred IN, which is this node.
49
+ 'onLayout',
50
+ // The composed `StyleSheet.compose(styles.container, style)` array (`:114`).
51
+ 'style',
52
+ // A class NAME resolves to a style (`routeProp`'s class branch), so a class written on the tag has
53
+ // to reach the centering view — the node `style` lands on — or the app's rule paints a spinner it
54
+ // was never written for.
55
+ 'class',
56
+ 'className',
57
+ ];
58
+ function resolveSize(size) {
59
+ if (size === 'small') {
60
+ return {
61
+ sizeStyle: { width: SIZE_SMALL_PX, height: SIZE_SMALL_PX },
62
+ sizeProp: 'small',
63
+ };
64
+ }
65
+ if (size === 'large') {
66
+ return {
67
+ sizeStyle: { width: SIZE_LARGE_PX, height: SIZE_LARGE_PX },
68
+ sizeProp: 'large',
69
+ };
70
+ }
71
+ return { sizeStyle: { width: size, height: size } };
72
+ }
73
+ // The HOST's fold: RN's `StyleSheet.compose(styles.container, style)` (ActivityIndicator.js:114).
74
+ // Base first, so an app style still wins.
75
+ const hostFold = props => ({
76
+ ...props,
77
+ style: [CONTAINER_STYLE, props.style],
78
+ });
79
+ function isActivityIndicatorSize(value) {
80
+ return value === 'small' || value === 'large' || typeof value === 'number';
81
+ }
82
+ // The SPINNER's fold — RN's own body (`ActivityIndicator.js:99-118`) applied to the node the app
83
+ // never names.
84
+ function spinnerFold(platform) {
85
+ return props => {
86
+ const size = isActivityIndicatorSize(props.size)
87
+ ? props.size
88
+ : DEFAULT_SIZE;
89
+ const { sizeStyle, sizeProp } = resolveSize(size);
90
+ const next = {
91
+ ...props,
92
+ // RN defaults both to true and every wrapper spelled that `!== false`. A tag has no
93
+ // destructuring default, so the fold is where the default has to live.
94
+ animating: props.animating !== false,
95
+ hidesWhenStopped: props.hidesWhenStopped !== false,
96
+ style: sizeStyle,
97
+ };
98
+ // A NUMBER never reaches native: it sizes the spinner through style alone, and the native enum
99
+ // takes 'small'/'large' only. So the key has to leave, not merely go unwritten.
100
+ if (sizeProp === undefined)
101
+ delete next.size;
102
+ else
103
+ next.size = sizeProp;
104
+ const color = typeof props.color === 'string' ? props.color : platform.defaultColor;
105
+ // Omitted rather than sent as null — Android's theme default is null and Fabric's colour parser
106
+ // rejects one.
107
+ if (color === null)
108
+ delete next.color;
109
+ else
110
+ next.color = color;
111
+ return next;
112
+ };
113
+ }
114
+ // The composition. Returns the spinner as the slot because the prop redirect is gated on
115
+ // `childHost` being set — the redirect is what this slot is FOR, and NOT where children go: RN's
116
+ // ActivityIndicator renders only the spinner (`ActivityIndicator.js:112-118`) and takes no children
117
+ // at all. Hence `slotTakesNoChildren` below; without it a stray child would mount INSIDE the native
118
+ // spinner, which on Android is a `ProgressBar` and not a `ViewGroup` — the `addView` crash
119
+ // `IHostBehavior.slotTakesNoChildren` records for ImageBackground's Image.
120
+ function buildSpinner(platform) {
121
+ return (node) => {
122
+ const descriptor = descriptorFor(ACTIVITY_INDICATOR_SPINNER_TAG);
123
+ const spinner = createElement(descriptor.component, descriptor.isText, ACTIVITY_INDICATOR_SPINNER_TAG);
124
+ // Constants of the platform, never a function of a prop, so they are seeded at build time the
125
+ // way ScrollView seeds `collapsable: false` — empty on iOS, AndroidProgressBar's two
126
+ // requirements on Android.
127
+ spinner.props = { ...platform.nativeExtras };
128
+ spinner.payloadFold = spinnerFold(platform);
129
+ appendChild(node, spinner);
130
+ return spinner;
131
+ };
132
+ }
133
+ function activityIndicatorBehavior(platform) {
134
+ return {
135
+ slotPropsExcept: ACTIVITY_INDICATOR_HOST_PROPS,
136
+ slotTakesNoChildren: true,
137
+ buildStructure: buildSpinner(platform),
138
+ foldPayload: hostFold,
139
+ // Required by the interface and deliberately empty: this primitive owns no timer, no listener
140
+ // and no native handshake. Written out rather than shared with a `noop` so the emptiness reads
141
+ // as a decision.
142
+ attach() { },
143
+ detach() { },
144
+ };
145
+ }
146
+ // Called by the platform files; nothing else should.
147
+ export function registerActivityIndicatorBehaviors(platform) {
148
+ registerHostBehavior(ACTIVITY_INDICATOR_TAG, activityIndicatorBehavior(platform));
149
+ }
@@ -0,0 +1,2 @@
1
+ export declare const BUTTON_TAG = "button";
2
+ export declare function registerButtonBehavior(): void;
@@ -0,0 +1,328 @@
1
+ // Button as an ENGINE-NODE behavior, so RN's one batteries-included control can be an intrinsic
2
+ // tag instead of five framework components (`.claude/rules/host-primitive-tier.md`, tier 2).
3
+ //
4
+ // THE WHOLE PRIMITIVE IS COMPOSITION. RN's Button is a touchable wrapping a View wrapping a Text
5
+ // (Button.js:363-388) and takes NO children — `title` is a string prop. So `buildStructure` owns
6
+ // the entire subtree, and the nodes below it are a PROJECTION of three owner props.
7
+ //
8
+ // AND THE TOUCHABLE IS NOT THE SAME ONE ON BOTH PLATFORMS (Button.js:281-284), which is why the
9
+ // two trees have different HEIGHTS. TouchableOpacity WRAPS — it renders its own `<Animated.View>`
10
+ // and puts the child inside it (TouchableOpacity.js:302,344). TouchableNativeFeedback RENDERS
11
+ // NOTHING and clones its props onto the child instead (TouchableNativeFeedback.js:339), so on
12
+ // Android Button's own `<View style={buttonStyles}>` IS the responder:
13
+ //
14
+ // iOS button RCTView TouchableOpacity's Animated.View — the responder + the fade
15
+ // └ view RCTView resolveButtonViewStyle(color, disabled) — `{}` here
16
+ // └ text RCTText resolveButtonTextStyle(color, disabled) + RN's Text defaults
17
+ // └ raw RCTRawText resolveButtonTitle(title) FOUR nodes
18
+ //
19
+ // Android button RCTView the styled button view, CLONED onto: the responder, the ripple
20
+ // │ background, the whole a11y fold. No fade, no wrapper.
21
+ // └ text RCTText
22
+ // └ raw RCTRawText UPPERCASED (Button.js:352-353) THREE nodes
23
+ //
24
+ // EVERY FOLD IS ALREADY WRITTEN AND TESTED in `../view/render-button`; nothing here re-derives one.
25
+ // What is new is only WHERE they run: on engine nodes, instead of in a component body.
26
+ //
27
+ // ---------------------------------------------------------------------------------------------
28
+ // HOW THE PROJECTION REACHES ITS NODES, given that each `payloadFold` MUST be pure:
29
+ //
30
+ // title a REDIRECT. The raw text is the slot, and `slotProps` renames `title` -> `text` on it,
31
+ // so the app's write lands on the label through the label's own `routeProp` and marks
32
+ // it. `resolveButtonTitle`'s uppercase is then the label's own fold over its OWN props —
33
+ // no owner to read, and `isEmptyRawText` still sees the real title, so an empty one is
34
+ // dropped by the commit walk exactly as it was before.
35
+ // color a per-node FOLD over the owner, on the text and — on iOS — on the view (the shape
36
+ // disabled `behaviors/scroll-view/shared.ts` uses). `slotDerived` marks the slot, and
37
+ // `addDerivedNode` extends that mark to the nodes past it. On Android the second
38
+ // consumer is the OWNER itself, which `setProp` already dirties.
39
+ //
40
+ // So no node writes to another and no follow-up commit is needed. Two seams that do NOT work here,
41
+ // measured against the real commit path, so neither is tried again:
42
+ //
43
+ // slotDerived alone marks `node.childHost` and nothing else — ONE node, where a colour change
44
+ // moves two. `addDerivedNode` is the hop past it.
45
+ // afterCommit UNREACHABLE for exactly the props that matter. `title` and `color` never
46
+ // reach the host payload, so a write to either produces a byte-identical
47
+ // payload and `commitContainer` returns on a no-op ABOVE
48
+ // `runDeferredAttaches`. Recorded at `IHostBehavior.afterCommit`.
49
+ //
50
+ // WHY THE OWNER'S FOLD IS BOUND IN `buildStructure` rather than declared as `behavior.foldPayload`.
51
+ // It needs two things that are not in the bag it is handed: `onPress` lives in the listener STASH
52
+ // (`ownedListeners` diverts it, so `props.onPress` is always undefined), and `focusable` is a
53
+ // function of it. `scroll-view/index.android.ts` assigns `owner.payloadFold` from `onWrapChange`
54
+ // for the same reason; `attachHostBehavior` sets the field one line BEFORE it calls
55
+ // `buildStructure`, so the binding here is what stands.
56
+ // ---------------------------------------------------------------------------------------------
57
+ // KNOWN DIVERGENCES, stated rather than left to be discovered on a device:
58
+ //
59
+ // 1. CLOSED 2026-09-09, and it closed by DELETION rather than by a fix. The gap was that all five
60
+ // wrappers rendered TouchableOpacity unconditionally where RN swaps in TouchableNativeFeedback
61
+ // (Button.js:280-283), so a Button faded on Android and committed four nodes where RN ripples
62
+ // and commits three. There is no wrapper left to diverge: `button` is a tag, registered by all
63
+ // five adapters, and the swap above is the only implementation.
64
+ //
65
+ // THE ORDER MATTERED AND IS THE REUSABLE HALF. The registry is keyed by TAG, so registering
66
+ // while a wrapper still built its own view and text would have given every Button a SECOND copy
67
+ // of the subtree — the hazard `behaviors/scroll-view/shared.ts` records. Entry, registration and
68
+ // the five deletions are one change, which is also what `touchable-native-feedback` did hours
69
+ // earlier and for the same reason.
70
+ //
71
+ // WHAT AN APP SEES, stated because it is a behaviour change and not a refactor: on Android a
72
+ // Button now ripples instead of fading and commits three nodes instead of four. That is the RN
73
+ // parity this whole line of work was for.
74
+ //
75
+ // 2. CLOSED 2026-09-09, kept for the seam rather than the gap. `aria-disabled` — and an authored
76
+ // `accessibilityState.disabled` — now suppress the press, not just grey the label.
77
+ //
78
+ // THE RESOLUTION IS BUTTON'S, NOT THE MACHINE'S, and that asymmetry is the finding. RN hands
79
+ // Pressability the RAW prop (Pressable.js:266), so on a bare `pressable` `aria-disabled` changes
80
+ // only what is ANNOUNCED and the press still fires; resolving it down there would be a new
81
+ // divergence pointing the other way. Button is the outlier (Button.js:337), so it hands the
82
+ // touchable an `IDisabledResolver` (`./pressable`) and `rebuild` calls it at every gesture start.
83
+ //
84
+ // A RESOLVER RATHER THAN A WRITE, because writing the answer into `node.props.disabled` LATCHES:
85
+ // `resolveButtonDisabled` short-circuits on `disabled !== undefined`, so the injected value would
86
+ // answer the next resolution as the app's own and the button could never re-enable. Reading per
87
+ // gesture also means a flip needs no commit to reach the machine.
88
+ //
89
+ // AND THE PRESS WAS ONLY HALF OF IT. `./touchable-opacity`'s `afterCommit` re-settles the fade
90
+ // when `disabled` moves, and it read the RAW prop — so for an hour after the press half closed, a
91
+ // Button disabled by `aria-disabled` mid-press stayed at its ACTIVE opacity while already
92
+ // refusing the press. It reads through the same resolver now. The general shape: one prop
93
+ // resolved in two places, and closing the first makes the second look done.
94
+ //
95
+ // 3. CLOSED 2026-09-09, repo-wide, and kept here for the finding rather than the gap. `focusable`
96
+ // was emitted by NOTHING in `core/components` — not a wrapper, not the press behavior — so a
97
+ // keyboard or TV host could focus a disabled button, on every adapter and both paths. RN carries
98
+ // two formulas (`Pressable.js:258` vs the four `Touchable*`, which also require a press handler
99
+ // and a non-disabled state); both now live in `../view/render-pressable` and every behavior and
100
+ // surviving wrapper calls them.
101
+ // ---------------------------------------------------------------------------------------------
102
+ //
103
+ // REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter entry does
104
+ // a bare `import './register';` that the barrel does not re-export. Registered by ALL FIVE adapters
105
+ // since 2026-09-09, in the same commit that deleted the five wrappers, which is what makes it safe:
106
+ // while a wrapper still built its own view and text under this tag, registering would have given
107
+ // every Button a second copy of the subtree.
108
+ import { addDerivedNode, appendChild, appListenerFor, createElement, createRawText, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, } from '@symbiote-native/engine';
109
+ import { descriptorFor } from '../component-names';
110
+ import { resolveTextProps } from '../text-props.js';
111
+ import { BUTTON_ACCESSIBILITY_ROLE, resolveButtonDisabled, resolveButtonImportantForAccessibility, resolveButtonTextStyle, resolveButtonTitle, resolveButtonViewStyle, } from '../view/render-button.js';
112
+ import { backgroundProps, selectableBackground, } from '../view/render-touchable-native-feedback.js';
113
+ import { resolveTouchableFocusable } from '../view/render-pressable/index.js';
114
+ import { booleanOr, createPressBehavior, } from './pressable.js';
115
+ import { nativeFeedbackRefinement } from './touchable-native-feedback.js';
116
+ import { createTouchableOpacityBehavior } from './touchable-opacity.js';
117
+ export const BUTTON_TAG = 'button';
118
+ // Read once, like `render-button`'s own module-level `buttonViewStyle`: the platform cannot change
119
+ // under a running app, and every test that needs the other branch already has to mock `Platform`
120
+ // for `render-button` regardless — which is why this is a branch rather than a `button/` folder
121
+ // split. A file split would move the behavior and leave its style half still reading `Platform`.
122
+ const IS_ANDROID = Platform.OS === 'android';
123
+ // The owner props the derived nodes' styles are derived from. A name missing here is a node frozen
124
+ // at its mount value, which is the whole failure mode this list has. `title` is NOT one of them: it
125
+ // is redirected by `SLOT_PROPS` and never reaches `setProp` on the owner, so listing it would be
126
+ // dead.
127
+ const SLOT_DERIVED = [
128
+ 'color',
129
+ 'disabled',
130
+ 'aria-disabled',
131
+ 'accessibilityState',
132
+ ];
133
+ // Button.js:386 renders `<Text>{title}</Text>`; the raw text is where that string lives.
134
+ const SLOT_PROPS = { title: 'text' };
135
+ function stringOr(value) {
136
+ return typeof value === 'string' ? value : undefined;
137
+ }
138
+ // `accessibilityState` arrives as `unknown` off `node.props`, and only `disabled` decides anything
139
+ // here. Narrowed field by field rather than cast, the idiom `./pressable` uses for the same bag.
140
+ function accessibilityDisabled(value) {
141
+ if (typeof value !== 'object' || value === null)
142
+ return {};
143
+ const disabled = Reflect.get(value, 'disabled');
144
+ return typeof disabled === 'boolean' ? { disabled } : {};
145
+ }
146
+ // Takes the PROP BAG rather than the node, so the owner's own fold — which is handed a bag and not
147
+ // a node — resolves the same projection its derived children do.
148
+ function projectionOf(props) {
149
+ return {
150
+ color: stringOr(props.color),
151
+ // Button.js:337 — `disabled` may be decided by `aria-disabled` or by an authored
152
+ // `accessibilityState.disabled`. The engine folds both into the COMMITTED accessibilityState
153
+ // already; this is the half a payload fold cannot do, which is greying the label.
154
+ disabled: resolveButtonDisabled(booleanOr(props.disabled), booleanOr(props['aria-disabled']), accessibilityDisabled(props.accessibilityState)),
155
+ };
156
+ }
157
+ // What the press machine reads instead of the raw prop — see KNOWN DIVERGENCES 2. Pure: the
158
+ // projection is derived per call and nothing is written back.
159
+ const buttonDisabled = props => projectionOf(props).disabled;
160
+ /**
161
+ * NO MEMO, and the earlier version's memo is deliberately gone. It guarded `setProp`'s `Object.is`,
162
+ * which a FRESH style object per call can never satisfy — so pushing unconditionally would have
163
+ * dirtied a node on every commit and re-committed forever
164
+ * (`.claude/rules/list-geometry-feedback-loop.md`). A payload fold does not go through `setProp`:
165
+ * its result reaches `reconcile`, which compares against the mirror with a recursive `propsEqual`
166
+ * (commit.ts) and reuses the committed handle when nothing moved. An equal-but-fresh style is
167
+ * therefore not a change, and there is nothing to feed back.
168
+ */
169
+ function viewFold(owner) {
170
+ return props => {
171
+ const { color, disabled } = projectionOf(owner.props);
172
+ return { ...props, style: resolveButtonViewStyle(color, disabled) };
173
+ };
174
+ }
175
+ function textFold(owner) {
176
+ return props => {
177
+ const { color, disabled } = projectionOf(owner.props);
178
+ return {
179
+ ...props,
180
+ style: resolveButtonTextStyle(color, disabled),
181
+ // RN puts `disabled` on the Text as well (Button.js:386) — a real RCTText prop read by
182
+ // Android's accessibility layer, and not the same thing as the greyed colour above.
183
+ disabled,
184
+ };
185
+ };
186
+ }
187
+ // Reads its OWN `text`, which `SLOT_PROPS` redirected the app's `title` into — no owner closure, so
188
+ // the fold is shared by every button. `fabricProps` reads only `.text` off a raw-text fold.
189
+ const labelFold = props => ({
190
+ text: resolveButtonTitle(stringOr(props.text) ?? ''),
191
+ });
192
+ // ---- the Android touchable -------------------------------------------------------------------
193
+ // Button.js:281-284. Not two variants of one component: see the tree diagram at the top for what
194
+ // wrapping instead of cloning costs. The Android arm composes the bare press machine, so no
195
+ // opacity value is opened and no fade runs — the ripple IS the feedback there.
196
+ //
197
+ // The refinement is TNF's own and now lives with TNF (`./touchable-native-feedback`). This file
198
+ // held a private copy while the `touchable-native-feedback` TAG did not exist and its wrappers
199
+ // still wrapped where RN clones; the tag landed, the responder node was already a parameter, and
200
+ // one caller became two.
201
+ const touchable = IS_ANDROID
202
+ ? createPressBehavior(nativeFeedbackRefinement, buttonDisabled)
203
+ : createTouchableOpacityBehavior(buttonDisabled);
204
+ // ---- the owner's own payload -------------------------------------------------------------------
205
+ /**
206
+ * The wrapper-body folds, over the touchable's own. What is deliberately NOT here:
207
+ *
208
+ * accessible the touchable's fold already applies `accessible !== false`, which is
209
+ * exactly RN's split — Button forwards the caller's value RAW (Button.js:365)
210
+ * and the touchable one level down defaults it (TouchableOpacity.js:303).
211
+ * accessibilityState the engine's aria fold gives `ariaDisabled ?? state.disabled` and the press
212
+ * fold then merges `props.disabled` over it, which composes to RN's
213
+ * `props.disabled ?? aria ?? state.disabled` — the same value, with
214
+ * busy/checked/expanded/selected preserved, without a Button-specific fold.
215
+ */
216
+ function ownerFold(node) {
217
+ return props => {
218
+ const next = {
219
+ ...(touchable.foldPayload === undefined
220
+ ? props
221
+ : touchable.foldPayload(props)),
222
+ };
223
+ next.accessibilityRole = BUTTON_ACCESSIBILITY_ROLE;
224
+ // 'no' is the only value the resolver moves (Button.js:356), so checking for it IS the
225
+ // narrowing this bag needs — the shared resolver still owns what 'no' becomes.
226
+ if (next.importantForAccessibility === 'no')
227
+ next.importantForAccessibility =
228
+ resolveButtonImportantForAccessibility('no');
229
+ // Re-mapped, so the raw name must not also reach Fabric. Where the wrappers put it too — the
230
+ // pressable owns sound suppression (Button.js:377 hands `touchSoundDisabled` to the touchable).
231
+ if (Object.hasOwn(next, 'touchSoundDisabled')) {
232
+ next.android_disableSound = next.touchSoundDisabled;
233
+ delete next.touchSoundDisabled;
234
+ }
235
+ const { color, disabled } = projectionOf(props);
236
+ // TouchableOpacity.js:336 and TouchableNativeFeedback.js:369 — the SAME expression, so the tag
237
+ // owes it on both platforms. `onPress` is an owned name, so it is in the stash and never in
238
+ // `props`; a flip of it dirties nothing by itself, which `onOwnedListenerChange` answers.
239
+ next.focusable = resolveTouchableFocusable(booleanOr(props.focusable), appListenerFor(node, 'press') !== undefined, disabled);
240
+ if (IS_ANDROID) {
241
+ // TNF renders no view, it CLONES onto Button's `<View style={buttonStyles}>`
242
+ // (TouchableNativeFeedback.js:339), so this host IS that view. Overwritten rather than merged
243
+ // because RN's Button declares no `style` prop at all — there is nothing to compose with.
244
+ next.style = resolveButtonViewStyle(color, disabled);
245
+ // Button passes no `background` and no `useForeground`, so TNF resolves the theme's
246
+ // selectable background onto the background slot (TouchableNativeFeedback.js:343-348,
247
+ // :402). The dicts are the shared factories', never restated here.
248
+ Object.assign(next, backgroundProps(selectableBackground(), false));
249
+ }
250
+ // Read by the folds above and declared by no ViewConfig. A key Fabric does not know throws
251
+ // nothing, logs nothing and paints nothing, so the strip has to be here or it is never noticed.
252
+ // `title` needs none — `SLOT_PROPS` redirects it before it can land on this node.
253
+ delete next.color;
254
+ return next;
255
+ };
256
+ }
257
+ /**
258
+ * Builds the whole subtree, once, at `attachHostBehavior`.
259
+ *
260
+ * RETURNS THE RAW TEXT. RN's Button declares no `children` prop and renders none, so the slot is
261
+ * not where the app's children go — it is where its `title` goes, which is what `SLOT_PROPS`
262
+ * redirects onto it. `childHost` is also the GATE on both engine seams this behavior uses:
263
+ * `slotDerived`'s mark and the prop redirect are both skipped unless it is set (node.ts), so
264
+ * returning `undefined` would leave the whole subtree frozen at its mount values.
265
+ */
266
+ function buildStructure(node) {
267
+ const textDescriptor = descriptorFor('text');
268
+ const text = createElement(textDescriptor.component, textDescriptor.isText, 'text');
269
+ // RN's Text.js applies these to every non-virtual Text on its way to native, and a hand-written
270
+ // host tag inherits nothing a `<Text>` component did — Svelte's Button clipped long labels
271
+ // mid-word for exactly this reason (`.claude/rules/host-primitive-tier.md`, "The THIRD path").
272
+ // Constants, because the app cannot reach this node to override them.
273
+ text.props = resolveTextProps({});
274
+ // Empty until the redirected `title` arrives. The commit walk drops an empty raw text
275
+ // (`isEmptyRawText`, node.ts), so no Fabric node exists for it until it has a label — and that
276
+ // check reads `props.text`, which the redirect writes, not the fold's uppercased output.
277
+ const label = createRawText('');
278
+ text.payloadFold = textFold(node);
279
+ label.payloadFold = labelFold;
280
+ // The hop `slotDerived` alone does not make: it marks the slot (the label), and this is past it.
281
+ addDerivedNode(node, text);
282
+ appendChild(text, label);
283
+ if (IS_ANDROID) {
284
+ // No fourth node: TNF clones onto the styled view, so the host IS it and the label's parent
285
+ // hangs straight off it.
286
+ appendChild(node, text);
287
+ }
288
+ else {
289
+ const viewDescriptor = descriptorFor('view');
290
+ const view = createElement(viewDescriptor.component, viewDescriptor.isText, 'view');
291
+ view.payloadFold = viewFold(node);
292
+ addDerivedNode(node, view);
293
+ appendChild(view, text);
294
+ // Lands on the owner, because `node.childHost` is still undefined here — the engine assigns it
295
+ // from what this returns. That ordering is why `buildStructure` RETURNS the slot instead of
296
+ // setting the field itself.
297
+ appendChild(node, view);
298
+ }
299
+ // See the header: the owner's fold needs its own node, and this runs after
300
+ // `attachHostBehavior` has already written `behavior.foldPayload` into the field.
301
+ node.payloadFold = ownerFold(node);
302
+ return label;
303
+ }
304
+ // `focusable` is a function of a LISTENER, and a listener flip changes no payload by itself — so
305
+ // the commit after it is a no-op and no fold re-runs (`IHostBehavior.onOwnedListenerChange`).
306
+ function onOwnedListenerChange(node, name) {
307
+ if (name !== 'press')
308
+ return;
309
+ markPropsDirty(node);
310
+ requestCommitFor(node);
311
+ }
312
+ // Idempotent: an adapter entry may be imported more than once in a bundle.
313
+ export function registerButtonBehavior() {
314
+ // `attach`/`detach` come from the touchable unwrapped: the internal nodes are ordinary children
315
+ // that leave with the sweep, and each carries only a pure fold, so this behavior owns no per-node
316
+ // runtime of its own to release.
317
+ //
318
+ // No `foldPayload` here on purpose — `buildStructure` binds the owner's fold to its node.
319
+ const behavior = {
320
+ ...touchable,
321
+ foldPayload: undefined,
322
+ buildStructure,
323
+ onOwnedListenerChange,
324
+ slotProps: SLOT_PROPS,
325
+ slotDerived: SLOT_DERIVED,
326
+ };
327
+ registerHostBehavior(BUTTON_TAG, behavior);
328
+ }
@@ -0,0 +1,2 @@
1
+ export declare const IMAGE_BACKGROUND_TAG = "image-background";
2
+ export declare function registerImageBackgroundBehavior(): void;