@symbiote-native/components 0.5.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 (88) hide show
  1. package/README.md +23 -10
  2. package/build/accessibility-props.d.ts +11 -0
  3. package/build/accessibility-props.js +30 -118
  4. package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
  5. package/build/behaviors/activity-indicator/index.android.js +16 -0
  6. package/build/behaviors/activity-indicator/index.d.ts +3 -0
  7. package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
  8. package/build/behaviors/activity-indicator/index.ios.js +14 -0
  9. package/build/behaviors/activity-indicator/index.js +5 -0
  10. package/build/behaviors/activity-indicator/shared.d.ts +18 -0
  11. package/build/behaviors/activity-indicator/shared.js +149 -0
  12. package/build/behaviors/button.d.ts +2 -0
  13. package/build/behaviors/button.js +328 -0
  14. package/build/behaviors/image-background.d.ts +2 -0
  15. package/build/behaviors/image-background.js +139 -0
  16. package/build/behaviors/image.d.ts +3 -0
  17. package/build/behaviors/image.js +123 -0
  18. package/build/behaviors/input-accessory-view.d.ts +3 -0
  19. package/build/behaviors/input-accessory-view.js +70 -0
  20. package/build/behaviors/pressable.d.ts +60 -0
  21. package/build/behaviors/pressable.js +385 -0
  22. package/build/behaviors/refresh-control.d.ts +2 -0
  23. package/build/behaviors/refresh-control.js +83 -0
  24. package/build/behaviors/scroll-view/index.android.d.ts +1 -0
  25. package/build/behaviors/scroll-view/index.android.js +52 -0
  26. package/build/behaviors/scroll-view/index.d.ts +2 -0
  27. package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
  28. package/build/behaviors/scroll-view/index.ios.js +10 -0
  29. package/build/behaviors/scroll-view/index.js +5 -0
  30. package/build/behaviors/scroll-view/shared.d.ts +11 -0
  31. package/build/behaviors/scroll-view/shared.js +291 -0
  32. package/build/behaviors/scroll-view/sticky.d.ts +17 -0
  33. package/build/behaviors/scroll-view/sticky.js +568 -0
  34. package/build/behaviors/switch.d.ts +2 -0
  35. package/build/behaviors/switch.js +186 -0
  36. package/build/behaviors/text-input.d.ts +14 -0
  37. package/build/behaviors/text-input.js +319 -0
  38. package/build/behaviors/touchable-highlight.d.ts +9 -0
  39. package/build/behaviors/touchable-highlight.js +192 -0
  40. package/build/behaviors/touchable-native-feedback.d.ts +20 -0
  41. package/build/behaviors/touchable-native-feedback.js +333 -0
  42. package/build/behaviors/touchable-opacity.d.ts +12 -0
  43. package/build/behaviors/touchable-opacity.js +227 -0
  44. package/build/behaviors/touchable-without-feedback.d.ts +2 -0
  45. package/build/behaviors/touchable-without-feedback.js +296 -0
  46. package/build/component-names/index.android.js +47 -15
  47. package/build/component-names/index.ios.js +35 -15
  48. package/build/component-names/shared.d.ts +2 -1
  49. package/build/component-names/shared.js +49 -3
  50. package/build/descriptor.js +4 -4
  51. package/build/fold-host-bag.d.ts +15 -0
  52. package/build/fold-host-bag.js +99 -0
  53. package/build/index.d.ts +23 -12
  54. package/build/index.js +50 -9
  55. package/build/register.d.ts +1 -0
  56. package/build/register.js +55 -0
  57. package/build/resolve-intrinsic.d.ts +7 -0
  58. package/build/resolve-intrinsic.js +49 -0
  59. package/build/scroll-view-commands.d.ts +4 -0
  60. package/build/scroll-view-commands.js +30 -31
  61. package/build/state/pressable.d.ts +9 -0
  62. package/build/state/pressable.js +120 -34
  63. package/build/state/text-input.d.ts +10 -2
  64. package/build/state/text-input.js +11 -0
  65. package/build/text-props.js +2 -1
  66. package/build/view/render-button.d.ts +36 -1
  67. package/build/view/render-button.js +101 -12
  68. package/build/view/render-image/index.d.ts +2 -0
  69. package/build/view/render-image/index.js +29 -2
  70. package/build/view/render-input-accessory-view.d.ts +2 -0
  71. package/build/view/render-input-accessory-view.js +37 -5
  72. package/build/view/render-modal.js +3 -3
  73. package/build/view/render-pressable/index.d.ts +2 -0
  74. package/build/view/render-pressable/index.js +24 -0
  75. package/build/view/render-scroll-view.d.ts +1 -0
  76. package/build/view/render-scroll-view.js +18 -9
  77. package/build/view/render-switch.d.ts +4 -1
  78. package/build/view/render-switch.js +8 -2
  79. package/build/view/render-text-input.js +6 -2
  80. package/build/view/render-touchable-native-feedback.d.ts +19 -0
  81. package/build/view/render-touchable-native-feedback.js +19 -0
  82. package/host-primitives.cjs +432 -0
  83. package/host-primitives.d.cts +33 -0
  84. package/package.json +33 -5
  85. package/build/view/render-activity-indicator.d.ts +0 -25
  86. package/build/view/render-activity-indicator.js +0 -62
  87. package/build/view/render-image-background.d.ts +0 -9
  88. package/build/view/render-image-background.js +0 -48
