@colixsystems/widget-sdk 0.207.0 → 0.212.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 (42) hide show
  1. package/README.md +97 -2
  2. package/dist/checkbox-view.js +150 -0
  3. package/dist/checkbox.js +11 -0
  4. package/dist/checkbox.native.js +7 -0
  5. package/dist/contract.cjs +78 -4
  6. package/dist/contract.js +78 -4
  7. package/dist/form-control-logic.js +55 -0
  8. package/dist/form-control-tokens.js +121 -0
  9. package/dist/form-control-view.js +98 -0
  10. package/dist/hooks.js +13 -0
  11. package/dist/host.d.ts +46 -0
  12. package/dist/host.js +4 -0
  13. package/dist/index.d.ts +230 -6
  14. package/dist/index.js +11 -1
  15. package/dist/index.native.js +11 -1
  16. package/dist/markdown-input-view.js +2 -3
  17. package/dist/markdown-input.js +4 -2
  18. package/dist/markdown-input.native.js +4 -2
  19. package/dist/markdown-link-dialog.js +94 -108
  20. package/dist/modal-tokens.js +107 -0
  21. package/dist/modal-view.js +255 -0
  22. package/dist/modal.js +8 -0
  23. package/dist/modal.native.js +45 -0
  24. package/dist/overlay-tokens.js +69 -5
  25. package/dist/overlay-view.js +52 -16
  26. package/dist/overlay.native.js +8 -1
  27. package/dist/search-field-view.js +155 -0
  28. package/dist/search-field.js +13 -0
  29. package/dist/search-field.native.js +9 -0
  30. package/dist/select-view.js +312 -0
  31. package/dist/select.js +15 -0
  32. package/dist/select.native.js +9 -0
  33. package/dist/surface-theme.js +108 -0
  34. package/dist/switch-view.js +149 -0
  35. package/dist/switch.js +80 -0
  36. package/dist/switch.native.js +7 -0
  37. package/dist/text-field-view.js +54 -8
  38. package/dist/text-field.js +6 -1
  39. package/dist/text-field.native.js +3 -3
  40. package/dist/web-modal.js +24 -5
  41. package/package.json +1 -1
  42. package/src/contract.js +78 -4
package/README.md CHANGED
@@ -83,7 +83,100 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
83
83
 
84
84
  ## Status
85
85
 
