@colixsystems/widget-sdk 0.112.0 → 0.114.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.
- package/README.md +24 -3
- package/dist/contract.cjs +88 -6
- package/dist/contract.js +88 -6
- package/dist/hooks.js +153 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.js +3 -0
- package/dist/index.native.js +3 -0
- package/dist/interaction.js +16 -0
- package/dist/interaction.native.js +10 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
17
17
|
|
|
18
18
|
| Group | Hook (signature) | Returns | Reads / scope |
|
|
19
19
|
| ----- | ---------------- | ------- | ------------- |
|
|
20
|
-
| **CORE** | `useTheme()` | `{ colors, elevation, spacing, spacingScale, radii, typography, components, widgetStyles }` | `ctx.workspace.theme` — no scope. `elevation` is the shared depth scale (`none / sm / md / lg / xl`) you spread into a style; `colors` includes the accent's quiet tiers (`primarySoft` / `onPrimarySoft` / `primaryStrong`) and `loader`, the spinner colour for your own loading state. `components` is HOST-OWNED (the theme's per-component style tokens); the host has already folded it into your `props.style`, so read `useWidgetStyle()` and ignore this slice. |
|
|
20
|
+
| **CORE** | `useTheme()` | `{ colors, elevation, interaction, spacing, spacingScale, radii, typography, components, widgetStyles }` | `ctx.workspace.theme` — no scope. `elevation` is the shared depth scale (`none / sm / md / lg / xl`) you spread into a style; `colors` includes the accent's quiet tiers (`primarySoft` / `onPrimarySoft` / `primaryStrong`) and `loader`, the spinner colour for your own loading state. `components` is HOST-OWNED (the theme's per-component style tokens); the host has already folded it into your `props.style`, so read `useWidgetStyle()` and ignore this slice. |
|
|
21
21
|
| **CORE** | `useWorkspaceCurrency()` | `{ currency, formatMoney }` | `ctx.workspace.currency` — no scope. The currency this workspace charges its app users in, resolved at RENDER time. Render every price as `formatMoney(minorUnits)` and never write a currency symbol or code into a widget: the owner can change it after the widget ships, and a baked label then contradicts the charge. |
|
|
22
22
|
| **CORE** | `useWidgetStyle()` | `{ [styleField]: value }` | `ctx.props.style` — no scope. The author-set per-widget style values declared in `manifest.styleSchema`; apply each onto whatever element you choose. |
|
|
23
23
|
| **CORE** | `useUser()` | `{ id, email, displayName, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope |
|
|
@@ -37,6 +37,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
37
37
|
| **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` — wired by the Player and the Expo export; an authoring preview omits it and the call is a no-op — no scope |
|
|
38
38
|
| **CORE** | `useGeolocation(options?)` | `{ latitude, longitude, accuracy, loading, error, getCurrentPosition }` | `ctx.device.geolocation` — no scope. Capture is IMPERATIVE: call `getCurrentPosition()` from a user gesture (a tap), never on mount. Resolves to `{ latitude, longitude, accuracy }`; rejects with `GeolocationError` (`.code` in `PERMISSION_DENIED \| UNAVAILABLE \| TIMEOUT \| UNSUPPORTED \| INTERNAL`). Identical on web (`navigator.geolocation`) and the Expo export (`expo-location`). |
|
|
39
39
|
| **CORE** | `useSpeechToText(options?)` | `{ transcript, partial, listening, supported, error, start, stop, abort, reset }` | `ctx.device.speech` — no scope. Dictation with the device's **on-device** recogniser: no audio is uploaded and no AI credit is spent. Capture is IMPERATIVE: call `start()` from a user gesture (a tap), never on mount. `transcript` accumulates finalised speech, `partial` holds the uncommitted guess (needs `options.interimResults`); `stop()` keeps it, `abort()` discards it. Rejects with `SpeechToTextError` (`.code` in `PERMISSION_DENIED \| NO_SPEECH \| LANGUAGE_UNSUPPORTED \| NETWORK \| ABORTED \| UNSUPPORTED \| INTERNAL`). **Gate your mic button on `supported`** — Firefox ships no `SpeechRecognition`. Identical on web (`SpeechRecognition`) and the Expo export (`expo-speech-recognition`). |
|
|
40
|
+
| **CORE** | `useCamera(options?)` | `{ asset, loading, error, supported, capture, pick, reset }` | `ctx.device.camera` — no scope. Take a photo (`capture()`) or choose one (`pick()`). Capture is IMPERATIVE: call from a user gesture (a tap), never on mount. Both resolve a normalised `{ uri, name, mimeType, width, height, size, file }`, or **`null` when the user dismisses the picker** — dismissal is not an error, so no `try/catch` is needed on the happy path. Rejects with `CameraError` (`.code` in `PERMISSION_DENIED \| UNSUPPORTED \| INTERNAL`). `asset.file` is already the right upload part for the host, so `fd.append("file", asset.file)` → `ctx.assets.upload(fd)` is ONE code path on both. **Gate your camera button on `supported`.** Identical on web (file input) and the Expo export (`expo-image-picker`). |
|
|
40
41
|
| **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
|
|
41
42
|
| **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
|
|
42
43
|
| **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
|
|
@@ -71,6 +72,25 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
71
72
|
|
|
72
73
|
`v0.112.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**.
|
|
73
74
|
|
|
75
|
+
### What's new in 0.113.0 (contract 1.86.0)
|
|
76
|
+
|
|
77
|
+
**New `useCamera()` hook — take a photo or pick one from the device library.** A new CORE hook reading a new `camera` capability on the existing `ctx.device` slice. Returns `{ asset, loading, error, supported, capture, pick, reset }`. Capture is **imperative** — call `capture()` or `pick()` from a user gesture (a `Pressable.onPress`); the browser and the mobile OS gate the permission prompt on a gesture, so it NEVER opens on mount. `options` (`{ allowsEditing, quality }`) pass through to the host. It needs **no manifest scope** and **no `requestedScopes` entry**.
|
|
78
|
+
|
|
79
|
+
**Dismissing the picker resolves `null`, not an error.** Backing out is the most common outcome, so it is deliberately not a rejection — your happy path needs no `try/catch`. A genuine failure (permission refused, no host broker) rejects with a structured `CameraError` (new named export) carrying a stable `.code` (`PERMISSION_DENIED` / `UNSUPPORTED` / `INTERNAL`).
|
|
80
|
+
|
|
81
|
+
**One upload path on both hosts.** The resolved asset is normalised to `{ uri, name, mimeType, width, height, size, file }`, where `uri` is directly displayable (`<Image source={{ uri }} />`) and `file` is already the right upload part for the platform — a `File` on web, a `{ uri, name, type }` triple on native. So the same three lines work everywhere:
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
const fd = new FormData();
|
|
85
|
+
fd.append("file", asset.file);
|
|
86
|
+
await ctx.assets.upload(fd);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`options` (`{ allowsEditing, quality }`) are **hints**: the Expo export applies both, the web file input applies neither — so never depend on a cropped result or a capped file size. `reset()` clears the asset and releases it (on web that revokes the blob URL, which otherwise leaks for the life of the document). **Gate your camera button on `supported`** — a host that brokers no camera reports `false` rather than throwing. The web Player brokers it via a file input (`capture="environment"` opens the camera on a phone); the Expo export via `expo-image-picker`, whose config plugin declares the camera and photo-library permissions the runtime needs.
|
|
90
|
+
|
|
91
|
+
Additive — one new hook, one new optional context-slice member; no existing export changed signature.
|
|
92
|
+
|
|
93
|
+
|
|
74
94
|
### What's new in 0.112.0 (contract unchanged at 1.85.0)
|
|
75
95
|
|
|
76
96
|
**Two linter rules make the styling contract checkable, and `lintStyleWiring` joins the linter export (sc-6455).** Every visual value a widget writes reaches the app's owner through one of exactly two channels — a **theme token** (`useTheme()`), which is the app-wide default and follows a look change, or a **`styleSchema` field** read off `props.style`, which the Studio offers per instance in the widget editor *and* app-wide under **Design → Widget appearance**. A literal reaches neither: it outranks the theme permanently and no control on either surface can move it, so the owner finds a corner of their app they cannot restyle. Until now that rule was documentation only.
|
|
@@ -1074,15 +1094,16 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
1074
1094
|
- **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.lg` + `...theme.elevation.sm`. Give it the elevation **or** a `colors.border` hairline, not both — and prefer the elevation, because a hairline-only card reads as a wireframe. `theme.elevation` is a token table you spread into a style (`...theme.elevation.md`), covering `none / sm / md / lg / xl`; never hand-write `shadowOpacity` / `shadowRadius` / `boxShadow`. Use the status roles (`danger / success / warning / info`) for state.
|
|
1075
1095
|
- **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
|
|
1076
1096
|
- **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.
|
|
1097
|
+
- **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.
|
|
1077
1098
|
- **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 `onLayout={(e) => setWidth(e.nativeEvent.layout.width)}` on the root `View` (a React Native primitive, so it behaves identically on both hosts), render nothing size-dependent while `width === 0`, and compute every threshold from the measured value: a widget gets no declared breakpoint prop, but it can always measure. **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 above ~720px measured width** (`flexDirection: width >= 720 ? '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.
|
|
1078
1099
|
- **Compose forms — pair fields into rows, don't stack one per row.** 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, 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.
|
|
1079
|
-
- **Respond to touch.** Give every `Pressable`
|
|
1100
|
+
- **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.
|
|
1080
1101
|
- **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`.
|
|
1081
1102
|
- **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).
|
|
1082
1103
|
- **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useAsset()`. Give every image a sized, `radii`-clipped container so it never renders as a raw rectangle, and never hardcode a credentialed image URL — expose an `image`-type property instead. The frame is your decision, never the picture's: size it for the role (a 40–56 square avatar, a 72–96 square row thumbnail, a `16 / 9` card cover, a 160–240 tall band) and let `resizeMode="cover"` crop the photo into it — photos arrive at every size and ratio, so one left to its own proportions breaks the layout. Keep `contain` for art whose whole subject must stay visible (a logo, a diagram), inside a fixed frame.
|
|
1083
1104
|
- **Design the empty, loading, and error states.** A blank box on a fresh install reads as broken — show a short helper line when a list is empty, a calm loading line, and a single human sentence in `colors.danger` on error.
|
|
1084
1105
|
|
|
1085
|
-
**Honest ceilings:** the styling surface is React Native style objects, not full CSS. Gradients come from the `<Gradient>` primitive (not a CSS `linear-gradient` string), depth comes from `theme.elevation` (not arbitrary `box-shadow` stacks), and there are no custom CSS keyframe animations or `transition` strings, no `filter` / `backdrop-filter` / `clip-path` / `mask` / blend modes, and no opacity-faked tints (that's what `primarySoft` is for). Aim for clean, confident, professional polish within those bounds — lifted surfaces, generous corners, one accent moment.
|
|
1106
|
+
**Honest ceilings:** the styling surface is React Native style objects, not full CSS. Gradients come from the `<Gradient>` primitive (not a CSS `linear-gradient` string), depth comes from `theme.elevation` (not arbitrary `box-shadow` stacks), and there are no custom CSS keyframe animations or `transition` strings (the hover/press lift comes built into `pressableLift` — never write your own), no `filter` / `backdrop-filter` / `clip-path` / `mask` / blend modes, and no opacity-faked tints (that's what `primarySoft` is for). Aim for clean, confident, professional polish within those bounds — lifted surfaces, generous corners, one accent moment.
|
|
1086
1107
|
|
|
1087
1108
|
## Managing app users from a widget
|
|
1088
1109
|
|
package/dist/contract.cjs
CHANGED
|
@@ -45,6 +45,24 @@ const ELEVATION = Object.freeze({
|
|
|
45
45
|
}),
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
// sc-6531 (REQ-AI-AGENT-DESIGN-LIFT): the ONE cross-platform interaction
|
|
49
|
+
// vocabulary. A tappable surface answers the pointer over it (web) and the
|
|
50
|
+
// press on it (touch) by LIFTING. `raised` is one elevation step up plus a
|
|
51
|
+
// nudge — the scale above stays the only shadow vocabulary — and is what a
|
|
52
|
+
// widget's own surface wears via `pressableLift`; `translateY` alone is the
|
|
53
|
+
// surface-agnostic nudge a tappable layout REGION gets on both hosts.
|
|
54
|
+
const INTERACTION = Object.freeze({
|
|
55
|
+
lift: Object.freeze({
|
|
56
|
+
translateY: -2,
|
|
57
|
+
raised: Object.freeze({
|
|
58
|
+
transform: Object.freeze([Object.freeze({ translateY: -2 })]),
|
|
59
|
+
...ELEVATION.lg,
|
|
60
|
+
}),
|
|
61
|
+
// Web-only smoothing; a touch state change is instant.
|
|
62
|
+
transitionMs: 150,
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
|
|
48
66
|
const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
49
67
|
colors: Object.freeze({
|
|
50
68
|
primary: "#3b82f6",
|
|
@@ -76,6 +94,7 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
|
76
94
|
info: "#0284c7",
|
|
77
95
|
}),
|
|
78
96
|
elevation: ELEVATION,
|
|
97
|
+
interaction: INTERACTION,
|
|
79
98
|
spacing: Object.freeze({ xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }),
|
|
80
99
|
// REQ-THEME-LOOK: multiplies every layout spacing value at render. 1 is
|
|
81
100
|
// unchanged, so a theme that never sets it renders exactly as before.
|
|
@@ -1576,6 +1595,37 @@ const HOOKS = [
|
|
|
1576
1595
|
requiredContextSlice: [],
|
|
1577
1596
|
scopes: null,
|
|
1578
1597
|
},
|
|
1598
|
+
// sc-6448 — host-brokered camera capture / photo picking. Optional slice;
|
|
1599
|
+
// the hook reports supported:false rather than throwing at render.
|
|
1600
|
+
{
|
|
1601
|
+
name: "useCamera",
|
|
1602
|
+
signature: "useCamera(options?)",
|
|
1603
|
+
description:
|
|
1604
|
+
"Take a photo or choose one from the device library. Returns { asset, loading, error, supported, capture, pick, reset }. " +
|
|
1605
|
+
"Capture is IMPERATIVE — call capture() or pick() from a user gesture (a tap); the browser and the mobile OS gate the " +
|
|
1606
|
+
"permission prompt on a gesture, so it NEVER opens on mount. Both resolve to a normalised asset " +
|
|
1607
|
+
"{ uri, name, mimeType, width, height, size, file }, or NULL when the user dismisses the picker — dismissal is the " +
|
|
1608
|
+
"common case and is deliberately NOT an error, so no try/catch is needed on the happy path. They reject with a " +
|
|
1609
|
+
"CameraError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. `asset.file` is already the right " +
|
|
1610
|
+
"upload part for the host (a File on web, { uri, name, type } on native): append it to a FormData as `file` and pass " +
|
|
1611
|
+
"that to ctx.assets.upload(fd) — one code path on both platforms. reset() clears the asset and releases it. Check " +
|
|
1612
|
+
"`supported` before rendering a camera button. options: { allowsEditing, quality } are HINTS the host honours where it " +
|
|
1613
|
+
"can — the Expo export applies both, the web file input applies neither, so never depend on a capped file size or a " +
|
|
1614
|
+
"cropped result. Behaviour is otherwise identical on web (file input) and the Expo export (expo-image-picker).",
|
|
1615
|
+
returnShape: {
|
|
1616
|
+
asset:
|
|
1617
|
+
"{ uri, name, mimeType, width, height, size, file } | null",
|
|
1618
|
+
loading: "boolean",
|
|
1619
|
+
error: "CameraError | null",
|
|
1620
|
+
supported: "boolean // false when the host brokers no camera",
|
|
1621
|
+
capture:
|
|
1622
|
+
"() => Promise<asset | null> // null if dismissed; rejects with CameraError",
|
|
1623
|
+
pick: "() => Promise<asset | null> // null if dismissed; rejects with CameraError",
|
|
1624
|
+
reset: "() => void // clear the asset + error and release it",
|
|
1625
|
+
},
|
|
1626
|
+
requiredContextSlice: [],
|
|
1627
|
+
scopes: null,
|
|
1628
|
+
},
|
|
1579
1629
|
];
|
|
1580
1630
|
|
|
1581
1631
|
// REQ-WSDK-RN-WEB: the SDK exposes the React Native primitive API
|
|
@@ -1709,6 +1759,17 @@ const PRIMITIVES = [
|
|
|
1709
1759
|
rnComponent: "expo-linear-gradient",
|
|
1710
1760
|
docsUrl: "https://docs.expo.dev/versions/latest/sdk/linear-gradient/",
|
|
1711
1761
|
},
|
|
1762
|
+
// sc-6531 (REQ-AI-AGENT-DESIGN-LIFT) — the hover/press lift affordance. A
|
|
1763
|
+
// FUNCTION primitive, not a component: it resolves the interaction tokens per
|
|
1764
|
+
// input, so one declaration answers the pointer on web and the press on
|
|
1765
|
+
// native.
|
|
1766
|
+
{
|
|
1767
|
+
name: "pressableLift",
|
|
1768
|
+
description:
|
|
1769
|
+
"Interaction feedback for a tappable surface. Call it inside a Pressable's style FUNCTION and spread the result: `<Pressable style={(state) => [styles.card, ...pressableLift(state)]}>`. While the pointer hovers the surface (web) or it is pressed (touch), the surface lifts: a -2px nudge plus one elevation step, from `themeTokens.interaction`, with the web transition built in. Give it to EVERY tappable card, row and list entry; never hand-write hover logic, `transition` strings, or your own pressed shadows.",
|
|
1770
|
+
rnComponent: null,
|
|
1771
|
+
docsUrl: null,
|
|
1772
|
+
},
|
|
1712
1773
|
];
|
|
1713
1774
|
|
|
1714
1775
|
const CATEGORIES = [
|
|
@@ -2168,14 +2229,18 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2168
2229
|
description:
|
|
2169
2230
|
"Optional host-brokered device capabilities. " +
|
|
2170
2231
|
"{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }> }, " +
|
|
2171
|
-
"speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }
|
|
2172
|
-
"
|
|
2173
|
-
"
|
|
2232
|
+
"speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
|
|
2233
|
+
"camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> } }. " +
|
|
2234
|
+
"Backs useGeolocation(), useSpeechToText() and useCamera(). The web Player brokers them via navigator.geolocation, " +
|
|
2235
|
+
"window.SpeechRecognition and a file input; the Expo export via expo-location, expo-speech-recognition and " +
|
|
2236
|
+
"expo-image-picker. " +
|
|
2174
2237
|
"getCurrentPosition rejects with a GeolocationError (.code PERMISSION_DENIED | UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL). " +
|
|
2175
2238
|
"speech.start streams { transcript, isFinal } to onResult and runs ON DEVICE — it uploads no audio and spends no AI credit; " +
|
|
2176
|
-
"its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted)."
|
|
2239
|
+
"its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted). " +
|
|
2240
|
+
"camera.capture/pick resolve a normalised { uri, name, mimeType, width, height, size, file, release? } or NULL when the " +
|
|
2241
|
+
"user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL).",
|
|
2177
2242
|
required: false,
|
|
2178
|
-
fields: { geolocation: "object", speech: "object" },
|
|
2243
|
+
fields: { geolocation: "object", speech: "object", camera: "object" },
|
|
2179
2244
|
},
|
|
2180
2245
|
};
|
|
2181
2246
|
|
|
@@ -3386,7 +3451,24 @@ const CONTRACT = deepFreeze({
|
|
|
3386
3451
|
// byte-identically. `mapSpacing` pushes each side through the responsive
|
|
3387
3452
|
// and theme scaling the scalar already got; `isZeroSpacing` lets each box
|
|
3388
3453
|
// property keep its own zero policy.
|
|
3389
|
-
|
|
3454
|
+
//
|
|
3455
|
+
// 1.86.0: additive (sc-6448) — new `useCamera()` hook + a `camera` member on
|
|
3456
|
+
// the optional `device` host slice. Takes a photo or picks one from the
|
|
3457
|
+
// library, resolving a normalised asset whose `file` is already the right
|
|
3458
|
+
// upload part for the host (a File on web, `{ uri, name, type }` on
|
|
3459
|
+
// native), so one code path feeds `ctx.assets.upload`. Host-brokered
|
|
3460
|
+
// rather than a vetted import — the same reasoning as `speech`: widgets
|
|
3461
|
+
// never import the native module, so it stays out of widget bundles.
|
|
3462
|
+
// This supersedes the `expo-camera` deferral in
|
|
3463
|
+
// docs/design/req-widget-sdk-cross-platform-primitives.md, which was
|
|
3464
|
+
// about vetting a full camera surface (permissions, multi-step UX, frame
|
|
3465
|
+
// processing) as a widget import — none of which this adds.
|
|
3466
|
+
// 1.87.0: additive (sc-6531) — `themeTokens.interaction`: the interaction-
|
|
3467
|
+
// state vocabulary (`lift.raised`, `lift.translateY`, `lift.transitionMs`),
|
|
3468
|
+
// plus the `pressableLift` primitive that resolves it per input — hover on
|
|
3469
|
+
// web, press on native. Tappable layout containers and widget surfaces lift
|
|
3470
|
+
// from this ONE table.
|
|
3471
|
+
version: "1.87.0",
|
|
3390
3472
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3391
3473
|
hooks: HOOKS,
|
|
3392
3474
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -45,6 +45,24 @@ const ELEVATION = Object.freeze({
|
|
|
45
45
|
}),
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
// sc-6531 (REQ-AI-AGENT-DESIGN-LIFT): the ONE cross-platform interaction
|
|
49
|
+
// vocabulary. A tappable surface answers the pointer over it (web) and the
|
|
50
|
+
// press on it (touch) by LIFTING. `raised` is one elevation step up plus a
|
|
51
|
+
// nudge — the scale above stays the only shadow vocabulary — and is what a
|
|
52
|
+
// widget's own surface wears via `pressableLift`; `translateY` alone is the
|
|
53
|
+
// surface-agnostic nudge a tappable layout REGION gets on both hosts.
|
|
54
|
+
const INTERACTION = Object.freeze({
|
|
55
|
+
lift: Object.freeze({
|
|
56
|
+
translateY: -2,
|
|
57
|
+
raised: Object.freeze({
|
|
58
|
+
transform: Object.freeze([Object.freeze({ translateY: -2 })]),
|
|
59
|
+
...ELEVATION.lg,
|
|
60
|
+
}),
|
|
61
|
+
// Web-only smoothing; a touch state change is instant.
|
|
62
|
+
transitionMs: 150,
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
|
|
48
66
|
const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
49
67
|
colors: Object.freeze({
|
|
50
68
|
primary: "#3b82f6",
|
|
@@ -76,6 +94,7 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
|
76
94
|
info: "#0284c7",
|
|
77
95
|
}),
|
|
78
96
|
elevation: ELEVATION,
|
|
97
|
+
interaction: INTERACTION,
|
|
79
98
|
spacing: Object.freeze({ xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }),
|
|
80
99
|
// REQ-THEME-LOOK: multiplies every layout spacing value at render. 1 is
|
|
81
100
|
// unchanged, so a theme that never sets it renders exactly as before.
|
|
@@ -1576,6 +1595,37 @@ const HOOKS = [
|
|
|
1576
1595
|
requiredContextSlice: [],
|
|
1577
1596
|
scopes: null,
|
|
1578
1597
|
},
|
|
1598
|
+
// sc-6448 — host-brokered camera capture / photo picking. Optional slice;
|
|
1599
|
+
// the hook reports supported:false rather than throwing at render.
|
|
1600
|
+
{
|
|
1601
|
+
name: "useCamera",
|
|
1602
|
+
signature: "useCamera(options?)",
|
|
1603
|
+
description:
|
|
1604
|
+
"Take a photo or choose one from the device library. Returns { asset, loading, error, supported, capture, pick, reset }. " +
|
|
1605
|
+
"Capture is IMPERATIVE — call capture() or pick() from a user gesture (a tap); the browser and the mobile OS gate the " +
|
|
1606
|
+
"permission prompt on a gesture, so it NEVER opens on mount. Both resolve to a normalised asset " +
|
|
1607
|
+
"{ uri, name, mimeType, width, height, size, file }, or NULL when the user dismisses the picker — dismissal is the " +
|
|
1608
|
+
"common case and is deliberately NOT an error, so no try/catch is needed on the happy path. They reject with a " +
|
|
1609
|
+
"CameraError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. `asset.file` is already the right " +
|
|
1610
|
+
"upload part for the host (a File on web, { uri, name, type } on native): append it to a FormData as `file` and pass " +
|
|
1611
|
+
"that to ctx.assets.upload(fd) — one code path on both platforms. reset() clears the asset and releases it. Check " +
|
|
1612
|
+
"`supported` before rendering a camera button. options: { allowsEditing, quality } are HINTS the host honours where it " +
|
|
1613
|
+
"can — the Expo export applies both, the web file input applies neither, so never depend on a capped file size or a " +
|
|
1614
|
+
"cropped result. Behaviour is otherwise identical on web (file input) and the Expo export (expo-image-picker).",
|
|
1615
|
+
returnShape: {
|
|
1616
|
+
asset:
|
|
1617
|
+
"{ uri, name, mimeType, width, height, size, file } | null",
|
|
1618
|
+
loading: "boolean",
|
|
1619
|
+
error: "CameraError | null",
|
|
1620
|
+
supported: "boolean // false when the host brokers no camera",
|
|
1621
|
+
capture:
|
|
1622
|
+
"() => Promise<asset | null> // null if dismissed; rejects with CameraError",
|
|
1623
|
+
pick: "() => Promise<asset | null> // null if dismissed; rejects with CameraError",
|
|
1624
|
+
reset: "() => void // clear the asset + error and release it",
|
|
1625
|
+
},
|
|
1626
|
+
requiredContextSlice: [],
|
|
1627
|
+
scopes: null,
|
|
1628
|
+
},
|
|
1579
1629
|
];
|
|
1580
1630
|
|
|
1581
1631
|
// REQ-WSDK-RN-WEB: the SDK exposes the React Native primitive API
|
|
@@ -1709,6 +1759,17 @@ const PRIMITIVES = [
|
|
|
1709
1759
|
rnComponent: "expo-linear-gradient",
|
|
1710
1760
|
docsUrl: "https://docs.expo.dev/versions/latest/sdk/linear-gradient/",
|
|
1711
1761
|
},
|
|
1762
|
+
// sc-6531 (REQ-AI-AGENT-DESIGN-LIFT) — the hover/press lift affordance. A
|
|
1763
|
+
// FUNCTION primitive, not a component: it resolves the interaction tokens per
|
|
1764
|
+
// input, so one declaration answers the pointer on web and the press on
|
|
1765
|
+
// native.
|
|
1766
|
+
{
|
|
1767
|
+
name: "pressableLift",
|
|
1768
|
+
description:
|
|
1769
|
+
"Interaction feedback for a tappable surface. Call it inside a Pressable's style FUNCTION and spread the result: `<Pressable style={(state) => [styles.card, ...pressableLift(state)]}>`. While the pointer hovers the surface (web) or it is pressed (touch), the surface lifts: a -2px nudge plus one elevation step, from `themeTokens.interaction`, with the web transition built in. Give it to EVERY tappable card, row and list entry; never hand-write hover logic, `transition` strings, or your own pressed shadows.",
|
|
1770
|
+
rnComponent: null,
|
|
1771
|
+
docsUrl: null,
|
|
1772
|
+
},
|
|
1712
1773
|
];
|
|
1713
1774
|
|
|
1714
1775
|
const CATEGORIES = [
|
|
@@ -2168,14 +2229,18 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2168
2229
|
description:
|
|
2169
2230
|
"Optional host-brokered device capabilities. " +
|
|
2170
2231
|
"{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }> }, " +
|
|
2171
|
-
"speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }
|
|
2172
|
-
"
|
|
2173
|
-
"
|
|
2232
|
+
"speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
|
|
2233
|
+
"camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> } }. " +
|
|
2234
|
+
"Backs useGeolocation(), useSpeechToText() and useCamera(). The web Player brokers them via navigator.geolocation, " +
|
|
2235
|
+
"window.SpeechRecognition and a file input; the Expo export via expo-location, expo-speech-recognition and " +
|
|
2236
|
+
"expo-image-picker. " +
|
|
2174
2237
|
"getCurrentPosition rejects with a GeolocationError (.code PERMISSION_DENIED | UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL). " +
|
|
2175
2238
|
"speech.start streams { transcript, isFinal } to onResult and runs ON DEVICE — it uploads no audio and spends no AI credit; " +
|
|
2176
|
-
"its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted)."
|
|
2239
|
+
"its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted). " +
|
|
2240
|
+
"camera.capture/pick resolve a normalised { uri, name, mimeType, width, height, size, file, release? } or NULL when the " +
|
|
2241
|
+
"user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL).",
|
|
2177
2242
|
required: false,
|
|
2178
|
-
fields: { geolocation: "object", speech: "object" },
|
|
2243
|
+
fields: { geolocation: "object", speech: "object", camera: "object" },
|
|
2179
2244
|
},
|
|
2180
2245
|
};
|
|
2181
2246
|
|
|
@@ -3386,7 +3451,24 @@ const CONTRACT = deepFreeze({
|
|
|
3386
3451
|
// byte-identically. `mapSpacing` pushes each side through the responsive
|
|
3387
3452
|
// and theme scaling the scalar already got; `isZeroSpacing` lets each box
|
|
3388
3453
|
// property keep its own zero policy.
|
|
3389
|
-
|
|
3454
|
+
//
|
|
3455
|
+
// 1.86.0: additive (sc-6448) — new `useCamera()` hook + a `camera` member on
|
|
3456
|
+
// the optional `device` host slice. Takes a photo or picks one from the
|
|
3457
|
+
// library, resolving a normalised asset whose `file` is already the right
|
|
3458
|
+
// upload part for the host (a File on web, `{ uri, name, type }` on
|
|
3459
|
+
// native), so one code path feeds `ctx.assets.upload`. Host-brokered
|
|
3460
|
+
// rather than a vetted import — the same reasoning as `speech`: widgets
|
|
3461
|
+
// never import the native module, so it stays out of widget bundles.
|
|
3462
|
+
// This supersedes the `expo-camera` deferral in
|
|
3463
|
+
// docs/design/req-widget-sdk-cross-platform-primitives.md, which was
|
|
3464
|
+
// about vetting a full camera surface (permissions, multi-step UX, frame
|
|
3465
|
+
// processing) as a widget import — none of which this adds.
|
|
3466
|
+
// 1.87.0: additive (sc-6531) — `themeTokens.interaction`: the interaction-
|
|
3467
|
+
// state vocabulary (`lift.raised`, `lift.translateY`, `lift.transitionMs`),
|
|
3468
|
+
// plus the `pressableLift` primitive that resolves it per input — hover on
|
|
3469
|
+
// web, press on native. Tappable layout containers and widget surfaces lift
|
|
3470
|
+
// from this ONE table.
|
|
3471
|
+
version: "1.87.0",
|
|
3390
3472
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3391
3473
|
hooks: HOOKS,
|
|
3392
3474
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -1297,6 +1297,159 @@ export function useSpeechToText(options) {
|
|
|
1297
1297
|
};
|
|
1298
1298
|
}
|
|
1299
1299
|
|
|
1300
|
+
/**
|
|
1301
|
+
* Structured error thrown by `useCamera` callbacks.
|
|
1302
|
+
*
|
|
1303
|
+
* `code` is one of:
|
|
1304
|
+
* - "PERMISSION_DENIED" — the user (or OS) refused camera / library access.
|
|
1305
|
+
* - "UNSUPPORTED" — this host does not broker the camera.
|
|
1306
|
+
* - "INTERNAL" — anything else.
|
|
1307
|
+
*
|
|
1308
|
+
* A user dismissing the picker is NOT an error — the promise resolves `null`.
|
|
1309
|
+
*/
|
|
1310
|
+
export class CameraError extends Error {
|
|
1311
|
+
constructor(code, message, opts) {
|
|
1312
|
+
super(message);
|
|
1313
|
+
this.name = "CameraError";
|
|
1314
|
+
this.code = code;
|
|
1315
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** Coerce a thrown value into a CameraError with a stable code. */
|
|
1320
|
+
function toCameraError(err) {
|
|
1321
|
+
if (err instanceof CameraError) return err;
|
|
1322
|
+
const raw = err && err.code !== undefined ? err.code : null;
|
|
1323
|
+
let code = "INTERNAL";
|
|
1324
|
+
if (raw === "PERMISSION_DENIED") code = "PERMISSION_DENIED";
|
|
1325
|
+
else if (raw === "UNSUPPORTED") code = "UNSUPPORTED";
|
|
1326
|
+
const message =
|
|
1327
|
+
(err && typeof err.message === "string" && err.message) ||
|
|
1328
|
+
"Camera request failed";
|
|
1329
|
+
return new CameraError(code, message, { cause: err });
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Take a photo or choose one from the device library. Returns
|
|
1334
|
+
* `{ asset, loading, error, supported, capture, pick, reset }`.
|
|
1335
|
+
*
|
|
1336
|
+
* Capture is IMPERATIVE — call `capture()` / `pick()` from a user gesture. The
|
|
1337
|
+
* OS and the browser gate the permission prompt on a gesture, so the hook never
|
|
1338
|
+
* opens the camera on mount.
|
|
1339
|
+
*
|
|
1340
|
+
* Both resolve to a normalised asset, or `null` when the user dismisses the
|
|
1341
|
+
* picker — dismissal is the most common outcome and is deliberately not an
|
|
1342
|
+
* error, so widgets need no try/catch on the happy path. They reject with a
|
|
1343
|
+
* `CameraError` for a genuine failure (permission refused, no host broker).
|
|
1344
|
+
*
|
|
1345
|
+
* The asset's `file` is already the right shape to upload on either host — a
|
|
1346
|
+
* `File` on web, a `{ uri, name, type }` part on native — so one code path
|
|
1347
|
+
* covers both:
|
|
1348
|
+
*
|
|
1349
|
+
* const fd = new FormData();
|
|
1350
|
+
* fd.append("file", asset.file);
|
|
1351
|
+
* await ctx.assets.upload(fd);
|
|
1352
|
+
*
|
|
1353
|
+
* Check `supported` before rendering a camera button; a host with no broker
|
|
1354
|
+
* reports false rather than throwing at render.
|
|
1355
|
+
*/
|
|
1356
|
+
export function useCamera(options) {
|
|
1357
|
+
const ctx = useWidgetContextOrThrow("useCamera");
|
|
1358
|
+
const [asset, setAsset] = useState(null);
|
|
1359
|
+
const [loading, setLoading] = useState(false);
|
|
1360
|
+
const [error, setError] = useState(null);
|
|
1361
|
+
|
|
1362
|
+
// `ctx` is a fresh identity every host render — hold the live client and
|
|
1363
|
+
// options in refs so the returned callbacks stay stable.
|
|
1364
|
+
const clientRef = useRef(ctx.device && ctx.device.camera);
|
|
1365
|
+
clientRef.current = ctx.device && ctx.device.camera;
|
|
1366
|
+
const optionsRef = useRef(options);
|
|
1367
|
+
optionsRef.current = options;
|
|
1368
|
+
// Web hands back a blob: URL per asset; abandoning it leaks the blob for the
|
|
1369
|
+
// life of the document, so the hook owns revoking the one it replaced.
|
|
1370
|
+
const releaseRef = useRef(null);
|
|
1371
|
+
const runRef = useRef(0);
|
|
1372
|
+
|
|
1373
|
+
const supported = Boolean(
|
|
1374
|
+
clientRef.current &&
|
|
1375
|
+
typeof clientRef.current.capture === "function" &&
|
|
1376
|
+
(typeof clientRef.current.isSupported !== "function" ||
|
|
1377
|
+
clientRef.current.isSupported()),
|
|
1378
|
+
);
|
|
1379
|
+
|
|
1380
|
+
const release = useCallback(() => {
|
|
1381
|
+
const revoke = releaseRef.current;
|
|
1382
|
+
releaseRef.current = null;
|
|
1383
|
+
if (typeof revoke === "function") {
|
|
1384
|
+
try {
|
|
1385
|
+
revoke();
|
|
1386
|
+
} catch {
|
|
1387
|
+
/* the host already released it */
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
}, []);
|
|
1391
|
+
|
|
1392
|
+
useEffect(() => () => release(), [release]);
|
|
1393
|
+
|
|
1394
|
+
const reset = useCallback(() => {
|
|
1395
|
+
runRef.current += 1;
|
|
1396
|
+
release();
|
|
1397
|
+
setAsset(null);
|
|
1398
|
+
setError(null);
|
|
1399
|
+
}, [release]);
|
|
1400
|
+
|
|
1401
|
+
// capture() and pick() differ only in which broker method they call, so both
|
|
1402
|
+
// run through one request path — otherwise the loading/abort bookkeeping
|
|
1403
|
+
// would exist twice and drift.
|
|
1404
|
+
const request = useCallback(
|
|
1405
|
+
async (method) => {
|
|
1406
|
+
const client = clientRef.current;
|
|
1407
|
+
if (
|
|
1408
|
+
!client ||
|
|
1409
|
+
typeof client[method] !== "function" ||
|
|
1410
|
+
(typeof client.isSupported === "function" && !client.isSupported())
|
|
1411
|
+
) {
|
|
1412
|
+
const e = new CameraError(
|
|
1413
|
+
"UNSUPPORTED",
|
|
1414
|
+
"This host does not provide camera access.",
|
|
1415
|
+
);
|
|
1416
|
+
setError(e);
|
|
1417
|
+
throw e;
|
|
1418
|
+
}
|
|
1419
|
+
const run = (runRef.current += 1);
|
|
1420
|
+
setLoading(true);
|
|
1421
|
+
setError(null);
|
|
1422
|
+
try {
|
|
1423
|
+
const next = await client[method](optionsRef.current || {});
|
|
1424
|
+
// A reset() or a newer request landed while this one was open — drop
|
|
1425
|
+
// the result rather than clobbering what the widget now shows.
|
|
1426
|
+
if (run !== runRef.current) {
|
|
1427
|
+
if (next && typeof next.release === "function") next.release();
|
|
1428
|
+
return null;
|
|
1429
|
+
}
|
|
1430
|
+
if (!next) return null;
|
|
1431
|
+
release();
|
|
1432
|
+
releaseRef.current =
|
|
1433
|
+
typeof next.release === "function" ? next.release : null;
|
|
1434
|
+
setAsset(next);
|
|
1435
|
+
return next;
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
const ce = toCameraError(err);
|
|
1438
|
+
if (run === runRef.current) setError(ce);
|
|
1439
|
+
throw ce;
|
|
1440
|
+
} finally {
|
|
1441
|
+
if (run === runRef.current) setLoading(false);
|
|
1442
|
+
}
|
|
1443
|
+
},
|
|
1444
|
+
[release],
|
|
1445
|
+
);
|
|
1446
|
+
|
|
1447
|
+
const capture = useCallback(() => request("capture"), [request]);
|
|
1448
|
+
const pick = useCallback(() => request("pick"), [request]);
|
|
1449
|
+
|
|
1450
|
+
return { asset, loading, error, supported, capture, pick, reset };
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1300
1453
|
/* ============================================================================
|
|
1301
1454
|
* DATASTORE CLIENT — ctx.datastore (@colixsystems/datastore-client)
|
|
1302
1455
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -1538,6 +1538,75 @@ export class SpeechToTextError extends Error {
|
|
|
1538
1538
|
);
|
|
1539
1539
|
}
|
|
1540
1540
|
|
|
1541
|
+
/**
|
|
1542
|
+
* Options for `useCamera(...)`. Both are HINTS the host honours where it can:
|
|
1543
|
+
* the Expo export applies them, the web file input applies neither — so never
|
|
1544
|
+
* depend on a cropped result or a capped file size.
|
|
1545
|
+
*/
|
|
1546
|
+
export interface CameraOptions {
|
|
1547
|
+
/** Let the user crop/rotate before returning. Native only. Defaults to false. */
|
|
1548
|
+
allowsEditing?: boolean;
|
|
1549
|
+
/** 0–1 compression quality. Native only. Defaults to 0.8. */
|
|
1550
|
+
quality?: number;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** A photo taken or picked through `useCamera()`, normalised across hosts. */
|
|
1554
|
+
export interface CameraAsset {
|
|
1555
|
+
/** Displayable source — `<Image source={{ uri }} />` / `<img src>`. */
|
|
1556
|
+
uri: string;
|
|
1557
|
+
/** File name, derived from the source when the host supplies none. */
|
|
1558
|
+
name: string;
|
|
1559
|
+
mimeType: string;
|
|
1560
|
+
width: number | null;
|
|
1561
|
+
height: number | null;
|
|
1562
|
+
/** Bytes, when the host reports it. */
|
|
1563
|
+
size: number | null;
|
|
1564
|
+
/**
|
|
1565
|
+
* Ready-to-upload part — a `File` on web, `{ uri, name, type }` on native.
|
|
1566
|
+
* Append it to a FormData and hand that to `ctx.assets.upload(...)`.
|
|
1567
|
+
*/
|
|
1568
|
+
file: unknown;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
export interface CameraResult {
|
|
1572
|
+
/** The most recent asset, or null before the first capture / after reset. */
|
|
1573
|
+
asset: CameraAsset | null;
|
|
1574
|
+
loading: boolean;
|
|
1575
|
+
error: CameraError | null;
|
|
1576
|
+
/** False when the host brokers no camera. */
|
|
1577
|
+
supported: boolean;
|
|
1578
|
+
/** Open the camera. Resolves null if the user dismisses it. */
|
|
1579
|
+
capture(): Promise<CameraAsset | null>;
|
|
1580
|
+
/** Open the photo library. Resolves null if the user dismisses it. */
|
|
1581
|
+
pick(): Promise<CameraAsset | null>;
|
|
1582
|
+
/** Clear `asset` and `error`, releasing the held asset. */
|
|
1583
|
+
reset(): void;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Take a photo or choose one from the device library. Capture is imperative
|
|
1588
|
+
* (call `capture()` / `pick()` from a user gesture; it never opens on mount).
|
|
1589
|
+
* The same hook drives both platforms — the web Player brokers it via a file
|
|
1590
|
+
* input, the Expo export via `expo-image-picker`. Dismissing the picker
|
|
1591
|
+
* resolves `null` rather than rejecting; a genuine failure rejects with a
|
|
1592
|
+
* `CameraError`. Safe to call on a host that brokers no camera: `supported` is
|
|
1593
|
+
* then false, so gate the camera button on it.
|
|
1594
|
+
*/
|
|
1595
|
+
export function useCamera(options?: CameraOptions): CameraResult;
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Error surfaced by `useCamera()` — thrown by `capture()` / `pick()` and stored
|
|
1599
|
+
* in the hook's `error` slot. `code` is a stable categorisation.
|
|
1600
|
+
*/
|
|
1601
|
+
export class CameraError extends Error {
|
|
1602
|
+
code: "PERMISSION_DENIED" | "UNSUPPORTED" | "INTERNAL";
|
|
1603
|
+
constructor(
|
|
1604
|
+
code: CameraError["code"],
|
|
1605
|
+
message: string,
|
|
1606
|
+
opts?: { cause?: unknown },
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1541
1610
|
/**
|
|
1542
1611
|
* Error class thrown by useDatastoreMutation callbacks (and surfaced by
|
|
1543
1612
|
* useDatastoreQuery in its `error` slot). The `code` is a stable
|
|
@@ -2243,3 +2312,15 @@ export const CONTRACT: AiWidgetContract;
|
|
|
2243
2312
|
|
|
2244
2313
|
export function isHookAllowed(name: string): boolean;
|
|
2245
2314
|
export function requiredContextKeys(): string[];
|
|
2315
|
+
|
|
2316
|
+
/**
|
|
2317
|
+
* sc-6531 (REQ-AI-AGENT-DESIGN-LIFT) — interaction feedback for a tappable
|
|
2318
|
+
* surface. Call inside a Pressable's style function and spread the result:
|
|
2319
|
+
* `<Pressable style={(state) => [styles.card, ...pressableLift(state)]}>`.
|
|
2320
|
+
* Web raises the surface while hovered or pressed (with the transition built
|
|
2321
|
+
* in); native raises it while pressed.
|
|
2322
|
+
*/
|
|
2323
|
+
export function pressableLift(state?: {
|
|
2324
|
+
hovered?: boolean;
|
|
2325
|
+
pressed?: boolean;
|
|
2326
|
+
}): Array<Record<string, unknown> | null>;
|
package/dist/index.js
CHANGED
|
@@ -80,6 +80,8 @@ export {
|
|
|
80
80
|
GeolocationError,
|
|
81
81
|
useSpeechToText,
|
|
82
82
|
SpeechToTextError,
|
|
83
|
+
useCamera,
|
|
84
|
+
CameraError,
|
|
83
85
|
WidgetTree,
|
|
84
86
|
} from "./hooks.js";
|
|
85
87
|
export { isNarrowWidth, NARROW_WIDTH_PX } from "./container-width.js";
|
|
@@ -127,3 +129,4 @@ export {
|
|
|
127
129
|
scaleSpacing,
|
|
128
130
|
} from "./contract.js";
|
|
129
131
|
export { normalizeLucideIconName } from "./lucideIconName.js";
|
|
132
|
+
export { pressableLift } from "./interaction.js";
|
package/dist/index.native.js
CHANGED
|
@@ -80,6 +80,8 @@ export {
|
|
|
80
80
|
GeolocationError,
|
|
81
81
|
useSpeechToText,
|
|
82
82
|
SpeechToTextError,
|
|
83
|
+
useCamera,
|
|
84
|
+
CameraError,
|
|
83
85
|
WidgetTree,
|
|
84
86
|
} from "./hooks.js";
|
|
85
87
|
export { isNarrowWidth, NARROW_WIDTH_PX } from "./container-width.js";
|
|
@@ -125,3 +127,4 @@ export {
|
|
|
125
127
|
scaleSpacing,
|
|
126
128
|
} from "./contract.js";
|
|
127
129
|
export { normalizeLucideIconName } from "./lucideIconName.js";
|
|
130
|
+
export { pressableLift } from "./interaction.native.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// sc-6531 (REQ-AI-AGENT-DESIGN-LIFT) — the web half of the lift affordance.
|
|
2
|
+
// react-native-web's Pressable hands its style function `{ hovered, pressed }`;
|
|
3
|
+
// either one raises the surface, and the smoothing rides the RESTING style so
|
|
4
|
+
// the lift animates in both directions. The touch half is interaction.native.js
|
|
5
|
+
// — no hover exists there, and a pressed state change is instant.
|
|
6
|
+
import { CONTRACT } from "./contract.js";
|
|
7
|
+
|
|
8
|
+
const LIFT = CONTRACT.themeTokens.interaction.lift;
|
|
9
|
+
const SMOOTHING = Object.freeze({
|
|
10
|
+
transitionProperty: "transform, box-shadow",
|
|
11
|
+
transitionDuration: `${LIFT.transitionMs}ms`,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export function pressableLift(state = {}) {
|
|
15
|
+
return [SMOOTHING, state.hovered || state.pressed ? LIFT.raised : null];
|
|
16
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// sc-6531 (REQ-AI-AGENT-DESIGN-LIFT) — the touch half of the lift affordance:
|
|
2
|
+
// there is no hover, the press state raises the surface, and the change is
|
|
3
|
+
// instant (RN styles carry no transitions). The pointer half is interaction.js.
|
|
4
|
+
import { CONTRACT } from "./contract.js";
|
|
5
|
+
|
|
6
|
+
const LIFT = CONTRACT.themeTokens.interaction.lift;
|
|
7
|
+
|
|
8
|
+
export function pressableLift(state = {}) {
|
|
9
|
+
return [state.pressed ? LIFT.raised : null];
|
|
10
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.114.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
],
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "node scripts/build.js",
|
|
51
|
-
"test": "node --test src/__tests__/contract.test.js src/__tests__/vetted-imports-audit.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-hardcoded-design.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/corner-radius.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js"
|
|
51
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/vetted-imports-audit.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-hardcoded-design.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/corner-radius.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/interaction-lift.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-camera.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=18"
|