@colixsystems/widget-sdk 0.114.0 → 0.116.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
@@ -49,7 +49,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
49
49
  | **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*` |
50
50
  | **DATASTORE** | `useDatastoreSubscription(table, handlers, options?)` | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the REQ-RT-07 socket; never throws, resolving to `{ status: "fallback" }` so the widget polls instead. A whole-table subscribe is gated on read-EVERY-row, because one envelope reaches every subscriber of the table — so for a table governed by per-record grants pass `options.scope`: `{ kind: "record", record_id }` for one row, or `{ kind: "parent", relation_column, record_id }` for the rows whose RELATION column points at that parent (the column must carry `inheritAcl`, else the subscribe reports `"fallback"`). Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
51
51
  | **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
52
- | **DATASTORE** | `useCanWrite(tableId, options?)` | `{ canWrite, loading, error, refetch }` | `myPermissions(tableId, { recordId? })` — scope `datastore.read:<table>`. A FLOOR, not a full replacement for domain-specific write rules: answers "is this caller signed in AND permitted", reading the same table-ACL answer the write endpoint enforces. Pass `{ recordId }` for a per-row check. A widget whose own rule is MORE SPECIFIC than the table ACL (e.g. "only the assigned user may edit this row") must still hand-check that in addition. Pair with `useUser()` to also tell "not signed in" apart from "signed in but forbidden" — both resolve `canWrite: false` here. Falsy `tableId`, or a host that hasn't injected `myPermissions` (an older host), collapses to `{ canWrite: false, loading: false, error: null, refetch: async () => undefined }` rather than throwing. |
52
+ | **DATASTORE** | `useCanWrite(tableId, options?)` | `{ canWrite, loading, error, refetch }` | `myPermissions(tableId, { recordId? })` — scope `datastore.read:<table>`. A FLOOR, not a full replacement for domain-specific write rules: answers "may this caller write", reading the same table-ACL answer the write endpoint enforces — so a table granting Create to Everyone answers `true` for a logged-out visitor, and this hook alone is the right gate for a widget meant to work without signing in. Pass `{ recordId }` for a per-row check. A widget whose own rule is MORE SPECIFIC than the table ACL (e.g. "only the assigned user may edit this row") must still hand-check that in addition. Pair with `useUser()` to also tell "not signed in" apart from "signed in but forbidden" — both resolve `canWrite: false` here. Falsy `tableId`, or a host that hasn't injected `myPermissions` (an older host), collapses to `{ canWrite: false, loading: false, error: null, refetch: async () => undefined }` rather than throwing. |
53
53
  | **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
54
54
  | **FILES** | `useAssetsByTag(tag, { type? })` | `{ assets, loading, error, refetch }` | `ctx.assets.list` (unwraps `{ data, meta }` to `assets`) — no scope. `type` defaults to `"image"`; pass `"all"` / `"audio"` / `"video"` / `"document"` to widen. Falsy `tag` collapses to `assets: []` without a round-trip. |
55
55
  | **DIRECTORY** (`ctx.directory`) | `useDirectory(query?)` | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
@@ -72,6 +72,30 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
72
72
 
73
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**.
74
74
 
75
+ ### What's new in 0.116.0 (contract 1.88.0)
76
+
77
+ **New primitive `<Overlay>` — a widget can finally open something over the SCREEN (sc-6607).** Until now a widget could only paint an overlay inside its own root, where the host's layout containers clip it: a PDF preview, a lightbox, or a confirm dialog opened trapped inside the widget's tile, and no amount of `zIndex` fixed it (`overflow: "hidden"` clips regardless, and `position: "fixed"` does not exist on native).
78
+
79
+ ```jsx
80
+ <Overlay visible={!!preview} onRequestClose={() => setPreview(null)} size="full">
81
+ <ScrollView>{renderPreview(preview)}</ScrollView>
82
+ </Overlay>
83
+ ```
84
+
85
+ `<Overlay>` renders its children OUTSIDE the widget's layout box on both hosts — the web Player portals them to the document root, the exported Expo app hands them to the OS modal — while they stay in your own React tree, so their state and hooks are untouched. `onRequestClose` carries the backdrop press, Escape on web, and the Android back button; `size` is `sm` | `md` (default) | `lg` | `full`; the scrim, surface, radius, padding and elevation come from the workspace theme.
86
+
87
+ An anchored dropdown or popover is the one overlay kind that still belongs inside your own root — `<Overlay>` centres on the screen rather than on a trigger.
88
+
89
+ ### What's new in 0.115.0 (contract unchanged)
90
+
91
+ **`write-not-gated-on-user` now accepts a `useCanWrite()` gate — a widget may be opened to logged-out visitors (sc-6593).** The rule (added in 0.89.0, below) flagged any `useDatastoreMutation` write that carried no identity guard, and only a `.id` / `groupIds` / `roles` check counted as one. That encoded "a write needs a signed-in app user" as a platform fact, which it is not: a table whose permissions grant **Create** to *Everyone (anonymous + signed-in)* accepts a write from a logged-out visitor, and `useCanWrite(tableId)` answers `true` for them.
92
+
93
+ So a widget gated on `useCanWrite` alone — the correct shape for a public tally, a guest sign-up sheet, or an open feedback form — used to trip the warning that steers the AI widget agent's repair loop back to identity gating, making the sign-in requirement impossible for an author to remove. It is now recognised as a gate, and its finding label names it first.
94
+
95
+ Nothing else changes: a widget with **no** gate at all is still flagged, `useUser().id` read purely as a VALUE still does not satisfy the rule, and the severity is still `warning` (never publish-blocking). Identity gating remains the right default for almost every write — this only stops the linter from arguing against the one case where it isn't.
96
+
97
+ When you take the `useCanWrite`-only route, omit the USER column for a guest (`if (user.id) payload[byField] = user.id;`) — an anonymous row records no author, so per-person limits and "my entries" views cannot work for one.
98
+
75
99
  ### What's new in 0.113.0 (contract 1.86.0)
76
100
 
77
101
  **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**.
@@ -1080,6 +1104,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
1080
1104
  - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useWidgetRoute`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError` carrying `code`, the server's user-safe `message`, and `retryable` (`false` = this charge cannot succeed until the workspace, manifest, or amount changes — show the message, not a retry). `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute, openLink }` for internal page navigation; `openLink(link)` follows an author- or data-supplied link of unknown shape through the host's shared resolver (in-app page → internal route, off-app http(s) → opened outside, unsafe → refused) and is the safe choice for any value your code did not construct, while `Linking.openURL(url)` is for an external URL you built yourself. `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case). `useWidgetInput(inputName)` returns the latest payload a sibling widget published on the event the page author wired to this widget's declared `inputs` entry (`undefined` when unwired or not yet published).