86
- `v0.207.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
86
+ `v0.212.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
87
+
88
+ ### What's new in 0.212.0 (contract 1.171.0)
89
+
90
+ **Standard form controls: `Checkbox`, `Switch`, `Select` and `SearchField`.** Only text entry had a themed component, so every widget drew its own tick boxes, toggles, dropdowns and search boxes. Four controls now sit beside `TextField`, each one view bound to both hosts, and all five share one frame: `label` draws the control's label and names it for a screen reader (pressing a field's label focuses it), `helper` a hint below, `error` a message in the hint's place that also marks the control (`true` marks it without a message), and `disabled` stops input.
91
+
92
+ ```jsx
93
+ <SearchField value={query} onChangeText={setQuery} placeholder="Search orders" />
94
+ <Select
95
+ label="Status"
96
+ value={status}
97
+ onValueChange={setStatus}
98
+ options={[{ value: "open", label: "Open" }, { value: "done", label: "Done" }]}
99
+ />
100
+ <Checkbox label="Urgent only" value={urgent} onValueChange={setUrgent} />
101
+ <Switch label="Notify me" value={notify} onValueChange={setNotify} />
102
+ ```
103
+
104
+ **What existing widgets will look different.** `Switch` was already exported, as React Native's switch with the platform's default colours. It is now themed, so every existing `<Switch>` that passes no colours is repainted when this ships, in published and generated widgets alike:
105
+
106
+ | | Before | Now |
107
+ | --- | --- | --- |
108
+ | Web, off track | `#939393` | the theme's border colour |
109
+ | Web, on track | `#A3D3CF` | the theme's primary colour |
110
+ | Web, on thumb | teal `#009688` | white |
111
+ | Web, off thumb | `#FAFAFA` | unchanged |
112
+ | Web, disabled | the host's grey track and thumb | the themed colours at half opacity |
113
+ | iOS and Android, tracks | the platform's defaults | the theme's border (off) and primary (on) |
114
+ | iOS and Android, thumb | the platform's default | unchanged |
115
+ | iOS and Android, disabled | the platform's greyed switch | unchanged |
116
+
117
+ Three smaller changes reach switches that do pass something:
118
+
119
+ - **Web, `thumbColor` without `activeThumbColor`:** the "on" thumb was the host's teal and is now the `thumbColor` you passed.
120
+ - **Web, a partial `trackColor`** (`{ true: "#0a0" }`, a string, or `activeTrackColor` alone): the state you did not name was the host's default and is now the theme's.
121
+ - **iOS and Android, a truthy non-boolean `value`** (`value={1}`): it was off, because React Native's switch is on only for `true`, and is now on, as it already was on the web.
122
+
123
+ A switch that passes its own colours keeps them, disabled or not: a `trackColor` overrides the theme for the states it names (`{ true: "#0a0" }` still gets the themed off track), the web's `activeTrackColor` and string `trackColor` are still read, and iOS's off colour follows `trackColor.false` unless `ios_backgroundColor` is passed. A disabled switch that brings its own colours is drawn exactly as before on the web, at full strength; only one that left its colours to the theme is dimmed there. Layout does not change: a switch with no `label`, `helper` or `error` renders the bare control. Nothing else that existed before changes appearance; a `TextField` with none of the new props renders exactly what it did.
124
+
125
+ - **`Checkbox`**: `value` / `onValueChange(next)`. One 44px row with the checkbox role and checked state on both hosts; Space toggles it on the web. The box is the input surface and fills with the primary colour when checked. `style` is the row, `containerStyle` the box around a row that shows a helper or error; other props and the ref land on the row.
126
+ - **`Switch`**: the platform's own switch (React Native's `Switch`), so each host keeps its role, state and keys. A device greys a disabled switch itself; on the web a disabled switch is drawn at half strength unless it brings its own colours. With a `label`, `helper` or `error` it draws a row whose label toggles it too.
127
+ - **`Select`**: `options` (`[{ value, label, disabled?, testID? }]`), `value`, `onValueChange(value, option)`, `placeholder`, `title`, `emptyText`. The trigger is the input field, exposed as a button that opens a dialog (`aria-haspopup`, `aria-expanded`). Its options open through `Overlay`, at screen level, each with the option role and a selected state on both hosts (the list around them is a `listbox` on the web only; React Native has no such role), so a card or scroll container cannot clip them, and a long list scrolls inside the sheet. On the web Enter, Space or an arrow key opens it, the arrow keys, Home and End move between options, Enter or Space chooses, Escape closes, and focus returns to the trigger; on Android the back button closes it. Other props and the ref land on the trigger.
128
+ - **`SearchField`**: a `TextField` with a search glyph, the search key on the on-screen keyboard (`enterKeyHint="search"`, overridable) and a clear control that appears once there is text. Clearing calls `onChangeText("")`, then `onClear`, and keeps focus in the field. `style` styles the input and `containerStyle` the box around it.
129
+ - **`TextField`** gains `label`, `helper`, `error`, `disabled` and `containerStyle`. An invalid field keeps its danger border while focused.
130
+ - **`spaceActivates(onPress)`**: the `onKeyDown` handler the controls use to answer Space on the web, for a control you build yourself whose role is not a button. It fires once per key press and is ignored on native.
131
+
132
+ **Theming.** No new scope. All five read **Design > Components > Muted** for the field (background, text colour, border, corner radius, height) and show focus in the primary colour, as `TextField` always has: a field and a Select trigger in their own border, a checkbox and a labelled switch as a 2px ring around the control whose width is always reserved, so focusing moves nothing. A bare switch keeps the host's own focus indication. The Select's option sheet reads **Popover** (background, text colour, border). In the contract, `muted` and `popover` now list these components under `sdkComponents` next to the form widgets they already targeted. The `ring` scope is the Button widget's emphasis border and is not read by these controls.
133
+
134
+ **Known limits.** On a device a long option list does not scroll to the current choice when it opens (the web does, through focus). A helper or error is linked to its control with `aria-describedby` on the web, for all five controls; a device's screen reader reads it in place, and Android announces an error when it appears.
135
+
136
+ The built-in Data List, Files and User Management widgets and the form widgets' user and group pickers now draw their search boxes with `SearchField`.
137
+
138
+ - **`CONTRACT.version` → `1.171.0`** (`Checkbox`, `Select`, `SearchField`, `spaceActivates`; the `TextField` frame props; `sdkComponents` on `muted` and `popover`; and the visible change to `Switch` described above).
139
+ ### What's new in 0.210.0 (contract 1.169.0)
140
+
141
+ **New component `<Modal>` — the standard dialog.** Widgets that opened a dialog each built their own header, close button and action row inside a bare `<Overlay>`, so no two dialogs in an app matched and none could be restyled from the theme. `<Modal>` is that dialog, built once on `<Overlay>`:
142
+
143
+ ```jsx
144
+ import { useState } from "react";
145
+ import { Modal, Button, TextField } from "@colixsystems/widget-sdk";
146
+
147
+ function AddNote({ onSave }) {
148
+ const [open, setOpen] = useState(false);
149
+ const [note, setNote] = useState("");
150
+ return (
151
+ <>
152
+ <Button title="Add note" onPress={() => setOpen(true)} />
153
+ <Modal
154
+ visible={open}
155
+ onRequestClose={() => setOpen(false)}
156
+ title="Add note"
157
+ footer={
158
+ <>
159
+ <Button variant="secondary" title="Cancel" onPress={() => setOpen(false)} />
160
+ <Button title="Save" onPress={() => onSave(note)} />
161
+ </>
162
+ }
163
+ >
164
+ <TextField value={note} onChangeText={setNote} placeholder="Note" accessibilityLabel="Note" />
165
+ </Modal>
166
+ </>
167
+ );
168
+ }
169
+ ```
170
+
171
+ It draws the title, a close button, a body that scrolls when it is long, and a footer action row laid out below it, and it renders outside the widget's layout box on both hosts exactly as `<Overlay>` does. Props: `visible`, `onRequestClose` (the close button, the backdrop press, Escape on web and the Android back button), `title`, `footer` (the action buttons, laid out as one right-aligned row that wraps; omit it for no footer), `presentation` (`dialog`, centred, the default | `sheet`, anchored to the bottom edge), `size` (`sm` | `md` | `lg` | `full`), `dismissOnBackdropPress`, `dismissible` (default true; false hides the close button and stops the backdrop, Escape and Android back from closing a dialog that must stay open, e.g. while it saves), `role` (`dialog` | `alertdialog`, web only), `initialFocusRef` (the control focused on open, web only), `closeAccessibilityLabel` (default `Close`), `accessibilityLabel` (names a dialog with no title) and `testID`. With no `onRequestClose` it draws no close button; with no title either, no header row. Use `dismissible={false}` only while that work runs and always restore it: with no footer button or in-body control, such a dialog cannot be closed. Do not wrap the body in your own `ScrollView`.
172
+
173
+ A dialog that paints its own background hands whatever is rendered inside it a theme re-inked for that background, so SDK components and any component of yours calling `useTheme()` inside the `<Modal>` stay readable; text you style with the theme read *outside* the dialog does not follow, so build the body as its own component. In the exported app the dialog pads a `sheet` by the bottom safe-area inset and a `full` dialog by the top and bottom ones, and wraps its panel in React Native's `KeyboardAvoidingView`. This is not yet verified on a device, and `KeyboardAvoidingView` inside a React Native `Modal` window is a known risk on Android: until it is verified, keep a form with text inputs in the default `dialog` presentation. A bare `<Overlay>` gets neither.
174
+
175
+ **New theme scope `modal`.** A dialog's `background`, `titleColor`, `scrim`, `radius`, `padding` and `shadow` are set app-wide under `themeConfig.components.modal` (Design > Components > Dialogs). `<Modal>` reads the scope itself, so the scope names it under `sdkComponents` and binds no `styleSchema` field. The `scrim` is always painted see-through: an opaque colour takes the default 55% alpha, and no scrim exceeds 85%. With no token set a `<Modal>` sits on exactly the panel and scrim `<Overlay>` has always drawn.
176
+
177
+ **`<Overlay>` gains `presentation`, `role`, `initialFocusRef`, `backdropAccessible` and `testID`, and a themed scrim.** Its `accessibilityLabel` now names the web dialog node itself (the panel still carries it in the exported app). Apart from the scrim, a bare `<Overlay>` renders in the exported app exactly as before. `presentation="sheet"` anchors the panel to the bottom edge; `dialog` (the default) is the centred panel it always was. Its scrim now follows the `modal` scope's `scrim` token, and is unchanged when the token is unset. Keep `<Overlay>` for what is not a dialog: a media or document preview, a lightbox.
178
+
179
+ The `<MarkdownInput>` link form now opens as a `<Modal>`. Hosts get `resolveModalTokens(theme, { size, presentation })`, `modalScopeDefaults(theme)` and `surfaceAwareTheme(theme, backgroundColor)` from `@colixsystems/widget-sdk/host`. Additive.
87
180
 
