@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
@@ -1,36 +1,33 @@
1
- // The lowering SPEC — one description of which primitives compile to an intrinsic tag, what each
2
- // folds, and when a transform must refuse. Data only: no AST, no framework, no code to share.
1
+ // The PRIMITIVE SPEC — one description of which intrinsic tag each primitive is, what it folds,
2
+ // and which of its two Fabric views a prop selects. Data only: no AST, no framework, no code.
3
3
  //
4
- // WHY IT IS A `.cjs` AND NOT PART OF `src/`. Its consumers are Babel plugins and Metro
5
- // transformers (`adapters/vue/metro-vue-transformer.cjs`, `adapters/{vue,solid}/babel-lower-host-
6
- // primitives.cjs`), which run before any TS exists and cannot import from `src/`. That constraint
7
- // is why the map was copied per adapter in the first place — `adapters/vue/src/components.ts`
8
- // says so out loud: "keeps its own copy of this map (a .cjs cannot import from here)".
4
+ // WHY IT IS A `.cjs` AND NOT PART OF `src/`. Its consumers include build-tool files that run
5
+ // before any TS exists and cannot import from `src/` — `adapters/vue/intrinsic-tags.cjs`, which
6
+ // both Vue compilers read to answer element-vs-component. That constraint is why the map was
7
+ // copied per adapter in the first place.
9
8
  //
10
9
  // WHAT IT DOES NOT UNIFY, stated here so nobody hunts for a contradiction that is not one: this
11
- // says WHAT a fold is, never WHICH LAYER applies it. Svelte folds Text's defaults at compile time
12
- // because a lowered `symbiote-text` has no wrapper left to do it; Vue and Solid apply the same
13
- // fold at runtime in their renderers, where the wrapper used to. Both are correct.
10
+ // says WHAT a fold is, never WHICH LAYER applies it. Svelte folds Text's defaults in its DOM shim;
11
+ // Vue and Solid apply the same fold in their renderers. Both are correct.
14
12
  //
15
13
  // Four transforms carried their own copy of this before it existed, and it had already produced a
16
14
  // real behaviour split — see `aliases` below.
17
15
 
18
- // `intrinsicWhen` DECLARED AHEAD OF ITS FIRST ENTRY, like the refusal categories below it.
16
+ // `intrinsicWhen` DECLARED AHEAD OF ITS FIRST ENTRY.
19
17
  //
20
18
  // WHAT IT IS FOR. `TextInput` is the first primitive whose TAG depends on a prop: `multiline`
21
- // selects between two different Fabric views, `symbiote-text-input` and
22
- // `symbiote-text-input-multiline` (`src/view/render-text-input.ts:33`), not between two values of
23
- // one view. A transform prints a static tag, so it can resolve the choice only for a literal.
19
+ // selects between two different Fabric views, `text-input` and
20
+ // `text-input-multiline` (`src/view/render-text-input.ts:33`), not between two values of
21
+ // one view. `src/resolve-intrinsic.ts` reads it at element creation, where the value is known.
24
22
  //
25
23
  // ONE selector and ONE alternative, deliberately — not a map and not a list. There is exactly one
26
24
  // such prop in the whole surface, and a wider field would be invented rather than needed. Absent
27
25
  // `intrinsicWhen` means "one tag", so no existing entry changes.
28
26
  //
29
- // THE ACCEPTED STATIC FORMS ARE THREE, and the boundary is IDENTITY, not truthiness: a bare
30
- // attribute is `true`, an explicit boolean literal is itself, absence is `false`. Everything else
31
- // refuses — including a truthy non-boolean literal like `multiline={1}`, which a type-shaped check
32
- // would wave through. The spec types the selector as a boolean; guessing past that is exactly how a
33
- // silently wrong native view gets committed, and no later prop write can correct one.
27
+ // THE BOUNDARY IS IDENTITY, not truthiness: only `true` picks the alternative, and a truthy
28
+ // non-boolean like `multiline={1}` does not. The spec types the selector as a boolean; guessing
29
+ // past that commits the wrong native view, and no later prop write moves a node between views.
30
+ //
34
31
  // THE TYPEDEF BELOW IS NOT WHAT TYPESCRIPT READS. `host-primitives.d.cts` is a hand-written
35
32
  // declaration file, and a field added here and not there compiles fine in every `.cjs` transform
36
33
  // while failing `tsc` in the one consumer written in TypeScript — measured 2026-08-31, when
@@ -60,7 +57,7 @@
60
57
  // Svelte rename at COMPILE time inside the lowering transform; Vue applies it at RUNTIME
61
58
  // (`PROP_ALIASES` in `adapters/vue/src/renderer/index.ts`, in `patchProp`) because Vue has FOUR
62
59
  // paths to a node — lowered SFC, lowered TSX, the component wrapper, and a hand-written