1081
1105
  - `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
1082
1106
  - `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`; `DateTimePicker` wraps `@react-native-community/datetimepicker` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
1107
+ - `Overlay` — the screen-level overlay (sc-6607). `<Overlay visible={!!preview} onRequestClose={() => setPreview(null)} size="full">…</Overlay>` renders its children OUTSIDE the widget's layout box on both hosts (web portals it to the document root, native uses the OS modal), so no clipping card, `ScrollView` or neighbouring widget can cut off a document/media preview, lightbox or confirm dialog — which absolute positioning inside the widget cannot achieve on either platform. Children stay in your own React tree, so state and hooks work normally. `onRequestClose` carries the backdrop press, Escape on web, and the Android back button; `size` is `sm` | `md` (default) | `lg` | `full`; the scrim, surface, radius, padding and elevation come from the workspace theme. An anchored dropdown still belongs inside your own root.
1083
1108
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
1084
1109
 
1085
1110
  ## Design & visual polish
package/dist/contract.cjs CHANGED
@@ -272,6 +272,16 @@ const LINK_ACTION_FIELDS = Object.freeze({
272
272
  radius: "buttonRadius",
273
273
  });
274
274
 
275
+ // sc-6602: Image frames media with the flat `radius`/`borderColor`/`background`
276
+ // trio rather than the `card*` names, so the card scope reaches it through this
277
+ // map. Bound by id, so `shadow` is safe to carry here.
278
+ const MEDIA_FRAME_FIELDS = Object.freeze({
279
+ background: "background",
280
+ borderColor: "borderColor",
281
+ radius: "radius",
282
+ shadow: "shadow",
283
+ });
284
+
275
285
  // REQ-THEME-WIDGET: the card fields whose NAMES are unambiguous, so they bind to
276
286
  // ANY widget that reads them -- including a Mason-generated one, whose id can
277
287
  // never appear in a hand-maintained allowlist. That allowlist is why "make the
@@ -280,7 +290,8 @@ const LINK_ACTION_FIELDS = Object.freeze({
280
290
  // `shadow` is deliberately ABSENT: its name is bare and shared with the button
281
291
  // scope, so binding it by name would cross the scopes. The bare names stay on
282
292
  // the allowlist for exactly that reason -- `appstudio.image` also reads a
283
- // `background` field, and the button scope must not leak into it.
293
+ // `background` field, and the button scope must not leak into it. The media
294
+ // surfaces get themed elevation by ID instead (sc-6602); keep it out of here.
284
295
  const CARD_UNIVERSAL_FIELDS = Object.freeze({
285
296
  background: "cardBackground",
286
297
  borderColor: "cardBorderColor",
@@ -367,6 +378,12 @@ const THEME_COMPONENTS = Object.freeze({
367
378
  "appstudio.form-builder": CARD_SURFACE_FIELDS,
368
379
  "appstudio.user-management": CARD_SURFACE_FIELDS,
369
380
  "appstudio.link": CARD_SURFACE_FIELDS,
381
+ // sc-6602: the media surfaces. They paint a card exactly as the widgets
382
+ // above do, so a themed elevation must reach them — a reference design's
383
+ // lifted hero image was unthemeable while these three were absent.
384
+ "appstudio.sound": CARD_SURFACE_FIELDS,
385
+ "appstudio.video": CARD_SURFACE_FIELDS,
386
+ "appstudio.image": MEDIA_FRAME_FIELDS,
370
387
  }),
371
388
  }),
372
389
  text: Object.freeze({
@@ -1770,6 +1787,17 @@ const PRIMITIVES = [
1770
1787
  rnComponent: null,
1771
1788
  docsUrl: null,
1772
1789
  },
1790
+ // sc-6607 — the SCREEN-level overlay. Widgets could previously only paint an
1791
+ // overlay inside their own box, so a preview or dialog was clipped by the
1792
+ // layout container the widget sits in; this is the one primitive that leaves
1793
+ // that box on both hosts.
1794
+ {
1795
+ name: "Overlay",
1796
+ description:
1797
+ 'Screen-level overlay. `<Overlay visible={open} onRequestClose={() => setOpen(false)} size="md">…panel…</Overlay>`. THE way to open anything that must cover the SCREEN rather than the widget — a preview (PDF, image, video), a lightbox, a confirm dialog, a full detail panel. It renders OUTSIDE the widget layout box on both hosts (react-native-web portals it to the document root; native uses the OS modal), so no `overflow: hidden` card, scroll container or sibling widget can clip or cover it — which absolute positioning inside the widget cannot achieve on either host. Children stay in your own React tree, so state and hooks work normally. Props: `visible` (boolean, required to show), `onRequestClose` (fired by the backdrop press, the Escape key on web, and the Android back button — always wire it or the overlay cannot be closed), `size` ("sm" | "md" | "lg" | "full", default "md"; `full` runs edge to edge for media), `dismissOnBackdropPress` (default true), `accessibilityLabel`, and `style` for extra panel styles. The scrim, surface, radius, padding and elevation come from the workspace theme — never re-style them. Keep it for screen-level surfaces: a dropdown anchored to its trigger still belongs inside the widget.',
1798
+ rnComponent: null,
1799
+ docsUrl: null,
1800
+ },
1773
1801
  ];
1774
1802
 
1775
1803
  const CATEGORIES = [
@@ -3468,7 +3496,11 @@ const CONTRACT = deepFreeze({
3468
3496
  // plus the `pressableLift` primitive that resolves it per input — hover on
3469
3497
  // web, press on native. Tappable layout containers and widget surfaces lift
3470
3498
  // from this ONE table.
3471
- version: "1.87.0",
3499
+ // 1.88.0: additive (sc-6607) — the `Overlay` primitive: the first surface a
3500
+ // widget can open at SCREEN level. Everything before it was clipped by the
3501
+ // layout container the widget sits in, so a preview or dialog could not leave
3502
+ // the widget's own tile on either host.
3503
+ version: "1.88.0",
3472
3504
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3473
3505
  hooks: HOOKS,
3474
3506
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -272,6 +272,16 @@ const LINK_ACTION_FIELDS = Object.freeze({
272
272
  radius: "buttonRadius",
273
273
  });
274
274
 
275
+ // sc-6602: Image frames media with the flat `radius`/`borderColor`/`background`
276
+ // trio rather than the `card*` names, so the card scope reaches it through this
277
+ // map. Bound by id, so `shadow` is safe to carry here.
278
+ const MEDIA_FRAME_FIELDS = Object.freeze({
279
+ background: "background",
280
+ borderColor: "borderColor",
281
+ radius: "radius",
282
+ shadow: "shadow",
283
+ });
284
+
275
285
  // REQ-THEME-WIDGET: the card fields whose NAMES are unambiguous, so they bind to
276
286
  // ANY widget that reads them -- including a Mason-generated one, whose id can
277
287
  // never appear in a hand-maintained allowlist. That allowlist is why "make the
@@ -280,7 +290,8 @@ const LINK_ACTION_FIELDS = Object.freeze({
280
290
  // `shadow` is deliberately ABSENT: its name is bare and shared with the button
281
291
  // scope, so binding it by name would cross the scopes. The bare names stay on
282
292
  // the allowlist for exactly that reason -- `appstudio.image` also reads a
283
- // `background` field, and the button scope must not leak into it.
293
+ // `background` field, and the button scope must not leak into it. The media
294
+ // surfaces get themed elevation by ID instead (sc-6602); keep it out of here.
284
295
  const CARD_UNIVERSAL_FIELDS = Object.freeze({
285
296
  background: "cardBackground",
286
297
  borderColor: "cardBorderColor",
@@ -367,6 +378,12 @@ const THEME_COMPONENTS = Object.freeze({
367
378
  "appstudio.form-builder": CARD_SURFACE_FIELDS,
368
379
  "appstudio.user-management": CARD_SURFACE_FIELDS,
369
380
  "appstudio.link": CARD_SURFACE_FIELDS,
381
+ // sc-6602: the media surfaces. They paint a card exactly as the widgets
382
+ // above do, so a themed elevation must reach them — a reference design's
383
+ // lifted hero image was unthemeable while these three were absent.
384
+ "appstudio.sound": CARD_SURFACE_FIELDS,
385
+ "appstudio.video": CARD_SURFACE_FIELDS,
386
+ "appstudio.image": MEDIA_FRAME_FIELDS,
370
387
  }),
371
388
  }),
372
389
  text: Object.freeze({
@@ -1770,6 +1787,17 @@ const PRIMITIVES = [
1770
1787
  rnComponent: null,
1771
1788
  docsUrl: null,
1772
1789
  },
1790
+ // sc-6607 — the SCREEN-level overlay. Widgets could previously only paint an
1791
+ // overlay inside their own box, so a preview or dialog was clipped by the
1792
+ // layout container the widget sits in; this is the one primitive that leaves
1793
+ // that box on both hosts.
1794
+ {
1795
+ name: "Overlay",
1796
+ description:
1797
+ 'Screen-level overlay. `<Overlay visible={open} onRequestClose={() => setOpen(false)} size="md">…panel…</Overlay>`. THE way to open anything that must cover the SCREEN rather than the widget — a preview (PDF, image, video), a lightbox, a confirm dialog, a full detail panel. It renders OUTSIDE the widget layout box on both hosts (react-native-web portals it to the document root; native uses the OS modal), so no `overflow: hidden` card, scroll container or sibling widget can clip or cover it — which absolute positioning inside the widget cannot achieve on either host. Children stay in your own React tree, so state and hooks work normally. Props: `visible` (boolean, required to show), `onRequestClose` (fired by the backdrop press, the Escape key on web, and the Android back button — always wire it or the overlay cannot be closed), `size` ("sm" | "md" | "lg" | "full", default "md"; `full` runs edge to edge for media), `dismissOnBackdropPress` (default true), `accessibilityLabel`, and `style` for extra panel styles. The scrim, surface, radius, padding and elevation come from the workspace theme — never re-style them. Keep it for screen-level surfaces: a dropdown anchored to its trigger still belongs inside the widget.',
1798
+ rnComponent: null,
1799
+ docsUrl: null,
1800
+ },
1773
1801
  ];
1774
1802
 
1775
1803
  const CATEGORIES = [
@@ -3468,7 +3496,11 @@ const CONTRACT = deepFreeze({
3468
3496
  // plus the `pressableLift` primitive that resolves it per input — hover on
3469
3497
  // web, press on native. Tappable layout containers and widget surfaces lift
3470
3498
  // from this ONE table.
3471
- version: "1.87.0",
3499
+ // 1.88.0: additive (sc-6607) — the `Overlay` primitive: the first surface a
3500
+ // widget can open at SCREEN level. Everything before it was clipped by the
3501
+ // layout container the widget sits in, so a preview or dialog could not leave
3502
+ // the widget's own tile on either host.
3503
+ version: "1.88.0",
3472
3504
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3473
3505
  hooks: HOOKS,
3474
3506
  primitives: PRIMITIVES,
package/dist/index.d.ts CHANGED
@@ -2151,6 +2151,36 @@ export const Gradient: (props: {
2151
2151
  children?: ReactNode;
2152
2152
  }) => any;
2153
2153
 
2154
+ /**
2155
+ * sc-6607 — screen-level overlay. Renders OUTSIDE the widget's layout box on
2156
+ * both hosts (web portals it to the document root, native uses the OS modal),
2157
+ * so no clipping card, scroll container or neighbouring widget can cut it off.
2158
+ * Use it for anything that takes over the screen — a document/media preview, a
2159
+ * lightbox, a confirm dialog. An anchored dropdown still belongs inside the
2160
+ * widget's own root.
2161
+ *
2162
+ * @example
2163
+ * <Overlay visible={!!preview} onRequestClose={() => setPreview(null)} size="full">
2164
+ * <ScrollView>{renderPreview(preview)}</ScrollView>
2165
+ * </Overlay>
2166
+ */
2167
+ export const Overlay: (props: {
2168
+ /** Nothing renders (and nothing is mounted) while this is false. */
2169
+ visible?: boolean;
2170
+ /** Backdrop press, Escape (web) and the Android back button all fire this. */
2171
+ onRequestClose?: () => void;
2172
+ /** Panel width tier. `full` runs edge to edge. Defaults to `md`. */
2173
+ size?: "sm" | "md" | "lg" | "full";
2174
+ /** Set false when only an explicit control may close the overlay. */
2175
+ dismissOnBackdropPress?: boolean;
2176
+ accessibilityLabel?: string;
2177
+ /** Label for the backdrop's dismiss target. Defaults to "Close". */
2178
+ closeAccessibilityLabel?: string;
2179
+ /** Extra styles merged onto the themed panel. */
2180
+ style?: any;
2181
+ children?: ReactNode;
2182
+ }) => any;
2183
+
2154
2184
  // ------------------------------------------------------- theme derivation
2155
2185
  // sc-3696: the colour maths both hosts resolve `useTheme()` with. Exported so
2156
2186
  // the Player (frontend/src/services/widgetTheme.js) and the exported app's
package/dist/index.js CHANGED
@@ -90,6 +90,10 @@ export { isNarrowWidth, NARROW_WIDTH_PX } from "./container-width.js";
90
90
  // the web variant and index.native.js picks the native variant.
91
91
  export { useClipboard, ClipboardError } from "./clipboard.js";
92
92
  export { useToast } from "./toast.js";
93
+ // sc-6607 — `<Overlay>` is one shared component (./overlay-view.js) bound to
94
+ // each platform's primitives, so a widget's modal escapes its container box
95
+ // identically on the Player and the Expo export.
96
+ export { Overlay } from "./overlay.js";
93
97
  export {
94
98
  Text,
95
99
  View,
@@ -88,6 +88,8 @@ export { isNarrowWidth, NARROW_WIDTH_PX } from "./container-width.js";
88
88
  // REQ-WSDK-PLATFORM §6 — Tier A hooks (native variants).
89
89
  export { useClipboard, ClipboardError } from "./clipboard.native.js";
90
90
  export { useToast } from "./toast.native.js";
91
+ // sc-6607 — see the note on the web mirror in ./index.js.
92
+ export { Overlay } from "./overlay.native.js";
91
93
  export {
92
94
  Text,
93
95
  View,
package/dist/linter.cjs CHANGED
@@ -928,12 +928,17 @@ const CURRENCY_LABEL_RES = [
928
928
  ];
929
929
 
930
930
  // sc-4985 — soft warning: a widget that writes must decide what a signed-OUT
931
- // visitor sees. A write needs a signed-in app user, so an anonymous visitor
932
- // handed a live "Save" / "Book" / "Delete" button can only tap it and fail —
933
- // the failure the gate exists to spare them. Satisfied by any identity guard:
934
- // a negated or compared `.id`, or a `groupIds` / `roles` check. Reading
935
- // `useUser().id` purely as a VALUE (the USER-column write pattern) is NOT a
936
- // guard, which is why an operator has to follow it.
931
+ // visitor sees. A visitor handed a live "Save" / "Book" / "Delete" button the
932
+ // table will refuse can only tap it and fail — the failure the gate exists to
933
+ // spare them. Satisfied by any identity guard: a negated or compared `.id`, or
934
+ // a `groupIds` / `roles` check. Reading `useUser().id` purely as a VALUE (the
935
+ // USER-column write pattern) is NOT a guard, which is why an operator follows.
936
+ //
937
+ // sc-6593 — `useCanWrite(tableId)` satisfies it too, and answers better: it
938
+ // reads the ACL the write endpoint enforces, so it goes live for whoever may
939
+ // write. Since a table granting Create to EVERYONE accepts an anonymous write
940
+ // (sc-5229), demanding identity here would flag the only correct way to build
941
+ // the logged-out-friendly widget an author explicitly asked for.
937
942
  //
938
943
  // Conservative on purpose: an unrelated `.id` comparison elsewhere in the
939
944
  // source silences the rule. A warning that occasionally stays quiet is far
@@ -952,6 +957,7 @@ function _writeGatedOnUserRules(source) {
952
957
  const code = _stripNonCode(source);
953
958
  const call = /\buseDatastoreMutation\s*\(/.exec(code);
954
959
  if (!call) return [];
960
+ if (/\buseCanWrite\s*\(/.test(code)) return [];
955
961
  if (_IDENTITY_GUARD_RES.some((re) => re.test(code))) return [];
956
962
  const line = code.slice(0, call.index).split(/\r?\n/).length;
957
963
  return [
@@ -961,9 +967,9 @@ function _writeGatedOnUserRules(source) {
961
967
  // Kept under ~210 chars: a finding is truncated at 300 downstream, and
962
968
  // the fix instruction is the half worth keeping.
963
969
  label:
964
- `writes with useDatastoreMutation() but never checks who is signed ` +
965
- `in - read useUser(), and when !user.id render the action inactive ` +
966
- `with a "sign in" line instead of a live button that can only fail.`,
970
+ `writes with useDatastoreMutation() but never checks who may write ` +
971
+ `- gate on useCanWrite(tableId), or useUser() when the action needs ` +
972
+ `a signed-in user, and render the control inactive, never live-but-doomed.`,
967
973
  line,
968
974
  snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
969
975
  },
package/dist/linter.js CHANGED
@@ -1077,12 +1077,17 @@ const CURRENCY_LABEL_RES = [
1077
1077
  ];
1078
1078
 
1079
1079
  // sc-4985 — soft warning: a widget that writes must decide what a signed-OUT
1080
- // visitor sees. A write needs a signed-in app user, so an anonymous visitor
1081
- // handed a live "Save" / "Book" / "Delete" button can only tap it and fail —
1082
- // the failure the gate exists to spare them. Satisfied by any identity guard:
1083
- // a negated or compared `.id`, or a `groupIds` / `roles` check. Reading
1084
- // `useUser().id` purely as a VALUE (the USER-column write pattern) is NOT a
1085
- // guard, which is why an operator has to follow it.
1080
+ // visitor sees. A visitor handed a live "Save" / "Book" / "Delete" button the
1081
+ // table will refuse can only tap it and fail — the failure the gate exists to
1082
+ // spare them. Satisfied by any identity guard: a negated or compared `.id`, or
1083
+ // a `groupIds` / `roles` check. Reading `useUser().id` purely as a VALUE (the
1084
+ // USER-column write pattern) is NOT a guard, which is why an operator follows.
1085
+ //
1086
+ // sc-6593 — `useCanWrite(tableId)` satisfies it too, and answers better: it
1087
+ // reads the ACL the write endpoint enforces, so it goes live for whoever may
1088
+ // write. Since a table granting Create to EVERYONE accepts an anonymous write
1089
+ // (sc-5229), demanding identity here would flag the only correct way to build
1090
+ // the logged-out-friendly widget an author explicitly asked for.
1086
1091
  //
1087
1092
  // Conservative on purpose: an unrelated `.id` comparison elsewhere in the
1088
1093
  // source silences the rule. A warning that occasionally stays quiet is far
@@ -1101,6 +1106,7 @@ function _writeGatedOnUserRules(source) {
1101
1106
  const code = _stripNonCode(source);
1102
1107
  const call = /\buseDatastoreMutation\s*\(/.exec(code);
1103
1108
  if (!call) return [];
1109
+ if (/\buseCanWrite\s*\(/.test(code)) return [];
1104
1110
  if (_IDENTITY_GUARD_RES.some((re) => re.test(code))) return [];
1105
1111
  const line = code.slice(0, call.index).split(/\r?\n/).length;
1106
1112
  return [
@@ -1110,9 +1116,9 @@ function _writeGatedOnUserRules(source) {
1110
1116
  // Kept under ~210 chars: a finding is truncated at 300 downstream, and
1111
1117
  // the fix instruction is the half worth keeping.
1112
1118
  label:
1113
- `writes with useDatastoreMutation() but never checks who is signed ` +
1114
- `in - read useUser(), and when !user.id render the action inactive ` +
1115
- `with a "sign in" line instead of a live button that can only fail.`,
1119
+ `writes with useDatastoreMutation() but never checks who may write ` +
1120
+ `- gate on useCanWrite(tableId), or useUser() when the action needs ` +
1121
+ `a signed-in user, and render the control inactive, never live-but-doomed.`,
1116
1122
  line,
1117
1123
  snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
1118
1124
  },
@@ -0,0 +1,64 @@
1
+ // sc-6607 — the values `<Overlay>` paints one screen-level overlay with.
2
+ //
3
+ // Presentation-free and platform-free, the same way `toast-host.js` is: both
4
+ // hosts render the overlay from the SAME component (`overlay-view.js`), and
5
+ // this module is where the theme becomes numbers. Keeping the maths here makes
6
+ // it unit-testable without a renderer, and makes the scrim/panel contract a
7
+ // thing tests can pin rather than a literal buried in JSX.
8
+
9
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
10
+
11
+ export const OVERLAY_SIZES = Object.freeze(["sm", "md", "lg", "full"]);
12
+
13
+ export const OVERLAY_DEFAULTS = Object.freeze({
14
+ size: "md",
15
+ // Slate-black at 55%: dark enough to mute a busy page behind the panel,
16
+ // light enough that the page still reads as "still there, just behind".
17
+ scrim: "rgba(15, 23, 42, 0.55)",
18
+ // Breathing room between the panel and the screen edge on a phone.
19
+ screenPadding: 16,
20
+ });
21
+
22
+ // `full` deliberately has no ceiling — a media/PDF preview wants the screen.
23
+ const SIZE_MAX_WIDTH = Object.freeze({
24
+ sm: 360,
25
+ md: 520,
26
+ lg: 760,
27
+ full: null,
28
+ });
29
+
30
+ export function normaliseOverlaySize(size) {
31
+ return OVERLAY_SIZES.indexOf(size) === -1 ? OVERLAY_DEFAULTS.size : size;
32
+ }
33
+
34
+ /**
35
+ * Resolve the values one overlay is painted with, from the workspace theme.
36
+ *
37
+ * Returns primitives only, so the panel a widget opens carries the workspace's
38
+ * own surface, radius, spacing and elevation instead of a hard-coded card.
39
+ *
40
+ * @param {object} theme — the resolved workspace theme (`useTheme()` shape).
41
+ * @param {string} size — `sm` | `md` | `lg` | `full`.
42
+ */
43
+ export function resolveOverlayTokens(theme, size) {
44
+ const safeSize = normaliseOverlaySize(size);
45
+ const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
46
+ const fallback = DEFAULT_THEME_TOKENS;
47
+ const colors = base.colors || fallback.colors;
48
+ const radii = base.radii || fallback.radii;
49
+ const spacing = base.spacing || fallback.spacing;
50
+ const elevation = (base.elevation || fallback.elevation).lg || {};
51
+
52
+ return {
53
+ size: safeSize,
54
+ scrim: OVERLAY_DEFAULTS.scrim,
55
+ surface: colors.surface || fallback.colors.surface,
56
+ radius: safeSize === "full" ? 0 : radii.lg || fallback.radii.lg,
57
+ padding: spacing.lg || fallback.spacing.lg,
58
+ // A `full` overlay runs edge to edge; every other size keeps a margin so
59
+ // the scrim stays visible and the panel reads as a layer, not a screen.
60
+ screenPadding: safeSize === "full" ? 0 : OVERLAY_DEFAULTS.screenPadding,
61
+ maxWidth: SIZE_MAX_WIDTH[safeSize],
62
+ elevation,
63
+ };
64
+ }
@@ -0,0 +1,109 @@
1
+ // sc-6607 — the ONE implementation behind the `<Overlay>` SDK primitive.
2
+ //
3
+ // A widget that opens a preview, a lightbox, or a confirm dialog used to have
4
+ // no way to paint over the SCREEN: it rendered the panel as an absolutely
5
+ // positioned child of its own root, where the Player's layout containers
6
+ // (`overflow: hidden` cards, scroll containers) clip it and where React Native
7
+ // has no `position: "fixed"` at all. `<Overlay>` fixes that for both hosts at
8
+ // once by rendering into React Native's `Modal`, which leaves the parent's
9
+ // layout on BOTH platforms — react-native-web portals it to `document.body`,
10
+ // native hands it to the OS — while keeping the children in the widget's own
11
+ // React tree, so their state, hooks and WidgetContext all survive.
12
+ //
13
+ // The two platform bindings (`overlay.js` / `overlay.native.js`) differ ONLY in
14
+ // where the primitives come from; the component itself is defined once here so
15
+ // the hosts cannot drift (CLAUDE.md §3, §8).
16
+
17
+ import React from "react";
18
+ import { useHostTheme } from "./hooks.js";
19
+ import { resolveOverlayTokens } from "./overlay-tokens.js";
20
+
21
+ /**
22
+ * Bind the shared overlay component to one platform's primitives.
23
+ *
24
+ * @param {{Modal: any, View: any, Pressable: any, StyleSheet: any}} rn
25
+ */
26
+ export function makeOverlay(rn) {
27
+ const { Modal, View, Pressable, StyleSheet } = rn;
28
+
29
+ function Overlay({
30
+ visible = false,
31
+ onRequestClose,
32
+ size = "md",
33
+ dismissOnBackdropPress = true,
34
+ accessibilityLabel,
35
+ closeAccessibilityLabel = "Close",
36
+ style,
37
+ children,
38
+ }) {
39
+ const theme = useHostTheme();
40
+ const tokens = React.useMemo(
41
+ () => resolveOverlayTokens(theme, size),
42
+ [theme, size],
43
+ );
44
+ const requestClose = React.useCallback(() => {
45
+ if (typeof onRequestClose === "function") onRequestClose();
46
+ }, [onRequestClose]);
47
+
48
+ // Unmounted while closed: `Modal` appends a host container the moment it
49
+ // renders, so every widget on the page would otherwise leave one behind.
50
+ if (!visible) return null;
51
+
52
+ const backdrop = {
53
+ flex: 1,
54
+ alignItems: "center",
55
+ justifyContent: "center",
56
+ padding: tokens.screenPadding,
57
+ backgroundColor: tokens.scrim,
58
+ };
59
+ const panel = {
60
+ width: "100%",
61
+ // Capped to the padded screen box so a tall panel scrolls inside its own
62
+ // ScrollView instead of overflowing past the viewport unreachably.
63
+ maxHeight: "100%",
64
+ flexShrink: 1,
65
+ ...(tokens.maxWidth ? { maxWidth: tokens.maxWidth } : { flex: 1 }),
66
+ backgroundColor: tokens.surface,
67
+ borderRadius: tokens.radius,
68
+ padding: tokens.padding,
69
+ ...tokens.elevation,
70
+ };
71
+
72
+ return React.createElement(
73
+ Modal,
74
+ {
75
+ visible: true,
76
+ transparent: true,
77
+ // Not animated on purpose: the web host arms the overlay's dialog role
78
+ // and its Escape handler when the entrance animation ENDS, so a fade
79
+ // leaves a window where the keyboard cannot dismiss it — and a dropped
80
+ // animation event would leave it un-dismissable for good.
81
+ animationType: "none",
82
+ onRequestClose: requestClose,
83
+ },
84
+ React.createElement(
85
+ View,
86
+ { style: backdrop },
87
+ dismissOnBackdropPress
88
+ ? React.createElement(Pressable, {
89
+ style: StyleSheet.absoluteFill,
90
+ onPress: requestClose,
91
+ accessibilityRole: "button",
92
+ accessibilityLabel: closeAccessibilityLabel,
93
+ })
94
+ : null,
95
+ React.createElement(
96
+ View,
97
+ {
98
+ style: [panel, style],
99
+ accessibilityLabel,
100
+ },
101
+ children,
102
+ ),
103
+ ),
104
+ );
105
+ }
106
+
107
+ Overlay.displayName = "Overlay";
108
+ return Overlay;
109
+ }
@@ -0,0 +1,13 @@
1
+ // sc-6607 — `<Overlay>` (web). Binds the shared implementation in
2
+ // ./overlay-view.js to react-native-web's primitives.
3
+ //
4
+ // The import is `react-native-web` rather than the bare `react-native`
5
+ // specifier for the rolldown optional-peer-dep reason spelled out at the top of
6
+ // ./primitives.js. react-native-web's `Modal` portals its subtree into a
7
+ // `document.body` container at `position: fixed`, which is exactly what makes
8
+ // the overlay escape the widget's clipping ancestors.
9
+
10
+ import * as ReactNative from "react-native-web";
11
+ import { makeOverlay } from "./overlay-view.js";
12
+
13
+ export const Overlay = makeOverlay(ReactNative);
@@ -0,0 +1,11 @@
1
+ // sc-6607 — `<Overlay>` (native). Binds the shared implementation in
2
+ // ./overlay-view.js to React Native's own primitives.
3
+ //
4
+ // RN's `Modal` renders outside the parent's layout and routes the Android
5
+ // hardware back button to `onRequestClose`, matching what react-native-web's
6
+ // portal does on the web (./overlay.js). Only the import differs.
7
+
8
+ import { Modal, View, Pressable, StyleSheet } from "react-native";
9
+ import { makeOverlay } from "./overlay-view.js";
10
+
11
+ export const Overlay = makeOverlay({ Modal, View, Pressable, StyleSheet });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.114.0",
3
+ "version": "0.116.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__/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"
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__/overlay-tokens.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"