88
181
  ### What's new in 0.207.0 (contract 1.166.0)
89
182
 
@@ -2457,6 +2550,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
2457
2550
  - `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
2458
2551
  - `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`; `DateTimePicker` wraps `@react-native-community/datetimepicker` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
2459
2552
  - `Overlay` — the screen-level overlay (sc-6607). `<Overlay visible={!!preview} onRequestClose={() => setPreview(null)} size="full">…</Overlay>` renders its children OUTSIDE the widget's layout box on both hosts (web portals it to the document root, native uses the OS modal), so no clipping card, `ScrollView` or neighbouring widget can cut off a document/media preview, lightbox or confirm dialog — which absolute positioning inside the widget cannot achieve on either platform. Children stay in your own React tree, so state and hooks work normally. `onRequestClose` carries the backdrop press, Escape on web, and the Android back button; `size` is `sm` | `md` (default) | `lg` | `full`; the scrim, surface, radius, padding and elevation come from the workspace theme. An anchored dropdown still belongs inside your own root.
2553
+ - `Modal` — the standard dialog, built on `Overlay`. `<Modal visible={open} onRequestClose={() => setOpen(false)} title="Edit order" footer={<Button title="Save" onPress={save} />}>…</Modal>` draws a title, a close button, a body that scrolls when it is long and a footer action row laid out below it. Its background, corner radius, padding, scrim, shadow and title colour come from the theme's `modal` scope (Design > Components > Dialogs), so every dialog in an app matches. Props: `visible`, `onRequestClose`, `dismissible`, `title`, `footer`, `presentation` (`dialog` | `sheet`), `size` (`sm` | `md` | `lg` | `full`), `dismissOnBackdropPress`, `role`, `initialFocusRef`, `closeAccessibilityLabel`, `accessibilityLabel`, `testID`. Use it for every dialog instead of hand-building a header and an action row inside `Overlay`.
2460
2554
  - `RichText` — the formatted-content renderer (sc-6970). `<RichText value={post.body} />` parses the markdown subset (`**bold**`, `*italic*` / `_italic_`, `` `code` ``, `#`/`##`/`###` headings, `-`/`*` bullets, `1.` ordered items, one-line `![alt](src "large")` images) and renders it with SDK primitives, so formatted content reads the same in the web Player and the exported Expo app. `value` is markdown, NOT HTML — widgets have no `dangerouslySetInnerHTML` on either host, so tags in a content string render as literal tag soup; stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. Props: `value`, `renderImage` (`({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving one needs the scopes your own widget holds; without it only absolute `http(s)` URLs render), `style`, `testID`. Colour, type scale and leading come from the workspace theme — never re-style them.
2461
2555
  - `MarkdownInput` — the authoring half of `<RichText>` (sc-6970). `<MarkdownInput value={draft} onChange={setDraft} />` is a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list, each toggling back off), and a live `<RichText>` preview of the result underneath — the way to let an app USER write formatted text, instead of a bare `TextInput` plus hand-rolled buttons that can only splice literal tags into the value. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default `Preview`), `showPreview` (default true), `renderImage` (passed through to the preview), `minHeight` (150) and `maxHeight` (280) so a long draft scrolls inside its box, `accessibilityLabel`, `style`, `testID`.
2462
2556
  - `parseMarkdown`, `markdownToPlainText`, `parseMarkdownImage`, `formatMarkdownImage`, `stripHtmlToMarkdown`, `MARKDOWN_IMAGE_SIZES` — the same grammar as pure helpers, for content you inspect rather than render: the block list, a marker-free projection (list previews, search, a11y labels), the one-line image form read and written, a legacy-HTML row normalised to markdown on read, and the closed size list (`small` | `medium` | `large` | `full`).
@@ -2467,6 +2561,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
2467
2561
  - `Banner` — the themed inline message. `<Banner tone message>` shows an error, warning, success or notice in place, styled by the theme's `alert` scope; `action` adds one button and `onDismiss` a close control. Use `useToast()` for a brief confirmation instead. Never hand-roll a tinted error box.
2468
2562
  - `Accordion`, `AccordionItem` — themed collapsible sections. `<Accordion><AccordionItem title subtitle trailing>…</AccordionItem></Accordion>` draws its surface from the theme's `card` scope and its headers and dividers from the `list` scope; one section open at a time, or `multiple`. Never hand-roll a collapsible from `Pressable` + a chevron.
