@colixsystems/widget-sdk 0.289.0 → 0.291.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 CHANGED
@@ -90,7 +90,15 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
90
90
 
91
91
  ## Status
92
92
 
93
- `v0.289.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**.
93
+ `v0.291.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**.
94
+
95
+ ### What's new in 0.291.0 (contract 1.238.0)
96
+
97
+ **New linter rule `grid-cell-too-narrow` (severity `error`, blocking).** A `<Grid minCellWidth={N}>` with `N` under 200 whose cell holds more than one `Text` is refused, naming the cell width and the default of 240: on cards carrying a title, a duration and a price it makes five narrow columns at desktop width, with broken words and the price wrapped onto two lines. A cell of a single `Text` (a chip, an avatar, an icon tile) passes at any width, and a Grid without `minCellWidth` passes. A width given as a `const` is followed; one computed at runtime is beyond the scan. See *Design & visual polish*.
98
+
99
+ ### What's new in 0.290.0 (contract 1.238.0)
100
+
101
+ **New `useConnector(connectorRef)` hook — a widget can call one of the workspace's REST connectors and use the answer itself.** Bind a connector with a `{ type: "connectorRef" }` property and declare `connectors.call:*` in `requestedScopes`. `call(operation, { params, query, body })` names an operation the workspace listed for apps on that connector — never a method or a path — and resolves `{ status, content_type, body }` with the outside API's status and parsed body. The whole call has 250 ms, token exchange included: a slower outside API is cut off and the call rejects with `UPSTREAM_TIMEOUT`. The credential is added on the server and never reaches the app. A new `connectors` host client (`createConnectorsClient` in `@colixsystems/actions-client`), the `connectorRef` property type, the `connectors.call:*` scope and a `ConnectorError`. Everything is additive.
94
102
 
95
103
  ### What's new in 0.289.0 (contract 1.237.0)
96
104
 
@@ -3488,6 +3496,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
3488
3496
  - **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.
3489
3497
  - **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.