@@ -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;
@@ -0,0 +1,139 @@
1
+ // ImageBackground's host behavior: the composition and the prop split the wrapper component did,
2
+ // moved below the framework so the primitive can be a bare tag.
3
+ //
4
+ // THE TWO-NODE SHAPE IS RN'S. `ImageBackground.js:74-103` opens a `<View>` carrying the app's
5
+ // `style`, puts an absolutely-filled `<Image>` inside it, and lays the app's `{children}` AFTER
6
+ // that image so they paint on top. The lowered form is the same two nodes — `image-background`
7
+ // (an RCTView, the tag an app writes) with an RCTImageView built under it.
8
+ //
9
+ // WHY THE SLOT TAKES NO CHILDREN, which is the one thing this primitive needed that ScrollView,
10
+ // ActivityIndicator and Button did not. `childHost` answers two questions at once — which node an
11
+ // owner prop redirects onto, and which node the app's children go under — and those had the same
12
+ // answer for every primitive until this one. Here they differ: the image takes `imageStyle` and the
13
+ // whole `...props` spread, while the children belong beside it. `slotTakesNoChildren` is what
14
+ // splits them (`IHostBehavior`, and it is not a JSX nicety upstream could have collapsed — an
15
+ // Android `<Image>` is an `ImageView`, not a `ViewGroup`).
16
+ //
17
+ // WHERE THE APP'S PROPS GO. RN destructures `children, style, imageStyle, imageRef,
18
+ // importantForAccessibility, ...props` and spreads `...props` onto the Image
19
+ // (`ImageBackground.js:62-81`), so the set that moves is OPEN — every event, every accessibility
20
+ // prop, `testID`, `id`, whatever an app writes next — and only a complement can express it.
21
+ // `IMAGE_BACKGROUND_HOST_PROPS` is the short list that stays behind, and it is shorter than RN's:
22
+ // `importantForAccessibility` rides to the image alone, which is what all five wrappers did, so
23
+ // the tag and the component it replaces commit the same payload. The divergence from RN predates
24
+ // lowering and is unchanged by it.
25
+ //
26
+ // WHAT THE IMAGE'S FOLD OWES. Everything on the image arrives as a real prop write, so its payload
27
+ // is built by the shared `foldImagePayload` like any other `image`. Two things cannot arrive that
28
+ // way and are folded here: the style, which is DERIVED from the owner's own `style` (RN proxies the
29
+ // wrapper's width/height onto the image so it fills the box rather than collapsing to the source's
30
+ // intrinsic size), and `id`, whose rename to `nativeID` is applied per adapter on the tag THEY
31
+ // create and so never reaches a node a behavior built.
32
+ import { appendChild, createElement, flattenStyle, registerHostBehavior, } from '@symbiote-native/engine';
33
+ import { descriptorFor } from '../component-names';
34
+ import { foldImagePayload, IMAGE_TAG } from './image.js';
35
+ export const IMAGE_BACKGROUND_TAG = 'image-background';
36
+ // The inner Image's positioning: absolute-fill behind the box's children.
37
+ const ABSOLUTE_FILL = {
38
+ position: 'absolute',
39
+ left: 0,
40
+ right: 0,
41
+ top: 0,
42
+ bottom: 0,
43
+ };
44
+ // The props RN keeps on the wrapper View (`ImageBackground.js:74-78`), plus the two spellings of a
45
+ // class name — `routeProp`'s slot redirect runs above its own class branch, so an unlisted `class`
46
+ // would style the image instead of the box.
47
+ const IMAGE_BACKGROUND_HOST_PROPS = ['style', 'class', 'className'];
48
+ // The owner props the image's payload is derived from. Only `style`, because a class name is
49
+ // published INTO `node.props.style` by `pushClassStyle` — so a `class` write arrives here spelled
50
+ // `style` and the proxied width/height follow a class-declared box as well as an inline one.
51
+ const IMAGE_BACKGROUND_SLOT_DERIVED = ['style'];
52
+ // `imageStyle` is the wrapper's own name for the image's `style`. A bare class NAME is a legal
53
+ // value for it — every adapter's wrapper resolved one — and `routeProp` routes a string landing on
54
+ // `style` as `class` instead, so the registry resolves it on the image with nothing needed here.
55
+ const IMAGE_BACKGROUND_SLOT_PROPS = { imageStyle: 'style' };
56
+ // A StyleProp is an object, an array of them, or a registered class array — all of which
57
+ // `flattenStyle` already handles one layer down. The only thing to exclude is a scalar.
58
+ function styleOf(value) {
59
+ if (typeof value !== 'object' || value === null)
60
+ return undefined;
61
+ if (Array.isArray(value))
62
+ return value;
63
+ return { ...value };
64
+ }
65
+ // RN opts the wrapper out of iOS's Smart Invert for its whole subtree (`ImageBackground.js:75`) —
66
+ // a photograph inverted by accessibility settings is the case that prop exists for. None of the
67
+ // five wrappers ever wrote it, so this closes a standing gap rather than reproducing one.
68
+ const hostFold = props => ({
69
+ ...props,
70
+ accessibilityIgnoresInvertColors: true,
71
+ });
72
+ // Read one explicit dimension off the (already-flattened) box style. A dp number or a percentage
73
+ // string is a valid IDimensionValue; anything else (auto / undefined) yields undefined.
74
+ function readDimension(style, key) {
75
+ const value = Object.hasOwn(style, key) ? Reflect.get(style, key) : undefined;
76
+ if (typeof value === 'number' || typeof value === 'string')
77
+ return value;
78
+ return undefined;
79
+ }
80
+ function imageFold(owner) {
81
+ return props => {
82
+ // RN's own workaround, and its comment is worth reading before "simplifying" this
83
+ // (`ImageBackground.js:86-96`): an RN Image overwrites its own width/height from the source's
84
+ // intrinsic size, which fights the box's explicit dimensions, so they are proxied back on.
85
+ // Reads the OWNER's live style — a class name lands there too, published by `pushClassStyle`.
86
+ const box = flattenStyle(styleOf(owner.props.style));
87
+ const next = {
88
+ ...props,
89
+ // `imageStyle` last, so a caller still wins over the fill and the proxy.
90
+ style: [
91
+ ABSOLUTE_FILL,
92
+ {
93
+ width: readDimension(box, 'width'),
94
+ height: readDimension(box, 'height'),
95
+ },
96
+ styleOf(props.style),
97
+ ],
98
+ };
99
+ // Unconditional priority when both are set, matching RN (`View.js:77-79`) and `foldHostBag`.
100
+ // A raw `id` is a key no ViewConfig declares, so Fabric drops it and the nativeID is lost.
101
+ if (Object.hasOwn(next, 'id')) {
102
+ next.nativeID = next.id;
103
+ delete next.id;
104
+ }
105
+ return foldImagePayload(next);
106
+ };
107
+ }
108
+ // Returns the image, so `slotProps` / `slotPropsExcept` / `slotDerived` all point at it — and the
109
+ // app's children stay on the owner because of `slotTakesNoChildren`, not because of what this
110
+ // returns. The image is appended FIRST and nothing else is ever placed in front of it, which is
111
+ // what makes the children paint over it.
112
+ //
113
+ // Built WITHOUT handing `IMAGE_TAG` to `createElement`, deliberately: that would attach Image's own
114
+ // behavior and set `payloadFold` to `foldImagePayload` alone, and this node's style has to be
115
+ // derived from the owner. `payloadFold` is a single slot, so the composition is spelled here — and
116
+ // it still calls the one shared mapping rather than restating it.
117
+ function buildBackgroundImage(node) {
118
+ const descriptor = descriptorFor(IMAGE_TAG);
119
+ const image = createElement(descriptor.component, descriptor.isText);
120
+ image.payloadFold = imageFold(node);
121
+ appendChild(node, image);
122
+ return image;
123
+ }
124
+ const imageBackgroundBehavior = {
125
+ slotProps: IMAGE_BACKGROUND_SLOT_PROPS,
126
+ slotPropsExcept: IMAGE_BACKGROUND_HOST_PROPS,
127
+ slotDerived: IMAGE_BACKGROUND_SLOT_DERIVED,
128
+ slotTakesNoChildren: true,
129
+ buildStructure: buildBackgroundImage,
130
+ foldPayload: hostFold,
131
+ // Required by the interface and deliberately empty: this primitive owns no timer, no listener
132
+ // and no native handshake. Written out rather than shared with a `noop` so the emptiness reads
133
+ // as a decision.
134
+ attach() { },
135
+ detach() { },
136
+ };
137
+ export function registerImageBackgroundBehavior() {
138
+ registerHostBehavior(IMAGE_BACKGROUND_TAG, imageBackgroundBehavior);
139
+ }
@@ -0,0 +1,3 @@
1
+ export declare const IMAGE_TAG = "image";
2
+ export declare function foldImagePayload(props: Readonly<Record<string, unknown>>): Record<string, unknown>;
3
+ export declare function registerImageBehavior(): void;
@@ -0,0 +1,123 @@
1
+ // Image's host behavior, and it carries NOTHING but a prop fold — no listeners, no timers, no
2
+ // commit hook. That is what a "fold-only" primitive means: the wrapper's whole body was prop
3
+ // mapping, so its lowered form owes exactly that and nothing else.
4
+ //
5
+ // The fold itself is not written here. `mapImageProps` in `../view/render-image` is the one
6
+ // implementation, and this file only narrows a flat prop bag into the typed view it takes — the
7
+ // same shape `behaviors/text-input.ts` uses (`stringOf` / `booleanOf` guards, then hand the shared
8
+ // resolver a typed object). A second copy of the mapping is the exact drift this primitive has
9
+ // already paid for once: `adapters/svelte/src/components/image/image-logic.ts` reproduces it by
10
+ // hand and says so in its own header, because nothing was exported to call.
11
+ //
12
+ // WHY THIS MAY SHARE THE WRAPPER'S TAG, where TextInput needed `-managed`. A behavior fold is keyed
13
+ // on the tag, and `renderImage` emits `image` too — so on a wrapper-built node this fold
14
+ // runs on ALREADY-FOLDED props. That is safe here and only here, because the mapping is idempotent:
15
+ // every alias it consumes (`src`, `srcSet`, `alt`, `width`, `height`, …) is absent from its own
16
+ // output, `source` comes back in the array shape `normalizeSource` guarantees, and
17
+ // `loadingIndicatorSource` leaves under a DIFFERENT name (`loadingIndicatorSrc`), so a second pass
18
+ // finds nothing left to fold. Idempotence is asserted in `image.test.ts` rather than assumed — the
19
+ // audit rule's point is that a double fold is invisible precisely when it happens to be harmless,
20
+ // so the property has to be pinned or it is an accident waiting to be broken.
21
+ import { registerHostBehavior, } from '@symbiote-native/engine';
22
+ import { IMAGE_VIEW_PROP_NAMES, mapImageProps, } from '../view/render-image/index.js';
23
+ export const IMAGE_TAG = 'image';
24
+ const RESIZE_MODES = new Set([
25
+ 'cover',
26
+ 'contain',
27
+ 'stretch',
28
+ 'repeat',
29
+ 'center',
30
+ ]);
31
+ const CROSS_ORIGINS = new Set([
32
+ 'anonymous',
33
+ 'use-credentials',
34
+ ]);
35
+ const CONSUMED = new Set(IMAGE_VIEW_PROP_NAMES);
36
+ function stringOf(value) {
37
+ return typeof value === 'string' ? value : undefined;
38
+ }
39
+ function numberOf(value) {
40
+ return typeof value === 'number' ? value : undefined;
41
+ }
42
+ function resizeModeOf(value) {
43
+ if (typeof value !== 'string' || !RESIZE_MODES.has(value))
44
+ return undefined;
45
+ // Narrowed by the set membership above, then re-stated as the union through a switch rather than
46
+ // a cast: the set and the type are two declarations of one list, and only this makes them agree.
47
+ switch (value) {
48
+ case 'cover':
49
+ case 'contain':
50
+ case 'stretch':
51
+ case 'repeat':
52
+ case 'center':
53
+ return value;
54
+ default:
55
+ return undefined;
56
+ }
57
+ }
58
+ function crossOriginOf(value) {
59
+ if (typeof value !== 'string' || !CROSS_ORIGINS.has(value))
60
+ return undefined;
61
+ return value === 'anonymous' ? 'anonymous' : 'use-credentials';
62
+ }
63
+ // A source is an asset id (number), a `{uri}` object, or an array of those. Anything else cannot be
64
+ // resolved and is dropped rather than forwarded — a malformed source reaching Fabric paints nothing
65
+ // and reports nothing, which is worse than an image that is simply absent.
66
+ function sourceOf(value) {
67
+ if (typeof value === 'number')
68
+ return value;
69
+ if (typeof value !== 'object' || value === null)
70
+ return undefined;
71
+ if (Array.isArray(value))
72
+ return value;
73
+ if (typeof Reflect.get(value, 'uri') === 'string')
74
+ return { ...value };
75
+ return undefined;
76
+ }
77
+ // A StyleProp is an object, an array of them, or a registered class array — all of which
78
+ // `flattenStyle` already handles. The only thing to exclude is a scalar.
79
+ function styleOf(value) {
80
+ if (typeof value !== 'object' || value === null)
81
+ return undefined;
82
+ if (Array.isArray(value))
83
+ return value;
84
+ return { ...value };
85
+ }
86
+ export function foldImagePayload(props) {
87
+ const passthrough = {};
88
+ for (const key of Object.keys(props)) {
89
+ if (!CONSUMED.has(key))
90
+ passthrough[key] = props[key];
91
+ }
92
+ return mapImageProps({
93
+ source: sourceOf(props.source),
94
+ defaultSource: sourceOf(props.defaultSource),
95
+ loadingIndicatorSource: sourceOf(props.loadingIndicatorSource),
96
+ style: styleOf(props.style),
97
+ resizeMode: resizeModeOf(props.resizeMode),
98
+ tintColor: stringOf(props.tintColor),
99
+ src: stringOf(props.src),
100
+ srcSet: stringOf(props.srcSet),
101
+ alt: stringOf(props.alt),
102
+ width: numberOf(props.width),
103
+ height: numberOf(props.height),
104
+ crossOrigin: crossOriginOf(props.crossOrigin),
105
+ referrerPolicy: stringOf(props.referrerPolicy),
106
+ passthrough,
107
+ });
108
+ }
109
+ // Required by IHostBehavior and deliberately empty: Image owns no per-node runtime. Written out
110
+ // rather than shared with a `noop` helper so the emptiness reads as a decision.
111
+ function attach(_node) {
112
+ // nothing to set up
113
+ }
114
+ function detach(_node) {
115
+ // nothing to release
116
+ }
117
+ export function registerImageBehavior() {
118
+ registerHostBehavior(IMAGE_TAG, {
119
+ attach,
120
+ detach,
121
+ foldPayload: foldImagePayload,
122
+ });
123
+ }
@@ -0,0 +1,3 @@
1
+ export declare const INPUT_ACCESSORY_VIEW_TAG = "input-accessory-view";
2
+ export declare function foldInputAccessoryViewPayload(props: Readonly<Record<string, unknown>>): Record<string, unknown>;
3
+ export declare function registerInputAccessoryViewBehavior(): void;