2469
2563
  - `Spinner`, `Skeleton` — the themed loading states. `<Spinner size label>` for a pending action; `<Skeleton shape="text" | "block" | "circle" | "listRow">` (or a group of bones) for content whose layout you know. The spinner wears `colors.loader`, the skeleton follows the theme's `loading` scope; both stop under reduced motion and mark one busy region. Never use `ActivityIndicator` or hand-build placeholder bars.
2564
+ - `Checkbox`, `Switch`, `Select`, `SearchField` — the themed tick box, on/off toggle, single-choice picker and search box. Like `TextField` they follow the theme's `muted` scope (and `popover` for the Select's option sheet) and take `label`, `helper`, `error` and `disabled`. `Select` opens its options at screen level. Never hand-roll one from `Pressable` and `View`.
2470
2565
  - `Tabs` — the themed tab strip. `<Tabs items value onChange>` draws a segmented control, pills or an underline as the theme's `tabs` scope says; render the selected panel yourself. Never hand-roll tabs from `Pressable` chips.
2471
2566
  - `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
2472
2567
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
@@ -2498,7 +2593,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
2498
2593
  - **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
2499
2594
  - **Answer the touch.** Every tappable card, row and list entry lifts while the pointer is over it (web) or it is pressed (touch). One declaration does both: give the Pressable a style FUNCTION and spread `pressableLift` — `<Pressable onPress={open} style={(state) => [styles.card, ...pressableLift(state)]}>`. The lift is a -2px nudge plus one elevation step from `theme.interaction`, with the web transition built in. Never hand-write hover logic or your own pressed shadows, and never fake feedback with `opacity` — a dimmed surface reads as disabling itself.
2500
2595
  - **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `const [width, onLayout] = useContainerWidth();`, spreading `onLayout` on an unpadded root `View` (a React Native callback, so it behaves identically on both hosts). A width of `0` means "not yet measured" and the SDK helpers answer it with the wide form, so do not special-case it — except a count or size computed from the width (a calendar day, a chart, a card grid's cards per line), which renders nothing size-dependent until the width arrives: a widget gets no declared breakpoint prop, but it can always measure. **Every form, list, table and filter bar lays out in three tiers** (sc-8508): `const [width, onLayout] = useContainerWidth();` then branch on `widthTier(width)` — a form is one column narrow, paired short fields at medium, a 2–3 column grid capped near 1000px wide; a list is stacked cards, one-line cards, then real columns under a header; a filter bar is one toolbar row (search, a dropdown per facet, sort) with the active filters as removable chips beneath, and a single "Filters" button opening a panel when narrow. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces in the wide tier** (`flexDirection: widthTier(width) === 'wide' ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget. **Whatever you narrow, you centre** — a block that caps its own measure carries all three of `{ width: '100%', maxWidth: FORM_MAX, alignSelf: 'center' }`, because `alignSelf` alone stops the block stretching and sizes it to its content instead, and `maxWidth` alone leaves the widget flush left with dead space beside it; a capped wrap row lays its cells out along the MAIN axis, so it centres with `justifyContent: 'center'` on the row instead — but a card grid or a tile row is never capped. **A card grid fills its lines, it never wraps freely** — a free `flexWrap` strands the remainder (six cards that fit five render 5 + 1). Count the cards that fit the measured width (`const max = Math.max(1, Math.floor((width + gap) / (CARD_MIN + gap)));`), take `const lines = Math.ceil(count / max); const cols = Math.ceil(count / lines);`, and render each slice of `cols` cards as a row of `{ flex: 1, minWidth: 0 }` cells, padding the last line with empty `{ flex: 1 }` spacers so its cards keep the width of the cards above (six that fit five are 3 + 3, ten that fit four are 4 + 4 + 2). **A short value never breaks across lines** — a time, date, price, count, percentage or code takes `numberOfLines={1}` and `flexShrink: 0`, never a fixed width narrower than it, and a narrow slot re-arranges or steps the font down instead. **A metric / stat-tile row FILLS ITS ROW** — when you know the count, balance the lines (`const lines = Math.ceil(count / max); const base = Math.floor(count / lines); const extra = count % lines; const rows = Array.from({ length: lines }, (_, i) => tiles.slice(i * base + Math.min(i, extra), (i + 1) * base + Math.min(i + 1, extra)));`, with `max` 4 / 3 / 2 by tier, each tile `{ flex: 1, minWidth: 0 }`), and when the count comes from data wrap it (`{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }`, each tile `{ flexGrow: 1, flexBasis: TILE_MIN }`, never a `maxWidth` — capped tiles cluster to one side), because a bare `flexDirection: 'row'` has no way to break and a React Native flex child defaults to `flexShrink: 0` (unlike CSS), so the tiles cannot give ground either: the row runs past its container, spilling out of the card natively and cut off by the scrolling page column on web, with nothing thrown and nothing logged, once there are enough of them (four fit where five do not). The basis is also the per-line ceiling — `Math.floor((usable + gap) / (TILE_MIN + gap))` tiles fit a line, so 240 tops out at five across a full-width ~1400px section and 320 at four; past four or five a line the tiles stop reading as cards.
2501
- - **Compose forms — pair fields into rows, don't stack one per row.** Lay the form out in three tiers and cap it near 1000px, centred with the three declarations above — never a 560–720px column alone in a wide box, which leaves a form that IS the page as a narrow strip in empty space. Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. When a field carries an icon beside it (a search glyph, a clear button), the border belongs on the WRAPPER row and the `TextInput` inside it goes borderless and transparent with `flex: 1, minWidth: 0` (the `minWidth: 0` stops a long value pushing the border past its container) — React Native has no `:focus-within`, so drive the wrapper's `borderColor` between `colors.border` and `colors.primary` from the input's own `onFocus` / `onBlur`. A border left on the input rings only its own `<input>` box on web, leaving the icon outside the ring; never absolutely-position the icon over the field to work around it.
2596
+ - **Compose forms — pair fields into rows, don't stack one per row.** Lay the form out in three tiers and cap it near 1000px, centred with the three declarations above — never a 560–720px column alone in a wide box, which leaves a form that IS the page as a narrow strip in empty space. Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. A search box is `<SearchField>`, which already carries its glyph and clear control. Put any other icon or unit beside the field as a sibling in a row (`flex: 1, minWidth: 0` on the field, so a long value shrinks instead of pushing the row past its container) rather than rebuilding the field to hold it: a hand-built bordered wrapper around a bare `TextInput` loses the app's input look and its label, focus and error handling.
2502
2597
  - **Respond to touch.** Give every `Pressable` the lift via the function-style `style={(state) => [base, ...pressableLift(state)]}` — see "Answer the touch" above. Never dim with `opacity`, which reads as the surface disabling itself.