63
- // `h('symbiote-view', {id})` — and compile time only covers two of them. A transform reading this
60
+ // `h('view', {id})` — and compile time only covers two of them. A transform reading this
64
61
  // spec must therefore not assume it owns the fold. Applying it at both layers happens to be
65
62
  // harmless here (the rename deletes `id`, so the second pass sees nothing), but that is a property
66
63
  // of THIS alias, not a licence.
@@ -75,12 +72,12 @@ const ID_ALIAS = { id: 'nativeID' };
75
72
  /** @type {Record<string, IHostPrimitive>} */
76
73
  const HOST_PRIMITIVES = {
77
74
  View: {
78
- intrinsic: 'symbiote-view',
75
+ intrinsic: 'view',
79
76
  aliases: ID_ALIAS,
80
77
  defaults: {},
81
78
  },
82
79
  // The five-way switch, thrown 2026-08-23 once all three transforms carried the refusals
83
- // (`observesState` below). `symbiote-pressable` resolves to the SAME `RCTView` a plain view does
80
+ // (`observesState` below). `pressable` resolves to the SAME `RCTView` a plain view does
84
81
  // — the tag exists only so the host-behavior registry, which is keyed by TAG and never by
85
82
  // resolved name, can find the press machine.
86
83
  //
@@ -89,13 +86,52 @@ const HOST_PRIMITIVES = {
89
86
  // time. (This read "No aliases and no defaults" while the line below already said `ID_ALIAS`, and
90
87
  // a Solid test injected a `Pressable` entry with `aliases: {}` on the strength of it.)
91
88
  Pressable: {
92
- intrinsic: 'symbiote-pressable',
89
+ intrinsic: 'pressable',
93
90
  aliases: ID_ALIAS,
94
91
  defaults: {},
95
92
  // Turns on `stateInTemplate` and `renderPropChild`. Without them a render-prop button becomes
96
93
  // a tag with no machine — the whole reason this entry landed last.
97
94
  observesState: true,
98
95
  },
96
+ // The three that were WITHHELD until their wrappers collapsed, landed 2026-09-11 now that none
97
+ // exists on any adapter. The condition the old note stated — "it lands when the wrappers collapse
98
+ // to a forwarder over the tag" — was met by deleting them outright.
99
+ //
100
+ // What the entry buys, since the tag and its behavior already worked without one: a hand-written
101
+ // `<scroll-view>` was resolving as a COMPONENT on Vue, because `adapters/vue/intrinsic-tags.cjs`
102
+ // derives its element set from this table and both Vue compilers read it. Missing here, the tag
103
+ // cost a dev-mode resolve warning per element plus the component codegen path — a slot closure
104
+ // instead of `_createElementBlock`.
105
+ //
106
+ // `aliases: ID_ALIAS` even though each behavior's own `foldPayload` already renames `id`. The
107
+ // two COMPOSE: `foldHostBag` deletes the source key, so the behavior's fold finds no `id` and is
108
+ // a no-op. Measured on the committed payload rather than reasoned, both arms identical — the
109
+ // check `adapter-parity-audit.md` demands before declining a majority value, run in the
110
+ // direction of accepting one.
111
+ TouchableOpacity: {
112
+ intrinsic: 'touchable-opacity',
113
+ aliases: ID_ALIAS,
114
+ defaults: {},
115
+ },
116
+ TouchableHighlight: {
117
+ intrinsic: 'touchable-highlight',
118
+ aliases: ID_ALIAS,
119
+ defaults: {},
120
+ },
121
+ // The second primitive whose TAG depends on a prop, and the first where the prop is one RN's own
122
+ // API takes (`<ScrollView horizontal>`): the axis is a SEPARATE native ViewManager, not a flag on
123
+ // one view (`behaviors/scroll-view/shared.ts:131`). So an app may write either spelling and
124
+ // `resolveIntrinsicTag` picks the view, which is also what puts `horizontal-scroll-view` into
125
+ // Vue's element set — a tag apps write directly and which would otherwise resolve as a component.
126
+ ScrollView: {
127
+ intrinsic: 'scroll-view',
128
+ aliases: ID_ALIAS,
129
+ defaults: {},
130
+ intrinsicWhen: {
131
+ prop: 'horizontal',
132
+ intrinsic: 'horizontal-scroll-view',
133
+ },
134
+ },
99
135
  // Landed 2026-08-31, on the second attempt. The first threw the switch with the runtime half
100
136
  // unwired and was reverted the same hour; both gaps it exposed are closed here, and the record is
101
137
  // kept because the SEQUENCE is the reusable part — an entry here is a switch for four transforms
@@ -105,20 +141,20 @@ const HOST_PRIMITIVES = {
105
141
  // the three that lower. React and Angular have no lowering transform, so no lowered node ever
106
142
  // exists there and neither carries a `register.ts` — the same reason they skip Pressable's.
107
143
  //
108
- // 2. The component path no longer shares these tags. It renders `symbiote-text-input-managed`
144
+ // 2. The component path no longer shares these tags. It renders `text-input-managed`
109
145
  // (`component-names/shared.ts`), because the registry is keyed by TAG and the wrappers run the
110
146
  // same machine in their own lifecycle — one shared tag would have installed both copies on a
111
147
  // wrapper-built node and fired `setInputFocused` twice per focus.
112
148
  TextInput: {
113
- intrinsic: 'symbiote-text-input',
149
+ intrinsic: 'text-input',
114
150
  aliases: ID_ALIAS,
115
151
  defaults: {},
116
152
  // `multiline` picks between two SEPARATE native views, not one view with a flag, so the tag is
117
153
  // decided at compile time and a runtime selector must refuse — a wrong view here is
118
- // uncorrectable by any later prop write. `REFUSAL_CATEGORIES.dynamicIntrinsicChoice`.
154
+ // uncorrectable by any later prop write.
119
155
  intrinsicWhen: {
120
156
  prop: 'multiline',
121
- intrinsic: 'symbiote-text-input-multiline',
157
+ intrinsic: 'text-input-multiline',
122
158
  },
123
159
  },
124
160
  // Landed 2026-09-01, same order as TextInput and Image: runtime half built
@@ -129,7 +165,7 @@ const HOST_PRIMITIVES = {
129
165
  // `-managed` twin, same reason as TextInput: the behavior carries a machine (mirrors the last
130
166
  // value native reported, sends a platform snap-back command on disagreement), so a wrapper-built
131
167
  // node — which already runs that same machine in its own lifecycle — must not also get the
132
- // engine's copy. `render-switch.ts` emits `symbiote-switch-managed`; this key's `intrinsic` is
168
+ // engine's copy. `render-switch.ts` emits `switch-managed`; this key's `intrinsic` is
133
169
  // the bare tag the behavior registry attaches to.
134
170
  //
135
171
  // IDEMPOTENCE OF THE FOLD IS MOOT HERE FOR A DIFFERENT REASON THAN IMAGE'S. Image's entry has no
@@ -137,7 +173,7 @@ const HOST_PRIMITIVES = {
137
173
  // idempotence is what makes that safe — asserted, not assumed. Switch's fold is NOT trivial (it
138
174
  // maps `trackColor`/`thumbColor`/`ios_backgroundColor` to native prop names, keyed on
139
175
  // `Platform.OS`) and running it twice would NOT be a no-op — but the question never arises: the
140
- // `-managed` split means only the bare `symbiote-switch` tag ever carries this behavior, and the
176
+ // `-managed` split means only the bare `switch` tag ever carries this behavior, and the
141
177
  // wrapper never emits that tag, so no node's payload ever passes through this fold more than
142
178
  // once. Unreachable by construction, not idempotent by property — the same distinction
143
179
  // TextInput's own entry draws for its fold.
@@ -147,12 +183,43 @@ const HOST_PRIMITIVES = {
147
183
  // `stateInTemplate` nor `renderPropChild` applies — unlike Pressable, whose machine is what
148
184
  // forced that flag.
149
185
  Switch: {
150
- intrinsic: 'symbiote-switch',
186
+ intrinsic: 'switch',
151
187
  aliases: ID_ALIAS,
152
188
  defaults: {},
153
189
  },
190
+ // Filed as NOT LOWERABLE for a week under `.claude/rules/host-primitive-tier.md`'s "SECOND
191
+ // disqualifier" — its own node is a single element, but its POSITION is decided by the ScrollView
192
+ // and differs per platform (iOS a sibling before the content view, Android the scroll view's
193
+ // PARENT), and a per-node behavior cannot own a decision another component makes.
194
+ //
195
+ // That is settled and it was settled elsewhere: the ScrollView states the placement as DATA
196
+ // (`claimedChildren: { [REFRESH_CONTROL]: platform.claimMode }`, `behaviors/scroll-view/shared.ts`)
197
+ // and the ENGINE moves the node in `appendChild`. So the claim needs nothing from this primitive's
198
+ // own behavior — verified on a bare node with no wrapper anywhere, both platforms, in
199
+ // `behaviors/refresh-control.test.ts`.
200
+ //
201
+ // What the behavior owes is therefore only the CONTROLLED HANDSHAKE, and no fold at all: four of
202
+ // the five wrappers folded exactly `resolveAccessibilityProps`, which the engine already runs at
203
+ // `fabricProps` on every path — the same reason SafeAreaView has no behavior file.
204
+ //
205
+ // ID_ALIAS, and it is the SafeAreaView resolution rather than the SafeAreaView position: none of
206
+ // the five wrappers declared `id`, and upstream's RefreshControl spreads `...ViewProps`
207
+ // (RefreshControl.js:70), so that was a standing parity gap rather than a deliberate omission.
208
+ // The prop is declared on all five in the same change as this alias — half of it in either
209
+ // direction is broken (a fold for a key nobody can pass, or a raw `id` reaching a view whose
210
+ // ViewConfig declares none).
211
+ //
212
+ // No `-managed` twin: the behavior carries a machine, so it needs one owner per node, and it has
213
+ // one — the wrappers forward to this tag and none of them runs a mirror any more.
214
+ RefreshControl: {
215
+ intrinsic: 'refresh-control',
216
+ aliases: ID_ALIAS,
217
+ // None. RN seeds nothing: `refreshing` is required, and every other prop is per-platform
218
+ // styling the native view defaults itself.
219
+ defaults: {},
220
+ },
154
221
  Text: {
155
- intrinsic: 'symbiote-text',
222
+ intrinsic: 'text',
156
223
  aliases: ID_ALIAS,
157
224
  // RN's Text.js applies both unconditionally on the non-virtual path. Each key below cites
158
225
  // the upstream line verbatim, because THIS DATA is now the thing that must not drift from RN.
@@ -184,8 +251,27 @@ const HOST_PRIMITIVES = {
184
251
  // proven against the wrapper's payload. Adding this key is what makes every transform start
185
252
  // lowering `Image` at once, so a fold that had not landed would surface as a raw `src` reaching
186
253
  // Fabric — a key no ViewConfig declares, which throws nothing and paints nothing.
254
+ // THE ENTRY IS NOT OPTIONAL HERE, and the reason has nothing to do with folds: this table is what
255
+ // `adapters/vue/intrinsic-tags.cjs` derives element-vs-component from, and a hyphenated tag it
256
+ // does not name compiles to `resolveComponent("image-background")` — children become a slot the
257
+ // element path never reads, so the subtree renders BLANK with no error. `image`/`view`/`text` are
258
+ // real SVG element names and survive that gap; this one is not.
259
+ //
260
+ // `aliases: ID_ALIAS` was MEASURED against the arm without it rather than reasoned about, because
261
+ // `behaviors/image-background.ts` folds `id` itself on the built image. Both arms commit
262
+ // `nativeID` on the image and no `id` anywhere, and the two compose because an alias DELETES its
263
+ // source key — so the second pass finds nothing. Kept for the property `foldHostBag` provides and
264
+ // the behavior cannot: the rename happens on the OWNER bag, before the redirect, so any adapter
265
+ // path that folds bags gets it whether or not the behavior ever runs.
266
+ ImageBackground: {
267
+ intrinsic: 'image-background',
268
+ aliases: ID_ALIAS,
269
+ // None. The absolute-fill style, the box-dimension proxy and the Image mapping are all derived
270
+ // from live props at commit, which a compile-time seed cannot express.
271
+ defaults: {},
272
+ },
187
273
  Image: {
188
- intrinsic: 'symbiote-image',
274
+ intrinsic: 'image',
189
275
  aliases: ID_ALIAS,
190
276
  // None. Every default RN's Image applies is already inside the shared mapping (the source
191
277
  // array shape, the width/height style fold, `alt` -> accessibilityLabel), which the behavior
@@ -202,7 +288,7 @@ const HOST_PRIMITIVES = {
202
288
  // where upstream RN renders nothing at all off iOS. That divergence predates the lowering, is
203
289
  // identical on both paths, and is with the owner as its own decision.
204
290
  InputAccessoryView: {
205
- intrinsic: 'symbiote-input-accessory-view',
291
+ intrinsic: 'input-accessory-view',
206
292
  aliases: ID_ALIAS,
207
293
  // None. The mapping has no aliasing and no derived value — every consumed name leaves under the
208
294
  // same name — so there is nothing for a compile-time seed to do.
@@ -218,7 +304,7 @@ const HOST_PRIMITIVES = {
218
304
  //
219
305
  // Counted before writing, which is the only thing standing behind that claim: five
220
306
  // implementations, zero shared, none synthesizing a node — each renders ONE
221
- // `symbiote-safe-area-view` with children on its framework's own channel (React's third argument,
307
+ // `safe-area-view` with children on its framework's own channel (React's third argument,
222
308
  // a Vue slot, a Solid JSX child, Angular's `<ng-content>`, a Svelte snippet). That clears the
223
309
  // disqualifier in `.claude/rules/host-primitive-tier.md`.
224
310
  //
@@ -234,7 +320,7 @@ const HOST_PRIMITIVES = {
234
320
  // so RN accepts `id` where our wrappers do not. It predates lowering, is identical on both paths,
235
321
  // and closing it means adding `id` to five wrappers AND this alias together, never one of the two.
236
322
  SafeAreaView: {
237
- intrinsic: 'symbiote-safe-area-view',
323
+ intrinsic: 'safe-area-view',
238
324
  // ID_ALIAS was deliberately ABSENT here until 2026-09-01, because none of the five wrappers
239
325
  // declared `id` and aliasing on the lowered path alone would have made lowering ADD a fold the
240
326
  // component spelling does not perform. That exposed a real divergence — Solid's renderer folds
@@ -251,130 +337,96 @@ const HOST_PRIMITIVES = {
251
337
  aliases: ID_ALIAS,
252
338
  defaults: {},
253
339
  },
254
- };
255
-
256
- // A transform must refuse — leave the element a component — whenever it hits one of these. The
257
- // categories are framework-independent; how each compiler DETECTS one is not, and stays in that
258
- // transform. Modelled on the Svelte preprocessor's set, which is the most developed of the three.
259
- //
260
- // Refusing is always safe: a refused element simply keeps today's behaviour. Guessing is not — a
261
- // half-read attribute set is a silently wrong render, on device only. Design every new category
262
- // around that asymmetry.
263
- // A stateful primitive carries `observesState: true`, and that flag is what turns the two refusal
264
- // categories below ON in a transform. Declared in one place so five transforms agree on the
265
- // spelling rather than inventing five.
266
- //
267
- // ADDING A STATEFUL ENTRY TO HOST_PRIMITIVES IS A FIVE-WAY SWITCH and must be the LAST step. Every
268
- // transform lowers whatever the spec lists, so an entry that lands before a transform can refuse
269
- // turns a render-prop button into a tag with no machine: a button that does not press, with nothing
270
- // red in any suite. `Pressable` was held back for exactly that and thrown only once all three
271
- // transforms carried the detections — verified by grep for `observesState` in
272
- // adapters/{solid,vue}/*.cjs and adapters/svelte/src/preprocessor/, not by asking.
273
- const REFUSAL_CATEGORIES = {
274
- // `{...spread}` / `v-bind="obj"` — the attribute set cannot be read whole.
275
- unreadableAttributeSet: 'an attribute set this transform cannot enumerate',
276
- // A computed or otherwise non-literal attribute KEY.
277
- unreadableKey: 'an attribute key this transform cannot resolve to a string',
278
- // A value shape the transform cannot reproduce in the lowered form.
279
- unreadableValue: 'an attribute value this transform cannot read whole',
280
- // `bind:` / `use:` / `{@attach}` / a template ref — binds the COMPONENT INSTANCE, which a
281
- // lowered element does not have.
282
- instanceBoundDirective:
283
- 'a directive that binds the component instance, not the host node',
284
- // RETIRED 2026-08-31 — `bagFold`, "an attribute whose fold needs to see its siblings (role,
285
- // aria-*)". Kept as a comment because the retirement carries two lessons the entry itself never
286
- // could.
340
+ // The one primitive that commits NO NODE: its intrinsic resolves to the engine's anchor, and the
341
+ // behavior (`src/behaviors/touchable-native-feedback.ts`) clones the owner's props onto the single
342
+ // child instead — RN's own shape (TouchableNativeFeedback.js:289,339). Entered in the same commit
343
+ // that deletes the five wrappers, because the registry is keyed by TAG: a wrapper still emitting
344
+ // its own `pressable` while the behavior is registered would put two press machines on one tree.
287
345
  //
288
- // WHY IT IS GONE. The aria fold moved into the engine — `core/engine/src/accessibility-props.ts`,
289
- // called from `fabricProps`, the one point where the whole bag is known on BOTH commit paths — so
290
- // a lowered element gets it exactly like a wrapped one. The premise that a per-key element path
291
- // cannot fold a composite was right; the conclusion that a TRANSFORM had to refuse was not. The
292
- // fold belongs at the layer every path goes through, not at the one layer lowering removes.
346
+ // ID_ALIAS, and it was proposed WITHOUT one on the reasoning that the behavior already reads
347
+ // `id ?? nativeID` itself (:373) so the shared alias would double-fold. Measured instead of
348
+ // reasoned (`behaviors/touchable-native-feedback.test.ts`, "folds `id` the same whichever layer
349
+ // renamed it"): the two compose idempotently — the alias renames on the OWNER, whose props never
350
+ // reach Fabric, and the behavior's `??` then reads the renamed key to the same answer. Declining
351
+ // the pair would have bought nothing and broken Solid's constant-pair fast path, whose guard
352
+ // (`adapters/solid/src/renderer-alias-fold.test.ts`) is what makes one string compare legal on
353
+ // 32 001 prop writes.
293
354
  //
294
- // AND IT WAS NEVER IN FORCE. Measured before removing it: of the four transforms, only Solid's
295
- // consulted this category. Vue's two lowered such elements happily and Svelte's preprocessor does
296
- // not contain the string `role` at all. So every lowered `aria-label` had been reaching Fabric as
297
- // a key no ViewConfig declares — the accessibility label silently dropped, on device only. This
298
- // constant is a VOCABULARY, not an enforcement point: writing a category down binds nobody, and
299
- // a transform that never consults it breaks nothing visible.
355
+ // No `defaults`: RN's TNF seeds nothing at all — every value it derives (`accessible`,
356
+ // `focusable`, `accessibilityState`, the ripple background) depends on ANOTHER prop or on a
357
+ // listener, which is a fold and not a default.
358
+ TouchableNativeFeedback: {
359
+ intrinsic: 'touchable-native-feedback',
360
+ aliases: ID_ALIAS,
361
+ defaults: {},
362
+ },
363
+ // The SECOND primitive that commits no node, same anchor shape and same reason
364
+ // (TouchableWithoutFeedback.js:229,286). Its clone list is not TNF's: the passthrough half is
365
+ // copied only when SET (:281), there is no ripple, and `onBlur`/`onFocus` are cloned where TNF
366
+ // drops them — read `src/behaviors/touchable-without-feedback.ts`'s header rather than inheriting
367
+ // the neighbour's fold.
300
368
  //
301
- // What replaced it is a ROW, not a rule: `aria-bag-fold` in `lowering-fixtures.cjs`, verdict
302
- // `lower`, which every transform's runner must answer. Retiring or adding a category owes a row
303
- // there in the same change, or the prose goes unenforced again
304
- // (`.claude/rules/adapter-parity-audit.md`).
305
- // The two below exist for a STATEFUL primitive (Pressable) and nothing refuses on them yet.
306
- // They are declared ahead of the spec entry on purpose: the moment a stateful tag appears in
307
- // HOST_PRIMITIVES, every transform lowers it, and a transform that cannot yet refuse lowers a
308
- // render-prop button into a tag with no machine — a button that does not press, with nothing
309
- // red anywhere. So the spec entry goes in LAST, after all five can refuse, and the names live
310
- // here from the start so five detections do not invent five spellings of one rule.
369
+ // ID_ALIAS for the reason measured on TNF: the alias renames on the OWNER, whose props never reach
370
+ // Fabric, and the behavior's own `id ?? nativeID` then reads the renamed key to the same answer.
371
+ // Upstream's passthrough loop lets an explicit `nativeID` win over `id` here (:280-284, unlike
372
+ // TNF's :373); NOT reproduced, because with the alias in place that quirk would depend on which
373
+ // adapter folds where. No `defaults` — every value TWF derives depends on another prop or on a
374
+ // listener, which is a fold and not a default.
375
+ TouchableWithoutFeedback: {
376
+ intrinsic: 'touchable-without-feedback',
377
+ aliases: ID_ALIAS,
378
+ defaults: {},
379
+ },
380
+ // RN's Button is a touchable wrapping a View wrapping a Text and takes NO children — `title` is a
381
+ // string prop (Button.js:363-388) — so the behavior owns the whole subtree and the tag is the
382
+ // only spelling. Entered in the same commit that deletes the five wrappers: the registry is keyed
383
+ // by TAG, and a wrapper still building its own View/Text under a registered `button` would give
384
+ // every existing Button a second copy of the subtree.
311
385
  //
312
- // A functional `style` — `style={({pressed}) => …}`. The TEMPLATE reads the press state, which
313
- // is exactly what tier 2 cannot do: the state resolves below the framework, through the style
314
- // registry's `:active`, and never crosses back up. The migration target is a `:active` CSS rule,
315
- // not a smarter transform (`.claude/rules/host-primitive-tier.md`).
386
+ // ID_ALIAS, and here it is REQUIRED rather than inherited — the one entry so far where declining
387
+ // it would have shipped a PLATFORM-DEPENDENT bug. Button's touchable is swapped by platform
388
+ // (Button.js:281-284), and only one of the two arms renames `id` itself: `touchable-opacity`'s
389
+ // own `foldPayload` does (it has no spec entry to do it for it), the bare press behavior does not
390
+ // (`Pressable`'s entry does it instead). Measured on the committed payload, no entry here:
316
391
  //
317
- // DETECT IT AS AN ALLOW-LIST, NOT AS A HUNT FOR A FUNCTION LITERAL. All five transforms must land
318
- // the same side of this or they diverge on the one call site that hoists its style: `style={fn}`
319
- // is an Identifier at compile time and no transform can tell whether it holds an object or a
320
- // function. So only provably inert value shapes lower — object / array / literal / template
321
- // literal — and everything else refuses. The asymmetry is what settles it: a refused element
322
- // keeps exactly today's behaviour, while a wrongly lowered one is a button that renders and does
323
- // not respond.
324
- stateInTemplate: 'a prop whose value is not provably inert at compile time',
325
- // NOT A REFUSAL — kept as a named requirement because it was briefly written as one, and the
326
- // difference is worth the paragraph.
392
+ // iOS nativeID: 'from-id' id: absent the touchable-opacity fold
393
+ // Android nativeID: undefined id: 'from-id' a key no ViewConfig declares -> dropped
327
394
  //
328
- // The resting/pressed pair needs the style callback invoked twice. A transform that emits the
329
- // guard INLINE — `typeof f === 'function' ? f({pressed:false}) : f` — puts the expression in its
330
- // own output three times, so `style={getStyle()}` calls the author's function three times,
331
- // `style={bag[i]}` evaluates the index three times, and `style={flag ? a : b}` can take
332
- // different branches on different reads. Faced with that, the first instinct is to refuse those
333
- // shapes.
395
+ // So the alias is what makes the two platforms agree, and it composes idempotently with the
396
+ // iOS-side fold exactly as TNF's does: the rename happens on the bag, so `Object.hasOwn(next,
397
+ // 'id')` one layer down finds nothing left to do.
334
398
  //
335
- // THAT IS THE WRONG FIX, and encoding it in this file would have made one transform's emission
336
- // defect a law binding the others. The double read is a property of the EMISSION SHAPE, not of
337
- // the expression: wrap once — `resolveStateStyle(expr)` — and the expression is evaluated
338
- // exactly once while its RESULT is what gets called twice. All three shapes above then lower and
339
- // stay correct.
399
+ // No `defaults`: every value RN's Button seeds is derived from another prop or from a listener
400
+ // (`accessible`, `focusable`, the greyed label, the uppercased title), which is a fold, not a
401
+ // default.
402
+ Button: {
403
+ intrinsic: 'button',
404
+ aliases: ID_ALIAS,
405
+ defaults: {},
406
+ },
407
+ // RN wraps the native spinner in a centering `<View>` (ActivityIndicator.js:112), so this tag is
408
+ // that View and the behavior builds `activity-indicator-spinner` under it. Entered in the same
409
+ // commit that deletes the five wrappers: the registry is keyed by TAG, and a wrapper still
410
+ // painting its own spinner while the behavior is registered would give every indicator two.
340
411
  //
341
- // So the rule for every transform is: **emit the style expression exactly once**, and assert it
342
- // on the output text (`occurrences(out, expr) === 1`) rather than trusting the shape. What
343
- // survives as a real contract is only that the callback must be PURE in `pressed` — its result is
344
- // invoked twice under any emission.
412
+ // ID_ALIAS, and unlike Button's it is not platform-dependent — measured on the committed payload,
413
+ // both platform arms, with the entry absent:
345
414
  //
346
- // Caught by the Svelte session after this had been written here as a refusal and sent to two
347
- // adapters; the Vue session found the underlying double-read in the first place.
348
- emitStyleExpressionOnce:
349
- 'REQUIREMENT, not a refusal: wrap the style expression once, never repeat it in the output',
350
- // A function child with arity >= 1 — `{({pressed}) => …}`. Same rule, through children rather
351
- // than props. Arity ZERO is an ordinary lazy child, not a render prop, and must NOT refuse.
352
- renderPropChild:
353
- 'a function child that takes the primitive own state as an argument',
354
- // DECLARED AHEAD OF ITS SPEC ENTRY, the same way the two above were, and for the same reason: the
355
- // moment `TextInput` appears in HOST_PRIMITIVES every transform lowers it at once, and one that
356
- // cannot yet refuse would pick the WRONG Fabric view. Four detections written against a name that
357
- // already exists cannot invent four spellings of one rule.
415
+ // iOS spinner: id 'probe' nativeID absent ActivityIndicatorView declares no `id`
416
+ // Android spinner: id 'probe' nativeID absent AndroidProgressBar declares no `id`
358
417
  //
359
- // `multiline` is the first prop in this project that selects between TWO intrinsics —
360
- // `symbiote-text-input` and `symbiote-text-input-multiline` are different Fabric views, not one
361
- // view with a flag (`core/components/src/view/render-text-input.ts`). A transform prints a static
362
- // tag name, so it can resolve `multiline` only when the value is a literal. `multiline={isLong}`
363
- // is a RUNTIME value and there is no tag to print — the element must stay a component.
418
+ // i.e. identically broken on both, because the platform half of this primitive is the spinner's
419
+ // COLOUR and native extras, and nothing about it touches the name fold. The alias renames on the
420
+ // OWNER's bag, before `slotPropsExcept` routes the survivor down — so the key that reaches the
421
+ // spinner is `nativeID`, which is where RN's `...restProps` puts it too (ActivityIndicator.js:99).
364
422
  //
365
- // NOT the same hazard as an unreadable attribute VALUE. A value the transform cannot read is a
366
- // prop that ends up wrong; this one ends up committing the wrong native view, which no prop write
367
- // can correct afterwards. Refusing keeps today's behaviour exactly.
368
- dynamicIntrinsicChoice:
369
- 'a prop that selects between two intrinsics and is not a compile-time literal',
423
+ // No `defaults`: `animating` and `hidesWhenStopped` ARE `notFalse` folds, but they belong to the
424
+ // SPINNER, and this table's ops are applied to the tag's own bag before any slot routing. They
425
+ // live in the behavior's spinner fold instead, which is the only layer that can see that node.
426
+ ActivityIndicator: {
427
+ intrinsic: 'activity-indicator',
428
+ aliases: ID_ALIAS,
429
+ defaults: {},
430
+ },
370
431
  };
371
-
372
- // Lowering rewrites `class="x"` into an opaque bag expression, so any pass that matches on literal
373
- // attributes can no longer see it. Reversed against Svelte's style scoper, every scoped class
374
- // silently stopped being scoped — nothing threw, the styles just stopped applying on device. The
375
- // hazard belongs to any transform that folds attributes into an expression, not to Svelte, so it
376
- // is stated once here as an ordering contract.
377
- const LOWERING_RUNS_LAST =
378
- 'lowering is the LAST attribute-rewriting pass in its pipeline';
379
-
380
- module.exports = { HOST_PRIMITIVES, REFUSAL_CATEGORIES, LOWERING_RUNS_LAST };
432
+ module.exports = { HOST_PRIMITIVES };
@@ -31,5 +31,3 @@ export interface IHostPrimitive {
31
31
  }
32
32
 
33
33
  export declare const HOST_PRIMITIVES: Readonly<Record<string, IHostPrimitive>>;
34
- export declare const REFUSAL_CATEGORIES: Readonly<Record<string, string>>;
35
- export declare const LOWERING_RUNS_LAST: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/components",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Framework-agnostic component logic (state machines + render functions) for SymbioteNative — written once, inherited by every adapter (React, Vue, Angular, ...).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,15 +40,6 @@
40
40
  "types": "./host-primitives.d.cts",
41
41
  "default": "./host-primitives.cjs"
42
42
  },
43
- "./specialize-state-style": "./specialize-state-style.cjs",
44
- "./lowering-fixtures": {
45
- "types": "./lowering-fixtures.d.cts",
46
- "default": "./lowering-fixtures.cjs"
47
- },
48
- "./state-style": {
49
- "types": "./build/state-style.d.ts",
50
- "default": "./build/state-style.js"
51
- },
52
43
  "./fold-host-bag": {
53
44
  "types": "./build/fold-host-bag.d.ts",
54
45
  "default": "./build/fold-host-bag.js"
@@ -56,27 +47,27 @@
56
47
  "./resolve-intrinsic": {
57
48
  "types": "./build/resolve-intrinsic.d.ts",
58
49
  "default": "./build/resolve-intrinsic.js"
50
+ },
51
+ "./register": {
52
+ "types": "./build/register.d.ts",
53
+ "default": "./build/register.js"
59
54
  }
60
55
  },
61
56
  "files": [
62
57
  "build",
63
58
  "host-primitives.cjs",
64
- "host-primitives.d.cts",
65
- "specialize-state-style.cjs",
66
- "specialize-state-style.d.cts",
67
- "lowering-fixtures.cjs",
68
- "lowering-fixtures.d.cts"
59
+ "host-primitives.d.cts"
69
60
  ],
70
61
  "publishConfig": {
71
62
  "access": "public"
72
63
  },
73
64
  "peerDependencies": {
74
65
  "react-native": ">=0.86",
75
- "@symbiote-native/engine": "^0.4.0"
66
+ "@symbiote-native/engine": "^0.5.0"
76
67
  },
77
68
  "devDependencies": {
78
69
  "@types/node": "^26.0.0",
79
- "@symbiote-native/engine": "0.4.0"
70
+ "@symbiote-native/engine": "0.5.0"
80
71
  },
81
72
  "scripts": {
82
73
  "typecheck": "tsc --build",
@@ -1,15 +0,0 @@
1
- export interface IPressStateArgument {
2
- pressed: boolean;
3
- }
4
- export interface IResolvedStateStyle {
5
- style: unknown;
6
- activeStyle: unknown;
7
- }
8
- /**
9
- * Splits an authored `style` into its resting and pressed halves, reading the value once.
10
- *
11
- * A non-callback passes through untouched with no active variant, which the engine reads as "leave
12
- * slot 1 alone" — that is what makes one emission safe for an expression whose value cannot be
13
- * known at compile time.
14
- */
15
- export declare function resolveStateStyle(value: unknown): IResolvedStateStyle;