3490
3498
  - **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**: `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. **Lay out a card grid, a tile row or a grid of capped cells with `<Grid>` by default** — it measures its own unpadded box and applies the rules below; lay one out yourself when it needs what `<Grid>` cannot express, such as masonry or cells that span lines. **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.
3499
+ - **A Grid cell that holds more than one `Text` is at least 200 wide.** A cell that holds more than one Text is at least 200 wide: `<Grid minCellWidth={150}>` makes five narrow columns at desktop width, and a card carrying a title, a duration and a price breaks words and wraps the price onto two lines there. Leave `minCellWidth` off (the default is 240) or set it to 240 or more; a width under 200 is for cells of a single `Text` (chips, avatars, icon tiles), and the publish gate refuses (`grid-cell-too-narrow`) a narrower Grid whose cell holds more than one `Text`. An action inside a card is a `<Button>`, never a `Pressable` around text.
3491
3500
  - **Compose forms — pair fields into rows, don't stack one per row.** Lay a form out with `<Form>` by default (above), which does the following for you; when it needs what `<Form>` cannot do (a multi-step wizard, a form beside a live preview, fields laid over a picture), 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 — 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. Any other icon goes inside the field through `leadingIcon` / `trailingIcon`; never wrap a `<TextField>` in a bordered row, since it draws its own border. Put a 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.
3492
3501
  - **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.
3493
3502
  - **Drag and drop — show what is being dragged.** Use `<SortableList>` for a list the user reorders and `<Board>` for cards moved between columns by default: they do everything this item describes, on both hosts, and give a user who cannot drag the same moves from the handle. When the request needs what they cannot do (free-form positioning on a canvas, a drag between two widgets), build your own as follows. 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.
@@ -3900,6 +3909,8 @@ Pass `--manifest` to enable `style-field-unread` and the three editability rules
3900
3909
 
3901
3910
  `flex-basis-percent` flags a literal percentage `flexBasis` — `"100%"`, `"50%"`, or the ternary a responsive cell writes. `flex-basis` sizes the MAIN axis, so in a column parent it asks for the parent's HEIGHT: under an ancestor that makes that height definite (a Grid cell stretches its child on the web Player) the child fills the container and, since a `View` never shrinks, overflows over everything below it. Say full-width with `width: "100%"` — correct in a column AND in a wrap row — and keep a NUMBER for a wrap threshold. A warning, because the percentage is right in a row and a text scan cannot see the parent.
3902
3911
 
3912
+ `grid-cell-too-narrow` refuses a `<Grid minCellWidth={N}>` with `N` under 200 whose cell holds more than one `Text`, naming the cell width and the default of 240: a card with a title and a price breaks its words at that width. A cell of a single `Text` passes at any width, and a Grid without `minCellWidth` passes. An error.
3913
+
3903
3914
  `row-action-not-gated` flags a widget that calls `update` or `delete` from `useDatastoreMutation` without a per-row permission check: no `_capabilities` read and no `useCanWrite(tableId, { recordId })`. The table-level `useCanWrite(tableId)` only answers "may create", so a list gated on it shows Edit / Delete on rows the server will refuse. A warning.
3904
3915
 
3905
3916
  `no-html-in-content` flags an HTML tag inside a **string** — `"<strong>"`, `"<br>"`, `"<p>…</p>"`. There is no HTML renderer on either host, so the tag reaches the reader as literal text; author formatted text with `<MarkdownInput>` and render it with `<RichText>` instead. Only string and template content is scanned, so your own `<View>` / `<Text>` JSX can never trip it. A warning for a human author, blocking for the AI widget agent.
package/dist/contract.cjs CHANGED
@@ -2299,6 +2299,46 @@ const HOOKS = [
2299
2299
  requiredContextSlice: ["actions.run"],
2300
2300
  scopes: ["actions.run:*"],
2301
2301
  },
2302
+ {
2303
+ name: "useConnector",
2304
+ signature: "useConnector(connectorRef)",
2305
+ description:
2306
+ "sc-15659 — call one of the WORKSPACE's REST connectors (a configured " +
2307
+ "outside API) straight from the widget, and use the answer in the " +
2308
+ "widget. call(operation, { params, query, body }) names an OPERATION " +
2309
+ "the workspace listed for apps on that connector, never a method or a " +
2310
+ "path: the server builds the request from the listing and adds the " +
2311
+ "connector's credential, which never reaches the app. `params` fills " +
2312
+ "the operation's {name} path segments (one whole segment each, no " +
2313
+ "slashes); `query` may use only the names the operation lists; `body` " +
2314
+ "only where the operation takes one. call resolves with { status, " +
2315
+ "content_type, body } — the outside API's status (a non-2xx answer " +
2316
+ "still resolves: branch on status) and its parsed JSON or text body. " +
2317
+ "The whole call must finish within 250 ms, token exchange included: a " +
2318
+ "slower outside API is cut off and the call rejects with UPSTREAM_TIMEOUT. " +
2319
+ "May run on mount for a read operation; an operation that changes data " +
2320
+ "belongs in an event handler. `connectorRef` is a connector id an " +
2321
+ "author bound through a `connectorRef` property — when it is missing, " +
2322
+ "or the host brokers no connectors client, `available` is false and " +
2323
+ "call() rejects UNAVAILABLE; render a visible 'pick a connector' state, " +
2324
+ "NEVER a blank box. Only signed-in app users may call. ConnectorError.code: " +
2325
+ "OPERATION_NOT_ALLOWED (403), CONNECTOR_NOT_FOUND (404, also when the " +
2326
+ "connector is not callable from the app) and CONNECTOR_SETUP_INCOMPLETE " +
2327
+ "(409) will NOT clear on a retry, so show an 'isn't available' state; " +
2328
+ "RATE_LIMITED (429) will; INVALID_CONNECTOR_PARAMS (400) means an " +
2329
+ "argument the operation does not take; UPSTREAM_ERROR (502) and " +
2330
+ "UPSTREAM_TIMEOUT (504) mean the outside API failed; AUTH_REQUIRED, " +
2331
+ "TOKEN_EXPIRED and TOKEN_INVALID mean the app user must sign in. NEVER " +
2332
+ "show an app user a raw error. Reads ctx.connectors.",
2333
+ returnShape: {
2334
+ available: "boolean // false when no connector is bound or the host brokers none",
2335
+ call: "(operation, { params?, query?, body? }) => Promise<{ status, content_type, body }> // rejects with ConnectorError",
2336
+ calling: "boolean // true while any call is in flight",
2337
+ error: "ConnectorError | null // the last call's error; cleared by the next call or a rebind",
2338
+ },
2339
+ requiredContextSlice: ["connectors.call"],
2340
+ scopes: ["connectors.call:*"],
2341
+ },
2302
2342
  {
2303
2343
  name: "useDatastoreMutation",
2304
2344
  signature: "useDatastoreMutation(tableId)",
@@ -4342,6 +4382,17 @@ const WIDGET_CONTEXT_SHAPE = {
4342
4382
  required: false,
4343
4383
  fields: { run: "function", status: "function" },
4344
4384
  },
4385
+ connectors: {
4386
+ description:
4387
+ "sc-15659 — injected connectors client from @colixsystems/actions-client. " +
4388
+ "{ call(connectorId, operation, { params, query, body }) -> Promise<{ status, content_type, body }> }. " +
4389
+ "Backs useConnector(). Calls one operation the workspace listed on an app-callable " +
4390
+ "connector, as the signed-in caller; the credential is added server-side. Errors carry " +
4391
+ "the server's `code` and `status`. Absent on a host that brokers none, where " +
4392
+ "useConnector() degrades to available: false rather than throwing.",
4393
+ required: false,
4394
+ fields: { call: "function" },
4395
+ },
4345
4396
  linkPreviews: {
4346
4397
  description:
4347
4398
  "Injected @colixsystems/link-preview-client instance. { get(url) -> Promise<{ url, title, description, site_name, image_url }> }. " +
@@ -6751,7 +6802,10 @@ const CONTRACT = deepFreeze({
6751
6802
  // PaymentError codes.
6752
6803
  // 1.237.0: widening — `manifest.translations` may declare up to 500 keys
6753
6804
  // (was 200).
6754
- version: "1.237.0",
6805
+ // 1.238.0: additive (sc-15659) — `useConnector(connectorRef)` + the
6806
+ // `connectors` host client + the `connectorRef` propertySchema type + the
6807
+ // `connectors.call:*` scope. A widget calls a REST connector's listed operations.
6808
+ version: "1.238.0",
6755
6809
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6756
6810
  hooks: HOOKS,
6757
6811
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -2295,6 +2295,46 @@ const HOOKS = [
2295
2295
  requiredContextSlice: ["actions.run"],
2296
2296
  scopes: ["actions.run:*"],
2297
2297
  },
2298
+ {
2299
+ name: "useConnector",
2300
+ signature: "useConnector(connectorRef)",
2301
+ description:
2302
+ "sc-15659 — call one of the WORKSPACE's REST connectors (a configured " +
2303
+ "outside API) straight from the widget, and use the answer in the " +
2304
+ "widget. call(operation, { params, query, body }) names an OPERATION " +
2305
+ "the workspace listed for apps on that connector, never a method or a " +
2306
+ "path: the server builds the request from the listing and adds the " +
2307
+ "connector's credential, which never reaches the app. `params` fills " +
2308
+ "the operation's {name} path segments (one whole segment each, no " +
2309
+ "slashes); `query` may use only the names the operation lists; `body` " +
2310
+ "only where the operation takes one. call resolves with { status, " +
2311
+ "content_type, body } — the outside API's status (a non-2xx answer " +
2312
+ "still resolves: branch on status) and its parsed JSON or text body. " +
2313
+ "The whole call must finish within 250 ms, token exchange included: a " +
2314
+ "slower outside API is cut off and the call rejects with UPSTREAM_TIMEOUT. " +
2315
+ "May run on mount for a read operation; an operation that changes data " +
2316
+ "belongs in an event handler. `connectorRef` is a connector id an " +
2317
+ "author bound through a `connectorRef` property — when it is missing, " +
2318
+ "or the host brokers no connectors client, `available` is false and " +
2319
+ "call() rejects UNAVAILABLE; render a visible 'pick a connector' state, " +
2320
+ "NEVER a blank box. Only signed-in app users may call. ConnectorError.code: " +
2321
+ "OPERATION_NOT_ALLOWED (403), CONNECTOR_NOT_FOUND (404, also when the " +
2322
+ "connector is not callable from the app) and CONNECTOR_SETUP_INCOMPLETE " +
2323
+ "(409) will NOT clear on a retry, so show an 'isn't available' state; " +
2324
+ "RATE_LIMITED (429) will; INVALID_CONNECTOR_PARAMS (400) means an " +
2325
+ "argument the operation does not take; UPSTREAM_ERROR (502) and " +
2326
+ "UPSTREAM_TIMEOUT (504) mean the outside API failed; AUTH_REQUIRED, " +
2327
+ "TOKEN_EXPIRED and TOKEN_INVALID mean the app user must sign in. NEVER " +
2328
+ "show an app user a raw error. Reads ctx.connectors.",
2329
+ returnShape: {
2330
+ available: "boolean // false when no connector is bound or the host brokers none",
2331
+ call: "(operation, { params?, query?, body? }) => Promise<{ status, content_type, body }> // rejects with ConnectorError",
2332
+ calling: "boolean // true while any call is in flight",
2333
+ error: "ConnectorError | null // the last call's error; cleared by the next call or a rebind",
2334
+ },
2335
+ requiredContextSlice: ["connectors.call"],
2336
+ scopes: ["connectors.call:*"],
2337
+ },
2298
2338
  {
2299
2339
  name: "useDatastoreMutation",
2300
2340
  signature: "useDatastoreMutation(tableId)",
@@ -4338,6 +4378,17 @@ const WIDGET_CONTEXT_SHAPE = {
4338
4378
  required: false,
4339
4379
  fields: { run: "function", status: "function" },
4340
4380
  },
4381
+ connectors: {
4382
+ description:
4383
+ "sc-15659 — injected connectors client from @colixsystems/actions-client. " +
4384
+ "{ call(connectorId, operation, { params, query, body }) -> Promise<{ status, content_type, body }> }. " +
4385
+ "Backs useConnector(). Calls one operation the workspace listed on an app-callable " +
4386
+ "connector, as the signed-in caller; the credential is added server-side. Errors carry " +
4387
+ "the server's `code` and `status`. Absent on a host that brokers none, where " +
4388
+ "useConnector() degrades to available: false rather than throwing.",
4389
+ required: false,
4390
+ fields: { call: "function" },
4391
+ },
4341
4392
  linkPreviews: {
4342
4393
  description:
4343
4394
  "Injected @colixsystems/link-preview-client instance. { get(url) -> Promise<{ url, title, description, site_name, image_url }> }. " +
@@ -6747,7 +6798,10 @@ const CONTRACT = deepFreeze({
6747
6798
  // PaymentError codes.
6748
6799
  // 1.237.0: widening — `manifest.translations` may declare up to 500 keys
6749
6800
  // (was 200).
6750
- version: "1.237.0",
6801
+ // 1.238.0: additive (sc-15659) — `useConnector(connectorRef)` + the
6802
+ // `connectors` host client + the `connectorRef` propertySchema type + the
6803
+ // `connectors.call:*` scope. A widget calls a REST connector's listed operations.
6804
+ version: "1.238.0",
6751
6805
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6752
6806
  hooks: HOOKS,
6753
6807
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -4118,6 +4118,115 @@ export function useAction(actionRef) {
4118
4118
  };
4119
4119
  }
4120
4120
 
4121
+ /**
4122
+ * sc-15659 — what useConnector() rejects with. `code` is one of:
4123
+ * - "UNAVAILABLE" — no connector bound, or the host brokers no
4124
+ * connectors client (render the unbound state)
4125
+ * - "OPERATION_NOT_ALLOWED" — 403 (the workspace did not list this operation)
4126
+ * - "INVALID_CONNECTOR_PARAMS" — 400 (an argument the operation does not take)
4127
+ * - "CONNECTOR_NOT_FOUND" — 404 (gone, or no longer callable from the app)
4128
+ * - "CONNECTOR_SETUP_INCOMPLETE" — 409 (the workspace has not finished its sign-in)
4129
+ * - "RATE_LIMITED" — 429 (too many calls — retry shortly)
4130
+ * - "AUTH_REQUIRED" / "TOKEN_EXPIRED" / "TOKEN_INVALID" — 401 (no app session)
4131
+ * - "UPSTREAM_ERROR" / "UPSTREAM_TIMEOUT" — 502 / 504 (the outside API failed)
4132
+ * - "CONNECTOR_FAILED" — no answer came
4133
+ *
4134
+ * The connectors client owns these codes; any other server code passes through.
4135
+ */
4136
+ export class ConnectorError extends Error {
4137
+ constructor(code, message, opts) {
4138
+ super(message);
4139
+ this.name = "ConnectorError";
4140
+ this.code = code;
4141
+ if (opts && opts.cause) this.cause = opts.cause;
4142
+ }
4143
+ }
4144
+
4145
+ function toConnectorError(err) {
4146
+ if (err instanceof ConnectorError) return err;
4147
+ const code = err && typeof err.code === "string" && err.code ? err.code : "CONNECTOR_FAILED";
4148
+ const message = (err && err.message) || "The connector call failed";
4149
+ return new ConnectorError(code, message, { cause: err });
4150
+ }
4151
+
4152
+ /**
4153
+ * sc-15659 — call one of the workspace's REST connectors through an operation
4154
+ * the workspace listed for apps. Returns `{ available, call, calling, error }`.
4155
+ *
4156
+ * call(operation, { params?, query?, body? }) → Promise<{ status, content_type, body }>
4157
+ *
4158
+ * The widget owns what it does with the answer. A non-2xx upstream status
4159
+ * still resolves; a platform refusal rejects with a ConnectorError. The
4160
+ * connector's credential is added on the server and never reaches the app.
4161
+ *
4162
+ * `connectorRef` is the connector id an author bound through a
4163
+ * `connectorRef` property. When it is absent, or the host brokers no
4164
+ * connectors client, `available` is false and `call` rejects UNAVAILABLE
4165
+ * rather than throwing at render — render a visible unbound state.
4166
+ *
4167
+ * Routes through the injected `@colixsystems/actions-client` at
4168
+ * `ctx.connectors`, so the Player and the Expo export call identically.
4169
+ */
4170
+ export function useConnector(connectorRef) {
4171
+ const ctx = useWidgetContextOrThrow("useConnector");
4172
+ const available =
4173
+ Boolean(connectorRef) && Boolean(ctx.connectors) && typeof ctx.connectors.call === "function";
4174
+
4175
+ const clientRef = useRef(null);
4176
+ clientRef.current = available ? ctx.connectors : null;
4177
+ const connectorIdRef = useRef(connectorRef);
4178
+ connectorIdRef.current = connectorRef;
4179
+
4180
+ const mountedRef = useRef(true);
4181
+ useEffect(() => {
4182
+ mountedRef.current = true;
4183
+ return () => {
4184
+ mountedRef.current = false;
4185
+ };
4186
+ }, []);
4187
+
4188
+ // A rebind starts clean: another connector's error never shows.
4189
+ const [outcome, setOutcome] = useState({ boundTo: connectorRef, pending: 0, error: null });
4190
+
4191
+ const call = useCallback(async (operation, args) => {
4192
+ const client = clientRef.current;
4193
+ const connectorId = connectorIdRef.current;
4194
+ if (!client || !connectorId) {
4195
+ throw new ConnectorError("UNAVAILABLE", "No connector is available for this host");
4196
+ }
4197
+ const settle = (error) => {
4198
+ if (!mountedRef.current) return;
4199
+ setOutcome((prior) =>
4200
+ prior.boundTo === connectorId
4201
+ ? { boundTo: connectorId, pending: Math.max(0, prior.pending - 1), error }
4202
+ : prior,
4203
+ );
4204
+ };
4205
+ setOutcome((prior) =>
4206
+ prior.boundTo === connectorId
4207
+ ? { ...prior, pending: prior.pending + 1, error: null }
4208
+ : { boundTo: connectorId, pending: 1, error: null },
4209
+ );
4210
+ try {
4211
+ const answer = await client.call(connectorId, operation, args);
4212
+ settle(null);
4213
+ return answer;
4214
+ } catch (err) {
4215
+ const e = toConnectorError(err);
4216
+ settle(e);
4217
+ throw e;
4218
+ }
4219
+ }, []);
4220
+
4221
+ const current = outcome.boundTo === connectorRef;
4222
+ return {
4223
+ available,
4224
+ call,
4225
+ calling: current && outcome.pending > 0,
4226
+ error: current ? outcome.error : null,
4227
+ };
4228
+ }
4229
+
4121
4230
  /**
4122
4231
  * Datastore mutation hook. Returns { create, update, delete }, each method
4123
4232
  * returning a Promise. Routes through the injected
package/dist/index.d.ts CHANGED
@@ -910,6 +910,10 @@ export interface WidgetContext<TProps = unknown> {
910
910
  /** sc-14224 — whether a run would reach the script; optional on older hosts. */
911
911
  status?(actionId: string): Promise<{ available: boolean; reason: string | null }>;
912
912
  };
913
+ /** sc-15659 — injected @colixsystems/actions-client backing useConnector(). */
914
+ connectors?: {
915
+ call(connectorId: string, operation: string, args?: ConnectorCallArgs): Promise<ConnectorCallResult>;
916
+ };
913
917
  /** Injected @colixsystems/link-preview-client backing useLinkPreview(). */
914
918
  linkPreviews?: { get(url: string): Promise<LinkPreview> };
915
919
  /** Host child-node renderer; backs WidgetTree / useChildRenderer. */
@@ -2105,6 +2109,40 @@ export interface ActionApi {
2105
2109
  */
2106
2110
  export function useAction(actionRef: string | null | undefined): ActionApi;
2107
2111
 
2112
+ export interface ConnectorCallArgs {
2113
+ /** Values for the operation's `{name}` path segments; one whole segment each. */
2114
+ params?: Record<string, string | number>;
2115
+ /** Only the query names the operation lists. */
2116
+ query?: Record<string, string | number | boolean | Array<string | number | boolean>>;
2117
+ /** A JSON body, only for an operation that takes one. */
2118
+ body?: unknown;
2119
+ }
2120
+
2121
+ /** The outside API's answer: its status (non-2xx still resolves) and parsed body. */
2122
+ export interface ConnectorCallResult {
2123
+ status: number;
2124
+ content_type: string | null;
2125
+ body: unknown;
2126
+ }
2127
+
2128
+ export interface ConnectorApi {
2129
+ /** False when no connector is bound or the host brokers no connectors client. */
2130
+ available: boolean;
2131
+ /** Call one operation the workspace listed; rejects with ConnectorError. */
2132
+ call(operation: string, args?: ConnectorCallArgs): Promise<ConnectorCallResult>;
2133
+ /** True while any call is in flight. */
2134
+ calling: boolean;
2135
+ /** The last call's error; cleared by the next call or a rebind. */
2136
+ error: ConnectorError | null;
2137
+ }
2138
+
2139
+ /**
2140
+ * sc-15659 — call one of the workspace's REST connectors through an operation
2141
+ * the workspace listed for apps. The widget needs `connectors.call:*` in
2142
+ * `requestedScopes`; the credential never reaches the app.
2143
+ */
2144
+ export function useConnector(connectorRef: string | null | undefined): ConnectorApi;
2145
+
2108
2146
  // REQ-RT-07 realtime subscription transport state.
2109
2147
  export type DatastoreSubscriptionStatus =
2110
2148
  | "connecting"
@@ -3441,6 +3479,30 @@ export class AgentError extends Error {
3441
3479
  * `RATE_LIMITED` will. `AUTH_REQUIRED`, `TOKEN_EXPIRED` and `TOKEN_INVALID`
3442
3480
  * mean the app user must sign in. Never show an app user the raw message.
3443
3481
  */
3482
+ /**
3483
+ * sc-15659 — structured error thrown by `useConnector().call`. Branch on
3484
+ * `code`: `OPERATION_NOT_ALLOWED`, `CONNECTOR_NOT_FOUND` and
3485
+ * `CONNECTOR_SETUP_INCOMPLETE` will not clear on a retry, `RATE_LIMITED` will.
3486
+ * Never show an app user the raw message.
3487
+ */
3488
+ export class ConnectorError extends Error {
3489
+ code:
3490
+ | "UNAVAILABLE"
3491
+ | "INVALID_CONNECTOR_PARAMS"
3492
+ | "OPERATION_NOT_ALLOWED"
3493
+ | "CONNECTOR_NOT_FOUND"
3494
+ | "CONNECTOR_SETUP_INCOMPLETE"
3495
+ | "RATE_LIMITED"
3496
+ | "AUTH_REQUIRED"
3497
+ | "TOKEN_EXPIRED"
3498
+ | "TOKEN_INVALID"
3499
+ | "UPSTREAM_ERROR"
3500
+ | "UPSTREAM_TIMEOUT"
3501
+ | "CONNECTOR_FAILED"
3502
+ | (string & {});
3503
+ constructor(code: string, message: string, opts?: { cause?: unknown });
3504
+ }
3505
+
3444
3506
  export class ActionError extends Error {
3445
3507
  code:
3446
3508
  | "UNAVAILABLE"
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ export {
30
30
  PermissionError,
31
31
  AgentError,
32
32
  ActionError,
33
+ ConnectorError,
33
34
  useDatastoreQuery,
34
35
  useDatastoreRecord,
35
36
  useDatastoreSchema,
@@ -39,6 +40,7 @@ export {
39
40
  useInterpretDraft,
40
41
  useAgent,
41
42
  useAction,
43
+ useConnector,
42
44
  useAsset,
43
45
  useAssetsByTag,
44
46
  useBundledAsset,
@@ -30,6 +30,7 @@ export {
30
30
  PermissionError,
31
31
  AgentError,
32
32
  ActionError,
33
+ ConnectorError,
33
34
  useDatastoreQuery,
34
35
  useDatastoreRecord,
35
36
  useDatastoreSchema,
@@ -39,6 +40,7 @@ export {
39
40
  useInterpretDraft,
40
41
  useAgent,
41
42
  useAction,
43
+ useConnector,
42
44
  useAsset,
43
45
  useAssetsByTag,
44
46
  useBundledAsset,
package/dist/linter.cjs CHANGED
@@ -861,6 +861,16 @@ function _scopeRules(source, manifest) {
861
861
  snippet: "",
862
862
  });
863
863
  }
864
+ // sc-15659 — DISCLOSURE, as for useAction: the server gate is the app-user
865
+ // JWT plus the connector's app opt-in and listed operations.
866
+ if (/\buseConnector\s*\(/.test(source) && !declared.has("connectors.call:*")) {
867
+ findings.push({
868
+ rule: "scope-required-for-useConnector",
869
+ label: "useConnector() requires `connectors.call:*` in manifest.requestedScopes",
870
+ line: 0,
871
+ snippet: "",
872
+ });
873
+ }
864
874
  const lines = source.split(/\r?\n/);
865
875
  let firedUsersWrite = false;
866
876
  let firedUsersDelete = false;
@@ -2155,6 +2165,79 @@ function _fieldInBorderedRowRules(source) {
2155
2165
  return findings;
2156
2166
  }
2157
2167
 
2168
+ // sc-15754 — a Grid cell narrower than the default fits one Text, not a card.
2169
+ //
2170
+ // `<Grid minCellWidth={150}>` on cards carrying a title, a duration and a
2171
+ // price made five narrow columns at desktop width, with broken words and the
2172
+ // prices wrapped onto two lines; the agent cannot see that until it renders.
2173
+ // A cell of a single Text (a chip, an avatar, an icon tile) is fine at any
2174
+ // width, so only a cell holding more than one Text is refused. A width read
2175
+ // from a `const` is followed; one computed at runtime stays beyond the scan.
2176
+ const _GRID_DEFAULT_MIN_CELL_WIDTH = 240; // grid-logic.js DEFAULT_MIN_CELL_WIDTH
2177
+ const _GRID_NARROW_CELL_WIDTH = 200;
2178
+ const _MIN_CELL_WIDTH_ATTR_RE =
2179
+ /\bminCellWidth\s*=\s*\{\s*(\d+(?:\.\d+)?|[A-Za-z_$][\w$]*)\s*\}/;
2180
+
2181
+ function _gridMinCellWidth(structure, attrs) {
2182
+ const hit = _MIN_CELL_WIDTH_ATTR_RE.exec(attrs);
2183
+ if (!hit) return NaN;
2184
+ if (/^\d/.test(hit[1])) return Number(hit[1]);
2185
+ const declared = new RegExp(`\\bconst\\s+${hit[1]}\\s*=\\s*(\\d+(?:\\.\\d+)?)\\s*;`).exec(structure);
2186
+ return declared ? Number(declared[1]) : NaN;
2187
+ }
2188
+
2189
+ function _gridCellWidthRules(source) {
2190
+ const findings = [];
2191
+ const structure = _stripNonCode(source);
2192
+ const index = _jsxScanIndex(structure);
2193
+ const sourceLines = source.split(/\r?\n/);
2194
+ const lineAt = _lineCounter(structure);
2195
+ const open = _openTagStack();
2196
+ _JSX_TOKEN_RE.lastIndex = 0;
2197
+ let token;
2198
+ while ((token = _JSX_TOKEN_RE.exec(structure))) {
2199
+ const [, closing, name] = token;
2200
+ const parent = open.top();
2201
+ if (!name) {
2202
+ if (structure[token.index + 1 + closing.length] !== ">") continue;
2203
+ // A fragment is transparent: its children are its parent's children.
2204
+ if (closing) open.pop();
2205
+ else open.push({ name: "", narrow: parent && parent.narrow, cell: parent && parent.cell });
2206
+ continue;
2207
+ }
2208
+ if (closing) {
2209
+ open.close(name);
2210
+ continue;
2211
+ }
2212
+ const end = index.tagEnd(token.index);
2213
+ if (end === -1) break;
2214
+ const tag = { name, narrow: undefined, cell: parent && parent.cell };
2215
+ if (parent && parent.narrow !== undefined) tag.cell = { width: parent.narrow, at: token.index, texts: 0 };
2216
+ if (name === "Grid") {
2217
+ const width = _gridMinCellWidth(structure, structure.slice(token.index, end));
2218
+ if (width < _GRID_NARROW_CELL_WIDTH) tag.narrow = width;
2219
+ }
2220
+ if (name === "Text" && tag.cell && ++tag.cell.texts === 2) {
2221
+ const line = lineAt(tag.cell.at);
2222
+ findings.push({
2223
+ rule: "grid-cell-too-narrow",
2224
+ severity: "error",
2225
+ label:
2226
+ `<Grid minCellWidth={${tag.cell.width}}> makes each cell ${tag.cell.width}px wide, and a cell ` +
2227
+ `holding more than one Text breaks its words and wraps a price or a time onto two ` +
2228
+ `lines at that width. Leave minCellWidth off (the default is ${_GRID_DEFAULT_MIN_CELL_WIDTH}) ` +
2229
+ `or set it to ${_GRID_DEFAULT_MIN_CELL_WIDTH} or more; a width under ${_GRID_NARROW_CELL_WIDTH} ` +
2230
+ `fits only a cell of a single Text, such as a chip, an avatar or an icon tile.`,
2231
+ line,
2232
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
2233
+ });
2234
+ }
2235
+ if (!_selfClosing(structure, end)) open.push(tag);
2236
+ _JSX_TOKEN_RE.lastIndex = end + 1;
2237
+ }
2238
+ return findings;
2239
+ }
2240
+
2158
2241
  // sc-4913 — a measured frame INCLUDES the element's own padding.
2159
2242
  //
2160
2243
  // `onLayout` reports the frame width, and padding sits inside that frame. A
@@ -2886,6 +2969,8 @@ function lintSource(source, options) {
2886
2969
  findings.push(..._fullWidthMarginRules(source));
2887
2970
  // sc-12232 — soft warning: a field inside a bordered row draws two borders.
2888
2971
  findings.push(..._fieldInBorderedRowRules(source));
2972
+ // sc-15754 — a Grid cell under 200px holding more than one Text is refused.
2973
+ findings.push(..._gridCellWidthRules(source));
2889
2974
  // sc-4913 — soft warning: a measured width that includes the widget's own
2890
2975
  // padding wraps the last grid column into an empty one.
2891
2976
  findings.push(..._measuredPaddingRules(source));
package/dist/linter.js CHANGED
@@ -856,6 +856,16 @@ function _scopeRules(source, manifest) {
856
856
  snippet: "",
857
857
  });
858
858
  }
859
+ // sc-15659 — DISCLOSURE, as for useAction: the server gate is the app-user
860
+ // JWT plus the connector's app opt-in and listed operations.
861
+ if (/\buseConnector\s*\(/.test(source) && !declared.has("connectors.call:*")) {
862
+ findings.push({
863
+ rule: "scope-required-for-useConnector",
864
+ label: "useConnector() requires `connectors.call:*` in manifest.requestedScopes",
865
+ line: 0,
866
+ snippet: "",
867
+ });
868
+ }
859
869
  const lines = source.split(/\r?\n/);
860
870
  let firedUsersWrite = false;
861
871
  let firedUsersDelete = false;
@@ -2150,6 +2160,79 @@ function _fieldInBorderedRowRules(source) {
2150
2160
  return findings;
2151
2161
  }
2152
2162
 
2163
+ // sc-15754 — a Grid cell narrower than the default fits one Text, not a card.
2164
+ //
2165
+ // `<Grid minCellWidth={150}>` on cards carrying a title, a duration and a
2166
+ // price made five narrow columns at desktop width, with broken words and the
2167
+ // prices wrapped onto two lines; the agent cannot see that until it renders.
2168
+ // A cell of a single Text (a chip, an avatar, an icon tile) is fine at any
2169
+ // width, so only a cell holding more than one Text is refused. A width read
2170
+ // from a `const` is followed; one computed at runtime stays beyond the scan.
2171
+ const _GRID_DEFAULT_MIN_CELL_WIDTH = 240; // grid-logic.js DEFAULT_MIN_CELL_WIDTH
2172
+ const _GRID_NARROW_CELL_WIDTH = 200;
2173
+ const _MIN_CELL_WIDTH_ATTR_RE =
2174
+ /\bminCellWidth\s*=\s*\{\s*(\d+(?:\.\d+)?|[A-Za-z_$][\w$]*)\s*\}/;
2175
+
2176
+ function _gridMinCellWidth(structure, attrs) {
2177
+ const hit = _MIN_CELL_WIDTH_ATTR_RE.exec(attrs);
2178
+ if (!hit) return NaN;
2179
+ if (/^\d/.test(hit[1])) return Number(hit[1]);
2180
+ const declared = new RegExp(`\\bconst\\s+${hit[1]}\\s*=\\s*(\\d+(?:\\.\\d+)?)\\s*;`).exec(structure);
2181
+ return declared ? Number(declared[1]) : NaN;
2182
+ }
2183
+
2184
+ function _gridCellWidthRules(source) {
2185
+ const findings = [];
2186
+ const structure = _stripNonCode(source);
2187
+ const index = _jsxScanIndex(structure);
2188
+ const sourceLines = source.split(/\r?\n/);
2189
+ const lineAt = _lineCounter(structure);
2190
+ const open = _openTagStack();
2191
+ _JSX_TOKEN_RE.lastIndex = 0;
2192
+ let token;
2193
+ while ((token = _JSX_TOKEN_RE.exec(structure))) {
2194
+ const [, closing, name] = token;
2195
+ const parent = open.top();
2196
+ if (!name) {
2197
+ if (structure[token.index + 1 + closing.length] !== ">") continue;
2198
+ // A fragment is transparent: its children are its parent's children.
2199
+ if (closing) open.pop();
2200
+ else open.push({ name: "", narrow: parent && parent.narrow, cell: parent && parent.cell });
2201
+ continue;
2202
+ }
2203
+ if (closing) {
2204
+ open.close(name);
2205
+ continue;
2206
+ }
2207
+ const end = index.tagEnd(token.index);
2208
+ if (end === -1) break;
2209
+ const tag = { name, narrow: undefined, cell: parent && parent.cell };
2210
+ if (parent && parent.narrow !== undefined) tag.cell = { width: parent.narrow, at: token.index, texts: 0 };
2211
+ if (name === "Grid") {
2212
+ const width = _gridMinCellWidth(structure, structure.slice(token.index, end));
2213
+ if (width < _GRID_NARROW_CELL_WIDTH) tag.narrow = width;
2214
+ }
2215
+ if (name === "Text" && tag.cell && ++tag.cell.texts === 2) {
2216
+ const line = lineAt(tag.cell.at);
2217
+ findings.push({
2218
+ rule: "grid-cell-too-narrow",
2219
+ severity: "error",
2220
+ label:
2221
+ `<Grid minCellWidth={${tag.cell.width}}> makes each cell ${tag.cell.width}px wide, and a cell ` +
2222
+ `holding more than one Text breaks its words and wraps a price or a time onto two ` +
2223
+ `lines at that width. Leave minCellWidth off (the default is ${_GRID_DEFAULT_MIN_CELL_WIDTH}) ` +
2224
+ `or set it to ${_GRID_DEFAULT_MIN_CELL_WIDTH} or more; a width under ${_GRID_NARROW_CELL_WIDTH} ` +
2225
+ `fits only a cell of a single Text, such as a chip, an avatar or an icon tile.`,
2226
+ line,
2227
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
2228
+ });
2229
+ }
2230
+ if (!_selfClosing(structure, end)) open.push(tag);
2231
+ _JSX_TOKEN_RE.lastIndex = end + 1;
2232
+ }
2233
+ return findings;
2234
+ }
2235
+
2153
2236
  // sc-4913 — a measured frame INCLUDES the element's own padding.
2154
2237
  //
2155
2238
  // `onLayout` reports the frame width, and padding sits inside that frame. A
@@ -2881,6 +2964,8 @@ export function lintSource(source, options) {
2881
2964
  findings.push(..._fullWidthMarginRules(source));
2882
2965
  // sc-12232 — soft warning: a field inside a bordered row draws two borders.
2883
2966
  findings.push(..._fieldInBorderedRowRules(source));
2967
+ // sc-15754 — a Grid cell under 200px holding more than one Text is refused.
2968
+ findings.push(..._gridCellWidthRules(source));
2884
2969
  // sc-4913 — soft warning: a measured width that includes the widget's own
2885
2970
  // padding wraps the last grid column into an empty one.
2886
2971
  findings.push(..._measuredPaddingRules(source));
@@ -32,6 +32,9 @@ const VALID_TYPES = new Set([
32
32
  // sc-13843: `actionRef` picks one of the workspace's Actions, a bare
33
33
  // Action UUID a widget passes to `useAction(actionRef)`. Remapped like agentRef.
34
34
  "actionRef",
35
+ // sc-15659: `connectorRef` picks one of the workspace's app-callable REST
36
+ // connectors, a bare id a widget passes to `useConnector`. Remapped likewise.
37
+ "connectorRef",
35
38
  // REQ-WDG-PAGEREF: `pageRef` is a page picker that emits a bare AppPage
36
39
  // UUID into the page JSON. Renders a page `<select>` in the Studio
37
40
  // Properties Panel; a widget reads it and navigates via
@@ -164,6 +167,7 @@ function coerceLeaf(def, value, path, errors) {
164
167
  case "sectionRef":
165
168
  case "agentRef":
166
169
  case "actionRef":
170
+ case "connectorRef":
167
171
  case "richText":
168
172
  case "asset":
169
173
  case "folderRef":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.289.0",
3
+ "version": "0.291.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -2295,6 +2295,46 @@ const HOOKS = [
2295
2295
  requiredContextSlice: ["actions.run"],
2296
2296
  scopes: ["actions.run:*"],
2297
2297
  },
2298
+ {
2299
+ name: "useConnector",
2300
+ signature: "useConnector(connectorRef)",
2301
+ description:
2302
+ "sc-15659 — call one of the WORKSPACE's REST connectors (a configured " +
2303
+ "outside API) straight from the widget, and use the answer in the " +
2304
+ "widget. call(operation, { params, query, body }) names an OPERATION " +
2305
+ "the workspace listed for apps on that connector, never a method or a " +
2306
+ "path: the server builds the request from the listing and adds the " +
2307
+ "connector's credential, which never reaches the app. `params` fills " +
2308
+ "the operation's {name} path segments (one whole segment each, no " +
2309
+ "slashes); `query` may use only the names the operation lists; `body` " +
2310
+ "only where the operation takes one. call resolves with { status, " +
2311
+ "content_type, body } — the outside API's status (a non-2xx answer " +
2312
+ "still resolves: branch on status) and its parsed JSON or text body. " +
2313
+ "The whole call must finish within 250 ms, token exchange included: a " +
2314
+ "slower outside API is cut off and the call rejects with UPSTREAM_TIMEOUT. " +
2315
+ "May run on mount for a read operation; an operation that changes data " +
2316
+ "belongs in an event handler. `connectorRef` is a connector id an " +
2317
+ "author bound through a `connectorRef` property — when it is missing, " +
2318
+ "or the host brokers no connectors client, `available` is false and " +
2319
+ "call() rejects UNAVAILABLE; render a visible 'pick a connector' state, " +
2320
+ "NEVER a blank box. Only signed-in app users may call. ConnectorError.code: " +
2321
+ "OPERATION_NOT_ALLOWED (403), CONNECTOR_NOT_FOUND (404, also when the " +
2322
+ "connector is not callable from the app) and CONNECTOR_SETUP_INCOMPLETE " +
2323
+ "(409) will NOT clear on a retry, so show an 'isn't available' state; " +
2324
+ "RATE_LIMITED (429) will; INVALID_CONNECTOR_PARAMS (400) means an " +
2325
+ "argument the operation does not take; UPSTREAM_ERROR (502) and " +
2326
+ "UPSTREAM_TIMEOUT (504) mean the outside API failed; AUTH_REQUIRED, " +
2327
+ "TOKEN_EXPIRED and TOKEN_INVALID mean the app user must sign in. NEVER " +
2328
+ "show an app user a raw error. Reads ctx.connectors.",
2329
+ returnShape: {
2330
+ available: "boolean // false when no connector is bound or the host brokers none",
2331
+ call: "(operation, { params?, query?, body? }) => Promise<{ status, content_type, body }> // rejects with ConnectorError",
2332
+ calling: "boolean // true while any call is in flight",
2333
+ error: "ConnectorError | null // the last call's error; cleared by the next call or a rebind",
2334
+ },
2335
+ requiredContextSlice: ["connectors.call"],
2336
+ scopes: ["connectors.call:*"],
2337
+ },
2298
2338
  {
2299
2339
  name: "useDatastoreMutation",
2300
2340
  signature: "useDatastoreMutation(tableId)",
@@ -4338,6 +4378,17 @@ const WIDGET_CONTEXT_SHAPE = {
4338
4378
  required: false,
4339
4379
  fields: { run: "function", status: "function" },
4340
4380
  },
4381
+ connectors: {
4382
+ description:
4383
+ "sc-15659 — injected connectors client from @colixsystems/actions-client. " +
4384
+ "{ call(connectorId, operation, { params, query, body }) -> Promise<{ status, content_type, body }> }. " +
4385
+ "Backs useConnector(). Calls one operation the workspace listed on an app-callable " +
4386
+ "connector, as the signed-in caller; the credential is added server-side. Errors carry " +
4387
+ "the server's `code` and `status`. Absent on a host that brokers none, where " +
4388
+ "useConnector() degrades to available: false rather than throwing.",
4389
+ required: false,
4390
+ fields: { call: "function" },
4391
+ },
4341
4392
  linkPreviews: {
4342
4393
  description:
4343
4394
  "Injected @colixsystems/link-preview-client instance. { get(url) -> Promise<{ url, title, description, site_name, image_url }> }. " +
@@ -6747,7 +6798,10 @@ const CONTRACT = deepFreeze({
6747
6798
  // PaymentError codes.
6748
6799
  // 1.237.0: widening — `manifest.translations` may declare up to 500 keys
6749
6800
  // (was 200).
6750
- version: "1.237.0",
6801
+ // 1.238.0: additive (sc-15659) — `useConnector(connectorRef)` + the
6802
+ // `connectors` host client + the `connectorRef` propertySchema type + the
6803
+ // `connectors.call:*` scope. A widget calls a REST connector's listed operations.
6804
+ version: "1.238.0",
6751
6805
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6752
6806
  hooks: HOOKS,
6753
6807
  primitives: PRIMITIVES,