2503
2598
  - **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`. **A board (kanban, pipeline) drops onto ANOTHER column**, which needs four more things: the board's root renders the one proxy (a column clips whatever leaves it); you hit-test the drop yourself — `measureInWindow` every column and the board when the drag starts and compare the gesture's window `moveX` (never `onLayout` frames, which are relative to a padded or scrolled parent) — highlighting exactly one target; the drop WRITES (`update(id, { [statusField]: value })` through `useDatastoreMutation`, optimistic until `refetch()`, a toast and a spring-back on failure); and three or more columns go in a `horizontal` `ScrollView` when `widthTier(width) === "narrow"`, with the grip claiming the gesture (`onStartShouldSetPanResponder`, `onPanResponderTerminationRequest: () => false`) and carrying `touchAction: 'none'` on web so a finger drag moves the card instead of scrolling the page. And because `useRef(PanResponder.create(...))` runs ONCE, everything its callbacks read — the `useCanWrite` flag included, which starts `false` — goes through a ref refreshed every render (`latest.current.canDrag`); the publish gate `widget.staleResponder` rejects a value read directly.
2504
2599
  - **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
@@ -0,0 +1,150 @@
1
+ // The ONE implementation behind the `<Checkbox>` SDK primitive. The platform
2
+ // bindings (`checkbox.js` / `checkbox.native.js`) differ only in where the
3
+ // primitives come from, so the hosts cannot drift.
4
+ //
5
+ // Widgets each drew their own tick box, so one control looked different per
6
+ // widget. This one follows the theme's `muted` scope.
7
+
8
+ import React from "react";
9
+ import { useHostTheme } from "./hooks.js";
10
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
11
+ import { Icon } from "./icon.js";
12
+ import {
13
+ CHECKBOX_MAX_RADIUS,
14
+ CHECKBOX_SIZE,
15
+ CONTROL_MIN_TARGET,
16
+ focusHaloStyle,
17
+ resolveFormControlTokens,
18
+ } from "./form-control-tokens.js";
19
+ import {
20
+ errorMessage,
21
+ hasNote,
22
+ isShown,
23
+ isText,
24
+ spaceActivates,
25
+ spokenLabel,
26
+ } from "./form-control-logic.js";
27
+ import { makeFieldFrame } from "./form-control-view.js";
28
+
29
+ const h = React.createElement;
30
+
31
+ /**
32
+ * Bind the shared checkbox to one platform's primitives.
33
+ *
34
+ * @param {{View: any, Text: any, Pressable: any}} rn
35
+ */
36
+ export function makeCheckbox(rn) {
37
+ const { View, Text, Pressable } = rn;
38
+ const FieldFrame = makeFieldFrame(rn);
39
+
40
+ const Checkbox = React.forwardRef(function Checkbox(props, ref) {
41
+ const {
42
+ value = false,
43
+ onValueChange,
44
+ label,
45
+ helper,
46
+ error,
47
+ disabled = false,
48
+ accessibilityLabel,
49
+ style,
50
+ containerStyle,
51
+ onFocus,
52
+ onBlur,
53
+ ...rest
54
+ } = props;
55
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
56
+ const t = resolveFormControlTokens(theme);
57
+ const [focused, setFocused] = React.useState(false);
58
+ const noteId = React.useId();
59
+ const checked = Boolean(value);
60
+ const invalid = isShown(error);
61
+ const noted = hasNote({ helper, error });
62
+ const radius = Math.min(t.radius, CHECKBOX_MAX_RADIUS);
63
+ const toggle = () => {
64
+ if (!disabled && typeof onValueChange === "function") onValueChange(!checked);
65
+ };
66
+
67
+ const row = h(
68
+ Pressable,
69
+ {
70
+ ...rest,
71
+ ref,
72
+ onPress: toggle,
73
+ onKeyDown: spaceActivates(toggle),
74
+ onFocus: (event) => {
75
+ setFocused(true);
76
+ if (typeof onFocus === "function") onFocus(event);
77
+ },
78
+ onBlur: (event) => {
79
+ setFocused(false);
80
+ if (typeof onBlur === "function") onBlur(event);
81
+ },
82
+ disabled,
83
+ // accessibilityRole + aria-*: the pair that announces on both hosts.
84
+ accessibilityRole: "checkbox",
85
+ "aria-checked": checked,
86
+ "aria-disabled": disabled,
87
+ ...(invalid ? { "aria-invalid": true } : null),
88
+ ...(noted ? { "aria-describedby": noteId } : null),
89
+ accessibilityLabel: spokenLabel(accessibilityLabel, label),
90
+ style: [
91
+ {
92
+ flexDirection: "row",
93
+ alignItems: "center",
94
+ gap: t.rowGap,
95
+ minHeight: CONTROL_MIN_TARGET,
96
+ minWidth: CONTROL_MIN_TARGET,
97
+ },
98
+ // The frame dims a noted checkbox as a whole.
99
+ disabled && !noted ? { opacity: t.disabledOpacity } : null,
100
+ style,
101
+ ],
102
+ },
103
+ h(
104
+ View,
105
+ { style: focusHaloStyle(t, focused, radius) },
106
+ h(
107
+ View,
108
+ {
109
+ style: {
110
+ width: CHECKBOX_SIZE,
111
+ height: CHECKBOX_SIZE,
112
+ alignItems: "center",
113
+ justifyContent: "center",
114
+ borderWidth: 1,
115
+ borderRadius: radius,
116
+ borderColor: checked ? t.accent : invalid ? t.errorColor : t.borderColor,
117
+ backgroundColor: checked ? t.accent : t.background,
118
+ },
119
+ },
120
+ checked ? h(Icon, { name: "check", size: 16, color: t.onAccent }) : null,
121
+ ),
122
+ ),
123
+ isText(label)
124
+ ? h(
125
+ Text,
126
+ {
127
+ style: {
128
+ flexShrink: 1,
129
+ color: t.labelColor,
130
+ fontFamily: t.fontFamily,
131
+ fontSize: t.fontSize,
132
+ },
133
+ },
134
+ label,
135
+ )
136
+ : isShown(label)
137
+ ? label
138
+ : null,
139
+ );
140
+ if (!noted) return row;
141
+ return h(
142
+ FieldFrame,
143
+ { t, helper, error: errorMessage(error), disabled, noteId, style: containerStyle },
144
+ row,
145
+ );
146
+ });
147
+
148
+ Checkbox.displayName = "Checkbox";
149
+ return Checkbox;
150
+ }
@@ -0,0 +1,11 @@
1
+ // `<Checkbox>` (web). Binds ./checkbox-view.js to react-native-web; see
2
+ // ./primitives.js for why not the bare specifier.
3
+
4
+ import * as ReactNative from "react-native-web";
5
+ import { makeCheckbox } from "./checkbox-view.js";
6
+
7
+ export const Checkbox = makeCheckbox({
8
+ View: ReactNative.View,
9
+ Text: ReactNative.Text,
10
+ Pressable: ReactNative.Pressable,
11
+ });
@@ -0,0 +1,7 @@
1
+ // `<Checkbox>` (native). Binds ./checkbox-view.js to React Native's own
2
+ // primitives; only the import differs from ./checkbox.js.
3
+
4
+ import { View, Text, Pressable } from "react-native";
5
+ import { makeCheckbox } from "./checkbox-view.js";
6
+
7
+ export const Checkbox = makeCheckbox({ View, Text, Pressable });
package/dist/contract.cjs CHANGED
@@ -618,6 +618,8 @@ const THEME_COMPONENTS = Object.freeze({
618
618
  // `text` scope already owns.
619
619
  muted: Object.freeze({
620
620
  label: "Muted",
621
+ // The SDK's form controls read this scope directly, in every widget.
622
+ sdkComponents: Object.freeze(["TextField", "Checkbox", "Switch", "Select", "SearchField"]),
621
623
  tokens: Object.freeze({
622
624
  background: Object.freeze({ type: "color", uiDefault: "colors.surfaceMuted" }),
623
625
  textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurfaceMuted" }),
@@ -635,6 +637,7 @@ const THEME_COMPONENTS = Object.freeze({
635
637
  // list is the closest thing the built-in catalog has to a popover panel.
636
638
  popover: Object.freeze({
637
639
  label: "Popover",
640
+ sdkComponents: Object.freeze(["Select"]),
638
641
  tokens: Object.freeze({
639
642
  background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
640
643
  textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurface" }),
@@ -723,6 +726,25 @@ const THEME_COMPONENTS = Object.freeze({
723
726
  }),
724
727
  targets: Object.freeze({}),
725
728
  }),
729
+ // Dialogs: `<Modal>` reads this scope directly, so it names the SDK component
730
+ // it styles and binds no widget field. `<Overlay>` reads the scrim alone.
731
+ modal: Object.freeze({
732
+ label: "Dialogs",
733
+ sdkComponents: Object.freeze(["Modal"]),
734
+ tokens: Object.freeze({
735
+ background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
736
+ titleColor: Object.freeze({ type: "color" }),
737
+ scrim: Object.freeze({
738
+ type: "color",
739
+ translucent: true,
740
+ note: "also the backdrop behind every <Overlay>",
741
+ }),
742
+ radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.lg" }),
743
+ padding: Object.freeze({ type: "size", min: 0, max: 64, uiDefault: "spacing.lg" }),
744
+ shadow: Object.freeze({ type: "shadow" }),
745
+ }),
746
+ targets: Object.freeze({}),
747
+ }),
726
748
  });
727
749
 
728
750
  // sc-4505 — the value types a widget event payload field may declare. Narrower
@@ -2792,7 +2814,7 @@ const PRIMITIVES = [
2792
2814
  {
2793
2815
  name: "Switch",
2794
2816
  description:
2795
- "Toggle. Controlled — `value` + `onValueChange`. See https://reactnative.dev/docs/switch.",
2817
+ 'Themed on/off toggle. `<Switch label="Email me updates" value={on} onValueChange={setOn} />` — THE way to draw a setting that takes effect at once. It is the platform\'s own switch, so each host gives it its switch role, state and keys; its "on" track is the app\'s primary colour and its "off" track the input border (Design > Components > Muted). `label` draws the text beside it and makes the text tappable too, `helper` a hint below, `error` a message in its place (or `true` to mark it without one), `disabled` stops it (a device greys it; on the web it is drawn at half strength unless you passed your own colours). Always pass `label`, or `accessibilityLabel` when a visible label already sits next to it. Controlled — `value` + `onValueChange(next)`. Every React Native `Switch` prop still works: a `trackColor` you pass overrides the theme for the states it names, and `thumbColor` colours the thumb. Never hand-roll a toggle from `Pressable` + a sliding `View`.',
2796
2818
  rnComponent: "Switch",
2797
2819
  docsUrl: "https://reactnative.dev/docs/switch",
2798
2820
  },
@@ -2886,7 +2908,15 @@ const PRIMITIVES = [
2886
2908
  {
2887
2909
  name: "Overlay",
2888
2910
  description:
2889
- 'Screen-level overlay. `<Overlay visible={open} onRequestClose={() => setOpen(false)} size="md">…panel…</Overlay>`. THE way to open anything that must cover the SCREEN rather than the widget — a preview (PDF, image, video), a lightbox, a confirm dialog, a full detail panel. It renders OUTSIDE the widget layout box on both hosts (react-native-web portals it to the document root; native uses the OS modal), so no `overflow: hidden` card, scroll container or sibling widget can clip or cover it — which absolute positioning inside the widget cannot achieve on either host. Children stay in your own React tree, so state and hooks work normally. Props: `visible` (boolean, required to show), `onRequestClose` (fired by the backdrop press, the Escape key on web, and the Android back button — always wire it or the overlay cannot be closed), `size` ("sm" | "md" | "lg" | "full", default "md"; `full` runs edge to edge for media), `dismissOnBackdropPress` (default true), `accessibilityLabel`, and `style` for extra panel styles. The scrim, surface, radius, padding and elevation come from the workspace theme — never re-style them. Keep it for screen-level surfaces: a dropdown anchored to its trigger still belongs inside the widget.',
2911
+ 'Screen-level overlay. `<Overlay visible={open} onRequestClose={() => setOpen(false)} size="md">…panel…</Overlay>`. THE way to open anything that must cover the SCREEN rather than the widget and is not a dialog — a preview (PDF, image, video), a lightbox, a full-screen viewer. A dialog (a title, a close button, actions) is `<Modal>`, which is built on this. It renders OUTSIDE the widget layout box on both hosts (react-native-web portals it to the document root; native uses the OS modal), so no `overflow: hidden` card, scroll container or sibling widget can clip or cover it — which absolute positioning inside the widget cannot achieve on either host. Children stay in your own React tree, so state and hooks work normally. Props: `visible` (boolean, required to show), `onRequestClose` (fired by the backdrop press, the Escape key on web, and the Android back button — always wire it or the overlay cannot be closed), `size` ("sm" | "md" | "lg" | "full", default "md"; `full` runs edge to edge for media), `presentation` ("dialog", centred, the default | "sheet", anchored to the bottom edge), `dismissOnBackdropPress` (default true), `role` ("dialog" default | "alertdialog"; web only), `initialFocusRef` (web only), `accessibilityLabel` (names the dialog), `testID`, and `style` for extra panel styles. The scrim, surface, radius, padding and elevation come from the workspace theme (the scrim from Design > Components > Dialogs) — never re-style them. Keep it for screen-level surfaces: a dropdown anchored to its trigger still belongs inside the widget.',
2912
+ rnComponent: null,
2913
+ docsUrl: null,
2914
+ },
2915
+ // The standard dialog, built on `<Overlay>`, so widgets stop hand-building one.
2916
+ {
2917
+ name: "Modal",
2918
+ description:
2919
+ 'Standard dialog. `<Modal visible={open} onRequestClose={() => setOpen(false)} title="Edit order" footer={<><Button variant="secondary" title="Cancel" onPress={() => setOpen(false)} /><Button title="Save" onPress={save} /></>}>…body…</Modal>`. THE way to open a dialog, a form in a pop-up, a detail panel or a bottom sheet: it draws the title, the close button, a body that scrolls when it is long, and a footer action row laid out below it, and its background, corner radius, padding, scrim, shadow and title colour come from the theme (Design > Components > Dialogs), so every dialog in the app matches. It is built on `<Overlay>`, so it renders OUTSIDE the widget layout box on both hosts and nothing can clip it. Never hand-build a header, a close button or an action row inside a bare `<Overlay>`; keep `<Overlay>` for surfaces that are not a dialog (a media or document preview, a lightbox). Props: `visible` (boolean), `onRequestClose` (fired by the close button, the backdrop press, the Escape key on web and the Android back button — always wire it), `dismissible` (default true; false hides the close button and stops the backdrop, Escape and Android back from closing it, for a dialog that must stay open while it saves — only while that work runs, and always restore it, or nobody can close the dialog), `title` (string), `footer` (the action buttons, laid out as one right-aligned row that wraps on a phone; omit for no footer), `presentation` ("dialog", centred, the default | "sheet", anchored to the bottom edge; keep a form with text inputs in "dialog" — in the exported app the keyboard handling of a sheet is not yet verified on a device), `size` ("sm" | "md" | "lg" | "full", default "md"), `dismissOnBackdropPress` (default true), `role` ("dialog" default | "alertdialog"; web only), `initialFocusRef` (a ref to the control focused on open; web only), `closeAccessibilityLabel` (default "Close" — pass your translated label), `accessibilityLabel` (names a dialog with no title) and `testID`. With no `onRequestClose` it draws no close button. Components rendered inside it read a theme re-inked for the background the dialog paints, so build the body as its own component. Do not wrap the body in your own `ScrollView` and do not re-style the panel.',
2890
2920
  rnComponent: null,
2891
2921
  docsUrl: null,
2892
2922
  },
@@ -3026,7 +3056,35 @@ const PRIMITIVES = [
3026
3056
  {
3027
3057
  name: "TextField",
3028
3058
  description:
3029
- 'Themed text-entry field. `<TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />` — every `TextInput` prop works, and its background, text colour, border, corner radius and height come from the app\'s input look (Theme Settings > Muted), so a widget\'s fields match the built-in forms. `multiline` grows the field to a ceiling and then scrolls inside it. Override one field with `background`, `textColor`, `borderColor`, `radius` or `height`; `style` is merged last. Use it instead of styling a bare `TextInput`.',
3059
+ 'Themed text-entry field. `<TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />` — every `TextInput` prop works, and its background, text colour, border, corner radius and height come from the app\'s input look (Theme Settings > Muted), so a widget\'s fields match the built-in forms. `multiline` grows the field to a ceiling and then scrolls inside it. Override one field with `background`, `textColor`, `borderColor`, `radius` or `height`; `style` is merged last. `label` draws a label above the field and names it for a screen reader, `helper` a hint below, `error` a message in the helper\'s place with a danger border (`true` marks it without a message), `disabled` makes it read-only and dimmed; `containerStyle` styles the box around a labelled field. Use it instead of styling a bare `TextInput`.',
3060
+ rnComponent: null,
3061
+ docsUrl: null,
3062
+ },
3063
+ {
3064
+ name: "Checkbox",
3065
+ description:
3066
+ 'Themed tick box. `<Checkbox label="Remember me" value={checked} onValueChange={setChecked} />` — THE way to draw a yes/no choice that is submitted with a form, or one row of a multi-select list. The box is the app\'s input surface (Design > Components > Muted) and fills with the primary colour when checked; focus draws a ring in the primary colour around it. The whole row is one 44px target with the checkbox role and checked state on both hosts, and Space toggles it on the web. Props: `value` (boolean), `onValueChange(next)`, `label` (text, or an element), `helper`, `error` (a message, or `true`), `disabled`, `accessibilityLabel` (required when there is no text `label`), `style` (the row), `containerStyle` (the box around a row that shows a helper or error), `testID`; any other prop lands on the row. Never hand-roll a tick box from `Pressable` + a bordered `View` + a "✓".',
3067
+ rnComponent: null,
3068
+ docsUrl: null,
3069
+ },
3070
+ {
3071
+ name: "Select",
3072
+ description:
3073
+ 'Themed single-choice picker. `<Select label="Status" value={status} onValueChange={setStatus} options={[{ value: "open", label: "Open" }, { value: "done", label: "Done" }]} placeholder="Choose a status" />` — THE way to let the user pick ONE of a known list. The trigger is the app\'s input field (Design > Components > Muted) showing the chosen label; pressing it opens the options at SCREEN level, in a sheet painted from Design > Components > Popover, so no card or scroll container can clip them, and a long list scrolls inside the sheet. The trigger is a button that opens a dialog (expanded state included) and each option carries the option role and its selected state on both hosts; the list around them is a listbox on the web only. On the web Enter, Space or an arrow key opens it, the arrow keys, Home and End move between options, Enter or Space chooses, Escape closes, and focus returns to the trigger; on Android the back button closes it. Props: `options` (`[{ value, label, disabled?, testID? }]`, `value` a string or number, unique), `value`, `onValueChange(value, option)`, `placeholder` (default "Select"), `title` (the sheet\'s heading; defaults to a text `label`), `emptyText` (default "No options"), `label`, `helper`, `error`, `disabled`, `accessibilityLabel`, `closeAccessibilityLabel`, `background` / `textColor` / `borderColor` / `radius` / `height` to override one field, `style` (the trigger), `containerStyle`, `testID`; any other prop lands on the trigger. Never hand-roll a dropdown from `Pressable` + an absolutely positioned list: it is clipped by the widget\'s container on both hosts.',
3074
+ rnComponent: null,
3075
+ docsUrl: null,
3076
+ },
3077
+ {
3078
+ name: "SearchField",
3079
+ description:
3080
+ 'Themed search box. `<SearchField value={query} onChangeText={setQuery} placeholder="Search orders" />` — THE way to draw a field that filters or searches. It IS a `TextField` (same look, same props, same `label` / `helper` / `error` / `disabled`) with a search glyph, the search key on the on-screen keyboard and a clear control that appears once there is text; clearing calls `onChangeText("")`, then `onClear` if you passed one, and keeps focus in the field. `placeholder` defaults to "Search" and `clearAccessibilityLabel` to "Clear search" — pass translated ones. `style` styles the input and `containerStyle` the box around it (use it for `flex: 1` in a toolbar). Debounce the query yourself before you fetch. Never hand-roll a search box from a `TextInput` + an icon.',
3081
+ rnComponent: null,
3082
+ docsUrl: null,
3083
+ },
3084
+ {
3085
+ name: "spaceActivates",
3086
+ description:
3087
+ 'Space-key activation for a control whose role is not a button. `<Pressable accessibilityRole="radio" aria-checked={on} onPress={pick} onKeyDown={spaceActivates(pick)} />`. On the web a `Pressable` answers Enter for every role but Space only for a button, and Space is the key a checkbox, radio, switch or option is operated with; this restores it, once per key press. Native ignores `onKeyDown`, so it is safe on both hosts. `<Checkbox>` and `<Select>` already use it — reach for it only for a control you build yourself.',
3030
3088
  rnComponent: null,
3031
3089
  docsUrl: null,
3032
3090
  },
@@ -5554,7 +5612,23 @@ const CONTRACT = deepFreeze({
5554
5612
  // 1.166.0: additive (sc-10927) — the `Banner` primitive (a themed inline
5555
5613
  // error, warning, success or notice) and the `alert` component scope it
5556
5614
  // reads.
5557
- version: "1.166.0",
5615
+ // 1.169.0: additive (sc-10924) — the `Modal` primitive: the standard dialog
5616
+ // (title, close button, scrolling body, footer action row; `presentation`
5617
+ // "dialog" | "sheet") built on `Overlay`. New `themeComponents.modal`
5618
+ // scope (background, titleColor, scrim, radius, padding, shadow), read by
5619
+ // `Modal`; `Overlay` reads its `scrim` and gains `presentation`, `role`,
5620
+ // `initialFocusRef`, `backdropAccessible` and `testID`. A scope may now
5621
+ // name `sdkComponents` and bind no widget field; a colour token may be
5622
+ // `translucent` (always painted see-through) and carry a `note`.
5623
+ // 1.171.0: (sc-10929) — the form controls `Checkbox`, `Select` and
5624
+ // `SearchField`, and the `spaceActivates` helper. `TextField` gains
5625
+ // `label`, `helper`, `error`, `disabled` and `containerStyle`. The `muted`
5626
+ // and `popover` scopes name the `sdkComponents` that read them directly.
5627
+ // VISIBLE CHANGE, not additive: `Switch` is now the themed platform
5628
+ // switch, so an existing `<Switch>` with no colour props is repainted:
5629
+ // its tracks follow the theme on both hosts and its "on" thumb is white
5630
+ // on the web. A caller's `trackColor` / `thumbColor` still win.
5631
+ version: "1.171.0",
5558
5632
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5559
5633
  hooks: HOOKS,
5560
5634
  primitives: PRIMITIVES,