@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,47 +0,0 @@
1
- // The runtime half of state-style lowering: splits an authored `style` into its resting and pressed
2
- // halves. Referenced by the code every lowering transform EMITS, never by app code.
3
- //
4
- // SHARED for the same reason `HOST_PRIMITIVES` and `specializeStateStyle` are — two adapters had
5
- // written byte-identical copies within an hour of each other, which is the duplication
6
- // `<adapters_stay_thin>` exists to stop. Each adapter re-exports it from its own `./state-style`
7
- // subpath so the emitted import specifier stays inside the package the app already depends on.
8
- //
9
- // WHY A HELPER EXISTS BESIDE THE INLINE GUARD, since a transform may emit either. Building the pair
10
- // needs the authored `style` expression, and the two emissions are NOT equivalent:
11
- //
12
- // inline typeof e === 'function' ? e({pressed:false}) : e prints `e` on both props, so a
13
- // `getStyle()` runs twice and a `flag ? a : b` can take DIFFERENT branches
14
- // helper resolveStateStyle(e) prints `e` once and calls its
15
- // RESULT twice
16
- //
17
- // The rule the two must satisfy is `REFUSAL_CATEGORIES.emitStyleExpressionOnce`, and it is about
18
- // the output rather than the input: **an expression capable of DOING WORK is printed once.** A bare
19
- // name or a non-computed dotted path is a read, not work, so the inline form is legitimate there —
20
- // and it matters, because a helper returns one object, which a framework must spread, and a spread
21
- // costs the element its patch flag (Vue measured `12 /* STYLE, PROPS */` becoming
22
- // `16 /* FULL_PROPS */` plus a `mergeProps` per render on the hottest element in the tree).
23
- //
24
- // So the VERDICT is identical across adapters — every shape lowers — and only the cost differs,
25
- // exactly as compile-time substitution differs between a JSX path and an SFC one. The verdicts are
26
- // what `@symbiote-native/components/lowering-fixtures` pins.
27
- //
28
- // THE CONTRACT THIS IMPOSES ON APP CODE: a style callback must be PURE in `pressed`. Its result is
29
- // invoked twice under every emission, so a side-effecting body is observable now.
30
- function isStyleCallback(value) {
31
- return typeof value === 'function';
32
- }
33
- /**
34
- * Splits an authored `style` into its resting and pressed halves, reading the value once.
35
- *
36
- * A non-callback passes through untouched with no active variant, which the engine reads as "leave
37
- * slot 1 alone" — that is what makes one emission safe for an expression whose value cannot be
38
- * known at compile time.
39
- */
40
- export function resolveStateStyle(value) {
41
- if (!isStyleCallback(value))
42
- return { style: value, activeStyle: undefined };
43
- return {
44
- style: value({ pressed: false }),
45
- activeStyle: value({ pressed: true }),
46
- };
47
- }
@@ -1,25 +0,0 @@
1
- import type { IStyleProp, IViewStyle, ISymbioteEvent } from '@symbiote-native/engine';
2
- import type { IDescriptor } from '../descriptor';
3
- import type { IAccessibilityProps, IAriaProps } from '../accessibility-props';
4
- export type IActivityIndicatorSize = 'small' | 'large' | number;
5
- export interface IActivityIndicatorProps extends IAccessibilityProps, IAriaProps {
6
- animating?: boolean;
7
- color?: string;
8
- size?: IActivityIndicatorSize;
9
- hidesWhenStopped?: boolean;
10
- style?: IStyleProp<IViewStyle>;
11
- onLayout?: (event: ISymbioteEvent) => void;
12
- }
13
- export type IActivityIndicatorViewProps = {
14
- animating: boolean;
15
- hidesWhenStopped: boolean;
16
- size: IActivityIndicatorSize;
17
- color?: string;
18
- style?: IStyleProp<IViewStyle>;
19
- passthrough: Record<string, unknown>;
20
- };
21
- export type IActivityIndicatorPlatform = {
22
- defaultColor: string | null;
23
- nativeExtras: Readonly<Record<string, unknown>>;
24
- };
25
- export declare function renderActivityIndicator(view: IActivityIndicatorViewProps, platform: IActivityIndicatorPlatform): IDescriptor;
@@ -1,88 +0,0 @@
1
- // ACTIVITYINDICATOR IS NOT A LOWERABLE PRIMITIVE, AND WILL NOT BECOME ONE. Decided 2026-09-01.
2
- // It stays a component in every adapter; do not add it to `HOST_PRIMITIVES`.
3
- //
4
- // The reason is the `el('symbiote-view', wrapperProps, [el('symbiote-activity-indicator', …)])` at
5
- // the bottom of this file: the render SYNTHESIZES a node that is not the primitive itself. A lowered
6
- // tag is ONE engine node, and a host behavior's `foldPayload` maps props to props on that node — it
7
- // cannot create a child. Lowering would therefore drop the centering container and change layout,
8
- // which is an optimisation moving the observable surface, the one thing this design may not do.
9
- //
10
- // The wrapper is not over-building: RN's own `ActivityIndicator.js:112` renders a `<View>` around
11
- // the native spinner too, so the two nodes are inherent. And the container cannot be folded INTO the
12
- // spinner — it carries `alignItems`/`justifyContent`, which centre the spinner inside the space it
13
- // was given; moved onto the spinner they would centre its children, of which it has none.
14
- //
15
- // The two alternatives were priced and both cost more than the primitive is worth. A behavior that
16
- // CREATES a node needs a commit hook, i.e. a machine, i.e. a `-managed` tag split — a new category,
17
- // not a fold. Synthesising the container inside the engine's commit walk is NOT the `RCTVirtualText`
18
- // precedent it resembles: `viewNameFor` changes what one node IS and never ADDS one, so adding one
19
- // would make the retained and committed trees disagree on node count — which every counter, census
20
- // probe and benchmark in this repo assumes. Spinners are rare on a screen, so the per-instance cost
21
- // lowering removes is not measurably paid here; the objection to leaving it is uniformity, not speed.
22
- //
23
- // Under half A this costs an app NOTHING: `View` and `ActivityIndicator` are both ordinary imports
24
- // from the adapter barrel, so which one is internally a tag and which a component is invisible at
25
- // the call site. The general rule is in `.claude/rules/host-primitive-tier.md`.
26
- //
27
- // ActivityIndicator: the render half (framework-agnostic). RN wraps the native spinner
28
- // in a centering View and translates `size` in JS: 'small'/'large' map to a native size
29
- // enum AND a fixed box style; a numeric size never reaches native (it sizes the spinner
30
- // via style only). That translation is platform-invariant and lives here.
31
- //
32
- // What IS platform-specific: Android's AndroidProgressBar needs
33
- // `styleAttr` (which triggers its setStyle(), without it the view throws "setStyle() not
34
- // called") plus `indeterminate: true`, and its default color is the theme (null), whereas
35
- // iOS's ActivityIndicatorView takes neither and defaults to GRAY. The adapter's per-host
36
- // file supplies those bits via `platform`.
37
- import { dlog } from '@symbiote-native/engine';
38
- import { el } from '../descriptor.js';
39
- // Fixed pixel boxes RN gives the two named sizes (styles.sizeSmall/sizeLarge).
40
- const SIZE_SMALL_PX = 20;
41
- const SIZE_LARGE_PX = 36;
42
- // Centering wrapper RN puts around the spinner (styles.container).
43
- const CONTAINER_STYLE = {
44
- alignItems: 'center',
45
- justifyContent: 'center',
46
- };
47
- function resolveSize(size) {
48
- if (size === 'small') {
49
- return {
50
- sizeStyle: { width: SIZE_SMALL_PX, height: SIZE_SMALL_PX },
51
- sizeProp: 'small',
52
- };
53
- }
54
- if (size === 'large') {
55
- return {
56
- sizeStyle: { width: SIZE_LARGE_PX, height: SIZE_LARGE_PX },
57
- sizeProp: 'large',
58
- };
59
- }
60
- return { sizeStyle: { width: size, height: size } };
61
- }
62
- export function renderActivityIndicator(view, platform) {
63
- const { sizeStyle, sizeProp } = resolveSize(view.size);
64
- dlog(sizeProp !== undefined
65
- ? `ActivityIndicator size '${sizeProp}' -> native size enum '${sizeProp}'`
66
- : `ActivityIndicator size ${String(view.size)} -> style only, native size not set`);
67
- const nativeProps = {
68
- animating: view.animating,
69
- hidesWhenStopped: view.hidesWhenStopped,
70
- style: sizeStyle,
71
- ...platform.nativeExtras,
72
- };
73
- // Omit color entirely when neither given nor defaulted (Android's theme default is
74
- // null); a null color prop would be rejected by Fabric's color parser.
75
- const resolvedColor = view.color ?? platform.defaultColor;
76
- if (resolvedColor !== null)
77
- nativeProps.color = resolvedColor;
78
- if (sizeProp !== undefined)
79
- nativeProps.size = sizeProp;
80
- dlog('ActivityIndicator -> RCTView(spinner)');
81
- const wrapperProps = {
82
- ...view.passthrough,
83
- style: [CONTAINER_STYLE, view.style],
84
- };
85
- return el('symbiote-view', wrapperProps, [
86
- el('symbiote-activity-indicator', nativeProps),
87
- ]);
88
- }
@@ -1,9 +0,0 @@
1
- import { type IStyleProp, type IViewStyle } from '@symbiote-native/engine';
2
- import { type IDescriptor } from '../descriptor';
3
- import { type IImageViewProps } from './render-image';
4
- export type IImageBackgroundViewProps = {
5
- style?: IStyleProp<IViewStyle>;
6
- imageStyle?: IStyleProp<IViewStyle>;
7
- image: IImageViewProps;
8
- };
9
- export declare function renderImageBackground(view: IImageBackgroundViewProps): IDescriptor;
@@ -1,48 +0,0 @@
1
- // ImageBackground: the render half (framework-agnostic). Pure JS composition, no native
2
- // component of its own (mirrors react-native/Libraries/Image/ImageBackground.js): an outer
3
- // View receives the wrapper `style`; an absolutely-filled Image sits behind it; the user's
4
- // `children` paint on top (as siblings AFTER the image in the wrapper's child order, injected
5
- // by the adapter). Shared verbatim across adapters: React and Vue both bridge this Descriptor.
6
- //
7
- // The inner Image is positioned absolute-fill and has the wrapper's width/height reapplied:
8
- // RN's Image overwrites its own width/height from the source's intrinsic size, which would
9
- // fight the wrapper's explicit dimensions, so we proxy them back onto the Image so it fills
10
- // the box. `imageStyle` wins last.
11
- import { dlog, flattenStyle, } from '@symbiote-native/engine';
12
- import { el } from '../descriptor.js';
13
- import { renderImage } from './render-image/index.js';
14
- // The inner Image's positioning: absolute-fill behind the wrapper's children.
15
- const IMAGE_BACKGROUND_ABSOLUTE_FILL = {
16
- position: 'absolute',
17
- left: 0,
18
- right: 0,
19
- top: 0,
20
- bottom: 0,
21
- };
22
- // Read one explicit dimension off the (already-flattened) wrapper style. A dp number or a
23
- // percentage string is a valid IDimensionValue; anything else (auto / undefined) yields undefined.
24
- function readDimension(style, key) {
25
- const value = Object.hasOwn(style, key) ? Reflect.get(style, key) : undefined;
26
- if (typeof value === 'number' || typeof value === 'string')
27
- return value;
28
- return undefined;
29
- }
30
- export function renderImageBackground(view) {
31
- // Flatten only to read the wrapper's explicit dimensions; RN copies these onto the Image so
32
- // it fills the box rather than collapsing to the source's intrinsic size. `imageStyle` last.
33
- const flattenedWrapper = flattenStyle(view.style);
34
- const imageMergedStyle = [
35
- IMAGE_BACKGROUND_ABSOLUTE_FILL,
36
- {
37
- width: readDimension(flattenedWrapper, 'width'),
38
- height: readDimension(flattenedWrapper, 'height'),
39
- },
40
- view.imageStyle,
41
- ];
42
- dlog('ImageBackground -> View(RCTView) > Image(RCTImageView absolute-fill) + children');
43
- // The wrapper View holds the inner Image as its only structural child; the adapter appends
44
- // the user children after it (so they paint on top).
45
- return el('symbiote-view', { style: view.style }, [
46
- renderImage({ ...view.image, style: imageMergedStyle }),
47
- ]);
48
- }
@@ -1,259 +0,0 @@
1
- // The shared VERDICT table for host-primitive lowering — the fifth parity surface named in
2
- // `.claude/rules/adapter-parity-audit.md`.
3
- //
4
- // WHY A TABLE OF CASES AND NOT SHARED CODE. Four transforms implement one rule set —
5
- // `adapters/solid/babel-lower-host-primitives.cjs`, `adapters/vue/babel-lower-host-primitives.cjs`,
6
- // `adapters/vue/metro-vue-transformer.cjs`, `adapters/svelte/src/preprocessor/lower-host-
7
- // primitives.ts` — over three different plumbings: a Babel plugin holding a real AST, an SFC
8
- // transform handed SOURCE TEXT, and a Svelte preprocessor that reads ESTree and emits text. They
9
- // share a spec (`host-primitives.cjs`) and a specialiser (`specialize-state-style.cjs`), and
10
- // sharing those proves NOTHING about the answer each one gives — which is the whole gap. So what is
11
- // shared here is the QUESTION and the EXPECTED ANSWER; the snippet that asks it stays per-framework,
12
- // because `<View {...spread}>` and `<View v-bind="x">` are the same case in two syntaxes.
13
- //
14
- // TWO LEVELS, ONE VERDICT. Coverage is decided by INVOCATION — the style callback is called once
15
- // per state and both results ride the bag as `style` + `activeStyle`, which is the same answer on
16
- // all five adapters. Compile-time SUBSTITUTION (`specialize-state-style.cjs`) is an optimisation a
17
- // transform applies when it can prove the body, saving a closure allocation; it never changes a
18
- // verdict. So every row below is an equality across adapters even though what each one costs
19
- // differs. A transform reporting `refuse` on a row the specialiser cannot prove has wired
20
- // substitution as the MECHANISM rather than as the optimisation, and that is the drift this table
21
- // exists to catch.
22
- //
23
- // A REFUSAL CATEGORY CAN BELONG TO THE TRANSFORM RATHER THAN TO THE LANGUAGE, and
24
- // `REFUSAL_CATEGORIES.emitStyleExpressionOnce` is the worked example — and it carries that name
25
- // because it was FIRST written as a refusal called `unrepeatableRead`, then corrected to a
26
- // requirement on the output. It names `style={getStyle()}`,
27
- // `style={bag[i]}`, `style={flag ? a : b}` — expressions that change meaning when read twice. But
28
- // the pair is only built by reading TWICE if the transform prints the expression twice, which an
29
- // inline guard (`typeof f === 'function' ? f({pressed}) : f`) does and a runtime helper
30
- // (`resolveStateStyle(expr)`) does not: the helper reads the expression ONCE and calls its RESULT
31
- // twice. Measured 2026-08-23 — Svelte lowers all three with exactly one read in the emitted text;
32
- // Vue refused them while emitting the guard inline.
33
- //
34
- // So a row asserting `refuse` there would encode one transform's emit shape as a shared law and
35
- // cost every other transform real coverage. Rows for these shapes belong in the table as `lower`,
36
- // with "emit the expression once" stated as the requirement — which makes a double-reading
37
- // transform FAIL the table instead of being ratified by it. The general form: before adding a
38
- // refusal row, ask whether the hazard survives a different emit. If it does not, the row belongs
39
- // to the emit.
40
- //
41
- // THE CONTRACT INVOCATION IMPOSES, stated because it is now observable: a style callback must be
42
- // PURE in `pressed`. It is executed twice. A side-effecting body was already broken under
43
- // substitution, but only invocation runs it.
44
- //
45
- // A `refuse` ROW IS UNPROVEN UNTIL A CONTROL ON THE SAME PRIMITIVE GOES THE OTHER WAY. `refuse` is
46
- // the ABSENCE of an observation, and "no intrinsic in the output" is produced equally by a
47
- // transform that refused and by a primitive nothing can lower — an unimported component, a
48
- // hardcoded default element, an entry withheld from the spec while its runtime half is wired.
49
- // Withholding is deliberate practice here, so the ambiguous state recurs by design. Measured
50
- // 2026-08-31: the `TextInput` entry was withdrawn and both `intrinsic-choice-*` rows went GREEN on
51
- // all three runners, in three different mechanisms. So a runner asserts a shape that MUST lower
52
- // before it reads a refusal — and the control's failure message says the row cannot distinguish
53
- // the two, which is the true state. Full account: `.claude/rules/adapter-parity-audit.md`.
54
- //
55
- // WHY IT MATTERS THAT NO OTHER AUDIT SEES THIS. Barrels, subpaths, `files` coverage and exported
56
- // symbols are all untouched by a transform that lowers a call site its sibling refuses. Every suite
57
- // stays green and the divergence surfaces either as one adapter being mysteriously slower, or as a
58
- // button that lowered when it should not have and therefore does not respond.
59
-
60
- /**
61
- * @typedef {'lower' | 'refuse'} IVerdict
62
- * @typedef {{ id: string, what: string, expected: IVerdict, why: string }} ILoweringCase
63
- */
64
-
65
- /**
66
- * Every case a lowering transform must answer the same way. Each adapter's test supplies its own
67
- * snippet per `id` and asserts `expected`; a case with no snippet is itself a failure, so adding a
68
- * row here forces every transform to declare where it stands.
69
- * @type {ReadonlyArray<ILoweringCase>}
70
- */
71
- const LOWERING_CASES = [
72
- {
73
- id: 'inert-object-style',
74
- what: 'style is an object literal',
75
- expected: 'lower',
76
- why: 'provably not a function, so the template cannot be reading press state through it',
77
- },
78
- {
79
- id: 'hoisted-identifier-style',
80
- what: 'style is a bare identifier that may hold an object OR a function',
81
- expected: 'lower',
82
- why: "the compile-time allow-list could never decide this one, which is why it used to refuse. INVOCATION decides it at runtime instead — `typeof f === 'function' ? f({pressed}) : f` — so the shape that no substitution can prove becomes the shape that needs no proof",
83
- },
84
- {
85
- id: 'specialisable-state-style',
86
- what: 'style is an arrow taking { pressed } and returning one object literal',
87
- expected: 'lower',
88
- why: 'INVOCATION covers it — the callback is called once per state and both results ride the bag as style + activeStyle. `specialize-state-style.cjs` is an OPTIMISATION on top for the bodies a transform can prove, saving the closure; it never changes the verdict',
89
- },
90
- {
91
- id: 'nested-function-state-style',
92
- what: 'the state style body contains another function',
93
- expected: 'lower',
94
- why: 'the SPECIALISER refuses this body — it cannot prove a nested function — but the verdict is set by invocation, which does not need to prove anything. The case stays in the table precisely because it separates the two levels: a transform that reports `refuse` here has wired substitution as the mechanism instead of as the optimisation',
95
- },
96
- {
97
- id: 'call-expression-style',
98
- what: 'style is a call expression',
99
- expected: 'lower',
100
- why: "`REFUSAL_CATEGORIES.emitStyleExpressionOnce` — safe exactly when the transform prints the expression ONCE and calls the RESULT twice. A transform printing an inline guard repeats it and runs the author's call once per copy per recompute; that is its emit to fix, not a shape to ban. Assert `occurrences(out, expr) === 1` on the output",
101
- },
102
- {
103
- id: 'computed-member-style',
104
- what: 'style is a computed member expression',
105
- expected: 'lower',
106
- why: 'same requirement as the call expression, and the index is the tell: printed twice, `bag[i]` is evaluated twice',
107
- },
108
- {
109
- id: 'conditional-style',
110
- what: 'style is a conditional expression',
111
- expected: 'lower',
112
- why: 'same requirement, and the sharpest failure of breaking it: printed twice, the two reads are free to take DIFFERENT branches, so the resting and pressed halves come from unrelated objects',
113
- },
114
- {
115
- id: 'zero-arity-child',
116
- what: 'children take no parameter',
117
- expected: 'lower',
118
- why: 'an ordinary lazy child, not a render prop. On Svelte EVERY child is a snippet whether the author wrote one or not, so arity is the only thing separating the two',
119
- },
120
- {
121
- id: 'render-prop-child',
122
- what: 'children take a parameter',
123
- expected: 'refuse',
124
- why: 'the parameter is the press state, and tier 2 resolves that state BELOW the framework where the template cannot read it back',
125
- },
126
- {
127
- id: 'spread-attributes',
128
- what: 'the element carries a spread',
129
- expected: 'refuse',
130
- why: 'the attribute set cannot be enumerated, and a half-read set is a silently wrong render',
131
- },
132
- // THE ROW THAT PROVES A CATEGORY IS A DICTIONARY, NOT AN ENFORCEMENT POINT.
133
- //
134
- // `REFUSAL_CATEGORIES.bagFold` said an element carrying `role` / `aria-*` must refuse, because
135
- // the fold needs the whole bag and a transform reads one attribute at a time. Measured
136
- // 2026-08-31, ONE of the four transforms implemented it — Solid. Vue's two lowered such elements
137
- // and Svelte's preprocessor does not contain the string `role` at all.
138
- //
139
- // That was not a dead refusal, it was a live defect: a lowered `aria-label` reached Fabric as a
140
- // key no ViewConfig knows, so the accessibility LABEL was silently dropped on device. A category
141
- // written in the shared spec binds nobody — each transform separately decides to consult it, and
142
- // not consulting it breaks nothing visible. Only a ROW here makes a divergence red.
143
- //
144
- // The verdict is `lower` because the fold now runs in the engine (`core/engine/src/
145
- // accessibility-props.ts`, called from `fabricProps` — the one point where the whole bag is known
146
- // on every commit path), so the reason to refuse is gone for every adapter at once. A transform
147
- // still refusing is not being safe, it is losing coverage on the props real apps write.
148
- // THE TAG CHOICE ITSELF IS DELIBERATELY NOT A ROW, and the reason is this table's own admission
149
- // test (`.claude/rules/adapter-parity-audit.md`). A row's verdict vocabulary is `lower` /
150
- // `refuse`; it cannot say WHICH intrinsic was emitted. So a row asserting that
151
- // `<TextInput multiline />` lowers would pass against a transform emitting the single-line tag —
152
- // and `symbiote-text-input` is a PREFIX of `symbiote-text-input-multiline`, so even a
153
- // hand-written `toContain` check reads the wrong one as right. The tag choice is pinned by each
154
- // adapter's own test, where the emitted text is available; all three carry one.
155
- //
156
- // What the table CAN decide is the refusal, and that is what these two rows are for: a dynamic
157
- // selector must refuse on every transform, or one of them commits the wrong NATIVE VIEW — an
158
- // error no later prop write can correct, unlike a merely wrong prop value.
159
- {
160
- id: 'intrinsic-choice-dynamic',
161
- what: 'the intrinsic-selecting prop is a runtime value',
162
- expected: 'refuse',
163
- why: 'a transform prints a static tag, so a selector it cannot resolve at compile time leaves it guessing which native view to commit',
164
- },
165
- {
166
- id: 'intrinsic-choice-nonboolean-literal',
167
- what: 'the intrinsic-selecting prop is a truthy non-boolean literal',
168
- expected: 'refuse',
169
- why: 'the boundary is IDENTITY, not truthiness — a type-shaped check waves `multiline={1}` through and commits the multiline view for an author who wrote a number',
170
- },
171
- {
172
- id: 'aria-bag-fold',
173
- what: 'the element carries role / aria-* attributes',
174
- expected: 'lower',
175
- why: 'the fold moved to the engine, so a lowered element gets it too — a transform that still refuses only costs coverage, and three of the four never refused in the first place',
176
- },
177
- // THE VERDICT IS RIGHT AND THE REASON THIS ROW SHIPPED WITH WAS FALSE — kept as a comment because
178
- // the correction is the more useful half. It read "a lowered element has no component instance
179
- // for the binding to target", which assumes the binding targeted one before. It did not: NO
180
- // adapter exposes a public `ref` on `Pressable`. React's `ref: viewRef`
181
- // (`components/pressable/index.ts:204`) is internal, handed to the inner View so the machine can
182
- // measure its retention region; Vue and Svelte declare none, and Solid's own props type says so
183
- // out loud. So `ref={handle}` on an un-lowered `<Pressable>` does nothing at all.
184
- //
185
- // What lowering does is therefore not to BREAK the binding but to ADD one — the intrinsic hands
186
- // back a live engine node. That is the actual hazard, and it is worse than the stated one: the
187
- // capability would exist only when the transform happened to lower, so an unrelated attribute
188
- // elsewhere on the tag would decide whether an app's `ref` works. A surface that flickers with a
189
- // compiler's verdict is harder to reason about than one that is absent everywhere.
190
- //
191
- // Hence the rule this row now stands on, which generalises past `ref`: A LOWERING TRANSFORM IS AN
192
- // OPTIMISATION, AND AN OPTIMISATION THAT CHANGES THE OBSERVABLE SURFACE — IN EITHER DIRECTION — IS
193
- // A BUG. Refusing keeps lowered and un-lowered call sites indistinguishable to an app. If
194
- // `Pressable` is later given a public ref, it is given one on all five adapters by design
195
- // (`<adapters_reach_full_feature_parity>`), and only then can this row be revisited.
196
- //
197
- // Found by the Solid session 2026-08-30, when Solid's newly-added runner answered `lower` here
198
- // and the investigation went looking for the instance the row assumed.
199
- {
200
- id: 'instance-bound-directive',
201
- what: 'the element carries a directive binding the component instance',
202
- expected: 'refuse',
203
- why: 'lowering must not change the observable surface: no adapter exposes a public ref on Pressable, so lowering would ADD one that appears only when the transform happens to lower',
204
- },
205
- // The first FOLD-ONLY primitive, and the row exists because a transform genuinely decides it: an
206
- // entry can be present in the spec and still be dropped between the file and the transform's own
207
- // projection (`spec-projection-covers-fields.test.ts` exists because `intrinsicWhen` was), in
208
- // which case the tag is simply never recognised and the element stays a component. Nothing else
209
- // in this table would catch that.
210
- //
211
- // No attribute of its own on purpose: what is under test is that the NAME is recognised, not any
212
- // rule about a prop. A row that needed an attribute would be testing two things at once.
213
- {
214
- id: 'image-fold-only',
215
- what: 'a fold-only primitive with no attribute worth refusing',
216
- expected: 'lower',
217
- why: 'the spec carries the entry and the behavior carries the fold, so every transform should recognise the tag; a refusal here means the entry never reached this transform projection',
218
- },
219
- // A SECOND fold-only name, and it is not a duplicate of the row above. What that one proves is
220
- // that a transform's spec projection works at all; what this proves is that it is driven by the
221
- // spec rather than by a hardcoded list of names — the shape `adapters/angular` shipped for months
222
- // (`LOWERABLE_NAMES = ['View', 'Text']`). A transform can pass `image-fold-only` and fail here.
223
- {
224
- id: 'input-accessory-view-fold-only',
225
- what: 'a second fold-only primitive, proving the name list is read rather than written',
226
- expected: 'lower',
227
- why: 'nothing about this primitive is refusable — no state, no intrinsic choice, no aliasing — so a refusal can only mean the transform never saw the entry',
228
- },
229
- // A THIRD name, and it is not fold-only the way the two above are — it carries a real ENGINE
230
- // machine (mirrors the last value native reported, sends a platform snap-back command on
231
- // disagreement). What decides a TRANSFORM's verdict is `observesState`/`intrinsicWhen`, neither
232
- // of which this entry sets (its public surface has no function-valued style and no dynamic
233
- // intrinsic choice), so from a transform's perspective it is answered exactly like a fold-only
234
- // primitive — the machine is an engine-side fact, invisible to this row. `thumbColor` is a
235
- // CONSUMED alias (folds to `thumbTintColor`), same rationale as Image's `alt`.
236
- {
237
- id: 'switch-fold-only',
238
- what: 'a third name with an engine machine but no compile-time refusal — the verdict does not depend on it',
239
- expected: 'lower',
240
- why: 'no observesState, no intrinsicWhen — a refusal here can only mean the transform never saw the entry, same as the two fold-only rows above',
241
- },
242
- ];
243
-
244
- // NO `safe-area-view` ROW, deliberately, and the reason belongs here or the next reader files it as
245
- // a coverage gap. SafeAreaView is a third fold-only primitive, and the two rows above already spend
246
- // that question: one proves a transform's spec projection works at all, the other proves the name
247
- // list is READ rather than written. A third attribute-free name is answered identically by every
248
- // implementation, including a broken one — the admission test in
249
- // `.claude/rules/adapter-parity-audit.md` ("could a transform get this wrong?") says that is a row
250
- // which proves nothing.
251
- //
252
- // It does carry one thing genuinely new — it is the first entry declaring `aliases: {}`, so a
253
- // transform that folded `id -> nativeID` unconditionally would be wrong on it. That is NOT
254
- // expressible here: this table's verdict is lower/refuse, and both the right and the wrong transform
255
- // LOWER. It needs a payload oracle, which is per-adapter by construction; Svelte's is the pair in
256
- // `preprocessor/lower-host-primitives.test.ts` ("folds no alias on a primitive whose spec declares
257
- // none", with the control beside it).
258
-
259
- module.exports = { LOWERING_CASES };
@@ -1,17 +0,0 @@
1
- // Hand-written types for the loose `.cjs` beside this file — Babel/Metro transforms cannot import
2
- // `.ts`, so the table stays CommonJS and its shape is declared here.
3
-
4
- export type IVerdict = 'lower' | 'refuse';
5
-
6
- export interface ILoweringCase {
7
- /** Stable key each adapter's test maps to its own snippet. */
8
- id: string;
9
- /** One line naming the construct, for a failure message that reads without the table open. */
10
- what: string;
11
- /** The answer every transform must give. */
12
- expected: IVerdict;
13
- /** Why that is the answer — the reasoning a transform author needs, not a restatement. */
14
- why: string;
15
- }
16
-
17
- export declare const LOWERING_CASES: ReadonlyArray<ILoweringCase>;