@colixsystems/widget-sdk 0.197.0 → 0.199.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/src/contract.js CHANGED
@@ -1044,6 +1044,51 @@ const HOOKS = [
1044
1044
  requiredContextSlice: ["navigation"],
1045
1045
  scopes: null,
1046
1046
  },
1047
+ {
1048
+ name: "useFilePreview",
1049
+ signature: "useFilePreview(file)",
1050
+ description:
1051
+ "{ uri } an <Image> can show for a file the person picked but has not uploaded yet (a photo waiting in a " +
1052
+ "composer, before it is sent). Native picks carry their own file uri; on the web the SDK makes a temporary " +
1053
+ "address and releases it when the file changes or the component unmounts. uri is null for no file, or a host " +
1054
+ "that cannot preview one. Widgets may not call createObjectURL themselves; this is the sanctioned way. No scope. " +
1055
+ "Identical on web (Player) and the Expo export.",
1056
+ returnShape: {
1057
+ uri: "string | null // pass as <Image source={{ uri }} />; null when there is nothing to show",
1058
+ },
1059
+ requiredContextSlice: [],
1060
+ scopes: null,
1061
+ },
1062
+ {
1063
+ name: "usePageBadge",
1064
+ signature: "usePageBadge(count)",
1065
+ description:
1066
+ "Puts count on the menu entry or tab of the page this widget is on (a chat's unread messages, open tasks). " +
1067
+ "The host sums every widget on the page and keeps the last count after the widget leaves the screen, until it " +
1068
+ "reports again; 0, or anything not a positive number, clears this widget's share. Returns { supported }: false " +
1069
+ "on a host without page badges (the Builder canvas), where it does nothing. Call it at render with the current " +
1070
+ "count, never in a loop. Backed by ctx.navigation.setBadge. Identical on web (Player) and the Expo export.",
1071
+ returnShape: {
1072
+ supported: "boolean // false where the host draws no page badges",
1073
+ },
1074
+ requiredContextSlice: ["navigation"],
1075
+ scopes: null,
1076
+ },
1077
+ {
1078
+ name: "useHardwareBack",
1079
+ signature: "useHardwareBack(handler)",
1080
+ description:
1081
+ "Android's back button, for a widget with pages of its own (a thread over a list, a settings page over a thread). " +
1082
+ "handler runs on a back press while the widget's screen is in view: return true when it went back a step, false to " +
1083
+ "let the app go back. Pass null while there is nothing to go back from. Returns { supported }: false on the web " +
1084
+ "Player, where browsers have no hardware back, so always keep a visible back control too. Backed by the optional " +
1085
+ "ctx.navigation.onHardwareBack(listener) → unsubscribe, which the Expo export answers only for the screen in view.",
1086
+ returnShape: {
1087
+ supported: "boolean // false on the web, which has no hardware back",
1088
+ },
1089
+ requiredContextSlice: ["navigation"],
1090
+ scopes: null,
1091
+ },
1047
1092
  {
1048
1093
  name: "usePageContext",
1049
1094
  signature: "usePageContext()",
@@ -1126,16 +1171,34 @@ const HOOKS = [
1126
1171
  requiredContextSlice: ["assets.list"],
1127
1172
  scopes: null,
1128
1173
  },
1174
+ {
1175
+ name: "useBundledAsset",
1176
+ signature: "useBundledAsset(fileName)",
1177
+ description:
1178
+ "Returns the URL of an image or sound file the widget package ships in " +
1179
+ "its assets/ directory, or null when the package has no file with that " +
1180
+ "name. Only an uploaded package can bundle files; a widget written in " +
1181
+ "the Studio has none.",
1182
+ returnShape: {
1183
+ "(returns)": "string | null",
1184
+ },
1185
+ requiredContextSlice: ["widget.assets"],
1186
+ scopes: null,
1187
+ },
1129
1188
  {
1130
1189
  name: "useFilestoreFiles",
1131
- signature: "useFilestoreFiles({ spaceType, folderId, q, type })",
1132
- optionalArgs: ["folderId", "q", "type"],
1190
+ signature: "useFilestoreFiles({ spaceType, recordId, folderId, q, type })",
1191
+ optionalArgs: ["recordId", "folderId", "q", "type"],
1133
1192
  description:
1134
- "Browse the end-user's Filestore files in a project, personal, or public " +
1135
- "space. The hook resolves owner_id from the host context (tenant for " +
1136
- "project/public, app user for personal) — the widget only chooses the " +
1193
+ "Browse the end-user's Filestore files in a project, personal, public or " +
1194
+ "record space. The hook resolves owner_id from the host context (tenant " +
1195
+ "for project/public, app user for personal) — the widget only chooses the " +
1137
1196
  "space. A public space is world-readable: its files render even for " +
1138
- "logged-out Player visitors. Reads ctx.filestore.files.list and unwraps " +
1197
+ "logged-out Player visitors. A record space (`{ spaceType: \"record\", " +
1198
+ "recordId }`) holds one datastore record's attachments, readable by " +
1199
+ "exactly whoever may read that record; it has no folders, lists newest " +
1200
+ "first, and `type: \"media\"` narrows it to images and videos " +
1201
+ "(`\"document\"` to everything else). Reads ctx.filestore.files.list and unwraps " +
1139
1202
  "{ data, meta } to the files array. Every row carries a ready-to-render " +
1140
1203
  "`url` (its absolutized presigned_url, aliased by the filestore client) " +
1141
1204
  "so a gallery renders `files.map(f => f.url)` directly. Every row ALSO " +
@@ -1190,11 +1253,13 @@ const HOOKS = [
1190
1253
  },
1191
1254
  {
1192
1255
  name: "useFilestoreUpload",
1193
- signature: "useFilestoreUpload({ spaceType, folderId, compress })",
1194
- optionalArgs: ["folderId", "compress"],
1256
+ signature: "useFilestoreUpload({ spaceType, recordId, folderId, compress })",
1257
+ optionalArgs: ["recordId", "folderId", "compress"],
1195
1258
  description:
1196
1259
  "Upload a file into the end-user's Filestore space. The widget passes " +
1197
- "the SPACE (`{ spaceType, folderId? }`); the hook resolves owner_id " +
1260
+ "the SPACE (`{ spaceType, folderId? }`, or `{ spaceType: \"record\", " +
1261
+ "recordId }` to attach the file to a datastore record: that needs write " +
1262
+ "on the record, and only the uploader may later delete it); the hook resolves owner_id " +
1198
1263
  "from the host context, builds a multipart FormData with the " +
1199
1264
  "snake_case fields the backend reads verbatim (`space_type`, " +
1200
1265
  "`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
@@ -1318,9 +1383,12 @@ const HOOKS = [
1318
1383
  signature: "useFilestoreCopy()",
1319
1384
  description:
1320
1385
  "Copy a Filestore file into any space and folder the caller may add files " +
1321
- "to, including ACROSS spaces (project, public, and the signed-in user's " +
1322
- "personal space). `copy(fileId, { spaceType, folderId?, name? })` names the " +
1323
- "DESTINATION (spaceType is required); the hook resolves its owner_id from " +
1386
+ "to, including ACROSS spaces (project, public, the signed-in user's " +
1387
+ "personal space, and a datastore record's attachments). " +
1388
+ "`copy(fileId, { spaceType, recordId?, folderId?, name? })` names the " +
1389
+ "DESTINATION (spaceType is required; `{ spaceType: \"record\", recordId }` " +
1390
+ "attaches the copy to that record, which needs write access to it and has " +
1391
+ "no folders); the hook resolves its owner_id from " +
1324
1392
  "the host context and posts through ctx.filestore.files.copy. Resolves to " +
1325
1393
  "the NEW file row (its own id and url) — the source is untouched and the " +
1326
1394
  "bytes are duplicated, so later changes to one never affect the other. A " +
@@ -1332,7 +1400,7 @@ const HOOKS = [
1332
1400
  "STORAGE_QUOTA_EXCEEDED when the destination space is full; 409 " +
1333
1401
  "FILE_PROCESSING while a video is still being processed.",
1334
1402
  returnShape: {
1335
- copy: "(fileId, { spaceType, folderId?, name? }) => Promise<FilestoreFile>",
1403
+ copy: "(fileId, { spaceType, recordId?, folderId?, name? }) => Promise<FilestoreFile> // recordId with spaceType \"record\"",
1336
1404
  copying: "boolean",
1337
1405
  error: "Error | null",
1338
1406
  lastCopied: "FilestoreFile | null",
@@ -1340,6 +1408,26 @@ const HOOKS = [
1340
1408
  requiredContextSlice: ["filestore.files"],
1341
1409
  scopes: ["files.write:*"],
1342
1410
  },
1411
+ {
1412
+ name: "useFilestoreDelete",
1413
+ signature: "useFilestoreDelete()",
1414
+ description:
1415
+ "Delete a Filestore file the caller may delete. `remove(fileId)` calls " +
1416
+ "ctx.filestore.files.remove: a record attachment (a file in a record " +
1417
+ "space) is deleted permanently and only its uploader may do it; a file in " +
1418
+ "any other space moves to the trash. Use it when the thing that showed " +
1419
+ "the file is withdrawn, such as an unsent chat message, so the file stops " +
1420
+ "being readable rather than merely hidden. Refusals arrive as " +
1421
+ "`err.status`: 403 when the caller may not delete it, 404 when it is not " +
1422
+ "visible.",
1423
+ returnShape: {
1424
+ remove: "(fileId) => Promise<void>",
1425
+ removing: "boolean",
1426
+ error: "Error | null",
1427
+ },
1428
+ requiredContextSlice: ["filestore.files"],
1429
+ scopes: ["files.write:*"],
1430
+ },
1343
1431
  {
1344
1432
  name: "useFileSignature",
1345
1433
  signature: "useFileSignature(fileId, existingSignatureId)",
@@ -1658,6 +1746,8 @@ const HOOKS = [
1658
1746
  name: "useDirectory",
1659
1747
  signature: "useDirectory(query)",
1660
1748
  optionalArgs: ["query"],
1749
+ description:
1750
+ "Read-only roster of app users. query is { q?, role?, is_active?, group_id?, ids?, limit?, offset? }; ids (1 to 100) resolves the names behind ids a widget already holds.",
1661
1751
  returnShape: {
1662
1752
  users: "Array<{ id, name, role }> // snake_case rows; unwrapped from { data, meta }",
1663
1753
  loading: "boolean",
@@ -1745,6 +1835,53 @@ const HOOKS = [
1745
1835
  requiredContextSlice: ["notifications.send"],
1746
1836
  scopes: ["notifications.send:appUser"],
1747
1837
  },
1838
+ // sc-10268 — the signed-in user's mute of one record's notifications.
1839
+ // Reads ctx.notifications.mutes. No scope: it only touches the caller's
1840
+ // own setting, and the server requires their read access to the record.
1841
+ {
1842
+ name: "useRecordMute",
1843
+ signature: "useRecordMute(tableId, recordId)",
1844
+ description:
1845
+ "The signed-in user's mute of the notifications ONE datastore record produces for them (a chat conversation, a " +
1846
+ "thread). mute(until) takes an ISO time or a Date, or null (or nothing) to mute until unmute(). While muted, a " +
1847
+ "server action's notifications.notifyRecordSubjects on that record sends this user nothing: no inbox row, no push, " +
1848
+ "no email. mutedUntil is an ISO string, or null when muted until unmuted; muted turns false by itself once " +
1849
+ "mutedUntil passes. The user must be able to read the record, otherwise every call fails with NotificationError " +
1850
+ "{ code: \"RECORD_NOT_FOUND\" }, exactly as for a missing record. A falsy tableId or recordId returns a stable " +
1851
+ "not-muted result with no network call. On a host that cannot mute (the Builder canvas preview) it reads not " +
1852
+ "muted and mute() / unmute() reject with NOTIFICATIONS_UNAVAILABLE instead of the hook throwing at render. " +
1853
+ "Needs no scope: it only ever changes the caller's own setting. Identical on web (Player) and the Expo export.",
1854
+ returnShape: {
1855
+ muted: "boolean",
1856
+ mutedUntil: "string | null // ISO time the mute ends; null when muted until unmuted, or not muted",
1857
+ loading: "boolean",
1858
+ error: "NotificationError | null",
1859
+ mute: "(until?: string | Date | null) => Promise<void> // null or omitted = until unmuted",
1860
+ unmute: "() => Promise<void>",
1861
+ refetch: "() => Promise<void>",
1862
+ },
1863
+ requiredContextSlice: ["notifications.mutes"],
1864
+ scopes: null,
1865
+ },
1866
+ {
1867
+ name: "useRecordMutes",
1868
+ signature: "useRecordMutes(tableId)",
1869
+ description:
1870
+ "Which records of ONE table the signed-in user has muted, read in one request for a whole list (a chat's " +
1871
+ "conversations), never one useRecordMute per row. isMuted(recordId) is true while that record's mute is active " +
1872
+ "and turns false by itself once a timed mute lapses. A mute or unmute made through useRecordMute on the same table " +
1873
+ "shows here at once. A falsy tableId, or a host that cannot mute (the Builder canvas preview), reads nothing " +
1874
+ "muted with no network call. Needs no scope: it only reads the caller's own settings. Identical on web (Player) " +
1875
+ "and the Expo export.",
1876
+ returnShape: {
1877
+ isMuted: "(recordId: string) => boolean",
1878
+ loading: "boolean",
1879
+ error: "NotificationError | null",
1880
+ refetch: "() => Promise<void>",
1881
+ },
1882
+ requiredContextSlice: ["notifications.mutes"],
1883
+ scopes: null,
1884
+ },
1748
1885
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
1749
1886
  // `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
1750
1887
  // Reads need `users.read:*` scope; edit-style mutations (invite /
@@ -2298,9 +2435,12 @@ const HOOKS = [
2298
2435
  optionalArgs: ["options"],
2299
2436
  description:
2300
2437
  "Take a photo or choose one from the device library. Returns { asset, loading, error, supported, capture, pick, reset }. " +
2438
+ "pick({ multiple: true }) lets the user choose up to 10 photos at once and resolves an ARRAY of assets (asset then holds that array); " +
2439
+ "pick({ videos: true }) offers videos beside photos — a video asset has a video/* mimeType and durationMs (null for a photo); play it with <Video>. " +
2440
+ "capture() always takes one. " +
2301
2441
  "Capture is IMPERATIVE — call capture() or pick() from a user gesture (a tap); the browser and the mobile OS gate the " +
2302
2442
  "permission prompt on a gesture, so it NEVER opens on mount. Both resolve to a normalised asset " +
2303
- "{ uri, name, mimeType, width, height, size, file }, or NULL when the user dismisses the picker — dismissal is the " +
2443
+ "{ uri, name, mimeType, width, height, size, durationMs, file }, or NULL when the user dismisses the picker — dismissal is the " +
2304
2444
  "common case and is deliberately NOT an error, so no try/catch is needed on the happy path. They reject with a " +
2305
2445
  "CameraError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. `asset.file` is already the right " +
2306
2446
  "upload part for the host — a BLOB on both (a File on web, expo-file-system's File on native), because Expo's fetch " +
@@ -2313,13 +2453,13 @@ const HOOKS = [
2313
2453
  "rejects UNSUPPORTED — there is no silent fall back to a file dialog.",
2314
2454
  returnShape: {
2315
2455
  asset:
2316
- "{ uri, name, mimeType, width, height, size, file } | null",
2456
+ "{ uri, name, mimeType, width, height, size, durationMs, file } | null",
2317
2457
  loading: "boolean",
2318
2458
  error: "CameraError | null",
2319
2459
  supported: "boolean // false when the host brokers no camera",
2320
2460
  capture:
2321
2461
  "() => Promise<asset | null> // null if dismissed; rejects with CameraError",
2322
- pick: "() => Promise<asset | null> // null if dismissed; rejects with CameraError",
2462
+ pick: "(options?: { multiple?: boolean, videos?: boolean }) => Promise<asset | asset[] | null> // { multiple: true } resolves an array of up to 10; { videos: true } offers videos too; null if dismissed; rejects with CameraError",
2323
2463
  reset: "() => void // clear the asset + error and release it",
2324
2464
  },
2325
2465
  requiredContextSlice: [],
@@ -2529,6 +2669,14 @@ const PRIMITIVES = [
2529
2669
  rnComponent: "expo-linear-gradient",
2530
2670
  docsUrl: "https://docs.expo.dev/versions/latest/sdk/linear-gradient/",
2531
2671
  },
2672
+ // sc-10268 — inline video playback with no platform split.
2673
+ {
2674
+ name: "Video",
2675
+ description:
2676
+ "Plays a video file inline with the platform's own controls. `<Video source={url} contentFit=\"cover\" style={{ width: 240, height: 180, borderRadius: theme.radii.md, overflow: \"hidden\" }} />`. `source` is a playable URL (a filestore file's url, or a picked asset's `uri` for a preview); `controls` (default true) shows play/seek/fullscreen; `autoPlay` starts it muted (browsers only autoplay muted video); `contentFit` is \"contain\" (default, letterboxed) or \"cover\"; `onError()` fires when the file cannot be played, so offer a download instead. Before playback it shows the first frame. Size it with `style` (it fills its box). Web uses the browser's <video>; native uses expo-video's VideoView, so the same widget plays on both hosts. A browser plays only the codecs it supports, so H.264 MP4 is the format that plays everywhere.",
2677
+ rnComponent: "expo-video",
2678
+ docsUrl: "https://docs.expo.dev/versions/latest/sdk/video/",
2679
+ },
2532
2680
  // sc-6531 (REQ-AI-AGENT-DESIGN-LIFT) — the hover/press lift affordance. A
2533
2681
  // FUNCTION primitive, not a component: it resolves the interaction tokens per
2534
2682
  // input, so one declaration answers the pointer on web and the press on
@@ -2628,7 +2776,7 @@ const PRIMITIVES = [
2628
2776
  {
2629
2777
  name: "Avatar",
2630
2778
  description:
2631
- 'Themed picture of a person. `<Avatar name={row.owner.label} url={photoUrl} size="sm" />`. THE way to show a PERSON: in a `<ListRow>` leading slot, a profile header, a list of members. It falls back from the picture to the name\'s initials to a person glyph, so the slot is never blank. Never use it for a place, product or topic. Props: `name` (the initials and the accessibility label come from it), `url` (an absolute image URL; resolve a file field to a URL first), `size` ("sm" 32 | "md" 40 | "lg" 56 | "xl" 80, or a number of px), `shape` ("circle" | "rounded"), `accessibilityLabel`, `style`, `testID`.',
2779
+ 'Themed picture of a person. `<Avatar name={row.owner.label} url={photoUrl} size="sm" />`. THE way to show a PERSON: in a `<ListRow>` leading slot, a profile header, a list of members. It falls back from the picture to the name\'s initials to a person glyph, so the slot is never blank. Never use it for a place, product or topic. Props: `name` (the initials and the accessibility label come from it), `seed` (a stable id such as the user\'s id: the initials sit on that person\'s own colour, the same one the Studio\'s member lists use, so people in a list tell apart at a glance; `avatarColorFor(seed)` returns it), `url` (an absolute image URL; resolve a file field to a URL first), `size` ("sm" 32 | "md" 40 | "lg" 56 | "xl" 80, or a number of px), `shape` ("circle" | "rounded"), `accessibilityLabel`, `style`, `testID`.',
2632
2780
  rnComponent: null,
2633
2781
  docsUrl: null,
2634
2782
  },
@@ -2750,6 +2898,19 @@ const ACTION_SCRIPT_GLOBALS = [
2750
2898
  // Mirrors action.service.js SCRIPT_MAX_BYTES.
2751
2899
  const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
2752
2900
 
2901
+ // The one package size limit; the backend derives its upload caps from it.
2902
+ const PACKAGE_MAX_BYTES = 12 * 1024 * 1024;
2903
+
2904
+ const BUNDLED_ASSETS = {
2905
+ directory: "assets",
2906
+ maxFiles: 50,
2907
+ maxNameLength: 64,
2908
+ types: {
2909
+ image: { extensions: ["png", "jpg", "jpeg", "gif", "webp"] },
2910
+ audio: { extensions: ["mp3", "m4a", "wav"] },
2911
+ },
2912
+ };
2913
+
2753
2914
  /**
2754
2915
  * sc-4915 — the trigger set a manifest action declares, canonically ordered.
2755
2916
  *
@@ -2913,13 +3074,13 @@ const MANIFEST_SCHEMA = {
2913
3074
  type: "object",
2914
3075
  required: false,
2915
3076
  description:
2916
- "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected.",
3077
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation).",
2917
3078
  },
2918
3079
  translations: {
2919
3080
  type: "object",
2920
3081
  required: false,
2921
3082
  description:
2922
- "Optional (REQ-L10N-WIDGET). Translation strings the widget ships. Shape { <key>: { en: string, <locale>?: string, ... } } — `en` is REQUIRED per key. At install the host merges these into the tenant's localization dictionary under a per-widget namespace (`widget.<manifest.id>.<key>`); `useI18n().t(\"<key>\")` resolves the namespaced key automatically, so authors never type the prefix. Non-destructive: an existing admin-edited value is never overwritten, and a language the tenant has not added is never created (values for absent locales are dropped; the widget's `en` seeds the tenant's base language so every key renders). Keys persist across uninstalls; admins prune them from the Translations admin (bulk delete by `widget.<id>.` prefix). Limits: ≤100 keys; each key matches /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; each value ≤1 KB; the namespaced key must fit the dictionary key cap (128 chars).",
3083
+ "Optional (REQ-L10N-WIDGET). Translation strings the widget ships. Shape { <key>: { en: string, <locale>?: string, ... } } — `en` is REQUIRED per key. At install the host merges these into the tenant's localization dictionary under a per-widget namespace (`widget.<manifest.id>.<key>`); `useI18n().t(\"<key>\")` resolves the namespaced key automatically, so authors never type the prefix. Non-destructive: an existing admin-edited value is never overwritten, and a language the tenant has not added is never created (values for absent locales are dropped; the widget's `en` seeds the tenant's base language so every key renders). Keys persist across uninstalls; admins prune them from the Translations admin (bulk delete by `widget.<id>.` prefix). Limits: ≤200 keys; each key matches /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; each value ≤1 KB; the namespaced key must fit the dictionary key cap (128 chars).",
2923
3084
  default: {},
2924
3085
  },
2925
3086
  };
@@ -2933,9 +3094,15 @@ const WIDGET_CONTEXT_SHAPE = {
2933
3094
  },
2934
3095
  widget: {
2935
3096
  description:
2936
- "Identity of the currently rendered widget instance ({ id, version }).",
3097
+ "Identity of the currently rendered widget instance ({ id, version }) " +
3098
+ "and the files its package bundles.",
2937
3099
  required: true,
2938
- fields: { id: "manifest.id", version: "manifest.version" },
3100
+ fields: {
3101
+ id: "manifest.id",
3102
+ version: "manifest.version",
3103
+ assets:
3104
+ "{ [fileName]: string } — bundled file name → absolute URL; {} when the package bundles nothing",
3105
+ },
2939
3106
  },
2940
3107
  // REQ-LAY-08 — optional host layout hint backing useFill(). True when the
2941
3108
  // host sized this widget to fill its layout slot's available height (a
@@ -2987,7 +3154,8 @@ const WIDGET_CONTEXT_SHAPE = {
2987
3154
  },
2988
3155
  },
2989
3156
  navigation: {
2990
- description: "{ push(path), replace(path), back() }.",
3157
+ description:
3158
+ "{ push(path), replace(path), back() }, and optionally setBadge(count) — the page badge usePageBadge() writes, bound by the host to this widget on its page — and onHardwareBack(listener) → unsubscribe, Android's back button for useHardwareBack(), which the native host answers only for the screen in view.",
2991
3159
  required: true,
2992
3160
  fields: { push: "function", replace: "function", back: "function" },
2993
3161
  },
@@ -3158,9 +3326,9 @@ const WIDGET_CONTEXT_SHAPE = {
3158
3326
  // sc-890 — backs useSendNotification(). Mirror of contract.cjs.
3159
3327
  notifications: {
3160
3328
  description:
3161
- "Injected @colixsystems/notifications-client instance (sc-890). { send(body) -> Promise<notification> }. Backs useSendNotification(); requires the notifications.send:appUser scope. The host POSTs the body snake_case verbatim ({ recipient_user_id, title, body, link?, payload? }) to /notifications/send and returns the created notification row; the call is imperative (a send never fires on mount).",
3329
+ "Injected @colixsystems/notifications-client instance (sc-890). { send(body) -> Promise<notification>, mutes: { get, set, clear } }. send backs useSendNotification(); requires the notifications.send:appUser scope. The host POSTs the body snake_case verbatim ({ recipient_user_id, title, body, link?, payload? }) to /notifications/send and returns the created notification row; the call is imperative (a send never fires on mount). mutes backs useRecordMute() and useRecordMutes() (sc-10268): get({ table_id, record_id }) / set({ table_id, record_id, muted_until }) -> { muted, muted_until }, clear({ table_id, record_id }) -> undefined, list({ table_id, limit?, offset? }) -> { data: [{ record_id, muted_until }], meta }. mutes is optional — a host without it (the Builder canvas preview) leaves useRecordMute() reading not muted.",
3162
3330
  required: true,
3163
- fields: { send: "function" },
3331
+ fields: { send: "function", mutes: "object" },
3164
3332
  },
3165
3333
  // REQ-IDENT (sc-4313) — backs useIdentification(). Mirror of contract.cjs.
3166
3334
  identification: {
@@ -3471,7 +3639,8 @@ const VETTED_IMPORTS = [
3471
3639
  platforms: ["native"],
3472
3640
  category: "media",
3473
3641
  description:
3474
- "Native video playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <video> element in widget.web.jsx.",
3642
+ "Native video playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <video> element in widget.web.jsx. " +
3643
+ "To play a video file on BOTH hosts, prefer the SDK's <Video> primitive — no platform split needed.",
3475
3644
  },
3476
3645
  {
3477
3646
  specifier: "@react-native-community/datetimepicker",
@@ -5104,7 +5273,35 @@ const CONTRACT = deepFreeze({
5104
5273
  // as before; a malformed `via` is dropped, or voids the gradient under the
5105
5274
  // new `{ strict: true }` option. New `componentGradientColors` / `componentGradientMidpoint`
5106
5275
  // read the paint list and the ink basis off a normalised gradient.
5107
- version: "1.157.0",
5276
+ // 1.158.0: additive (sc-10268) — a datastoreTemplate column may declare
5277
+ // `inheritAclReadOnly` (with `inheritAcl`), `defaultValue`, and
5278
+ // `defaultLocked` (with a "@currentUser" / "@now" default), and
5279
+ // `immutable` (write-once; not on MANY_TO_MANY).
5280
+ // (sc-10269) `useFilestoreFiles` / `useFilestoreUpload` accept
5281
+ // `spaceType: "record"` with `recordId`: a record's private attachments.
5282
+ // New `useFilestoreDelete()` → `{ remove, removing, error }`.
5283
+ // `useCamera().pick({ multiple: true })` resolves an array of up to 10.
5284
+ // `<Avatar seed>` + `avatarColorFor(seed)`: a person's own monogram
5285
+ // colour, shared with the Studio's member lists.
5286
+ // New `useRecordMute(tableId, recordId)` mutes one record's
5287
+ // notifications for the signed-in user, through the new
5288
+ // `ctx.notifications.mutes` ({ get, set, clear }).
5289
+ // `useFilestoreCopy().copy` takes `{ spaceType: "record", recordId }`.
5290
+ // `useDirectory({ ids })` resolves the names behind up to 100 user ids.
5291
+ // `manifest.translations` may declare up to 200 keys (was 100).
5292
+ // New `useRecordMutes(tableId)` lists the muted records at once;
5293
+ // `usePageBadge(count)` counts on the page's menu entry through the new
5294
+ // optional `ctx.navigation.setBadge`; `useFilePreview(file)` shows a
5295
+ // picked file before upload. New `<Video>` plays a file inline on both
5296
+ // hosts, and `useCamera().pick({ videos: true })` offers videos, an
5297
+ // asset carrying `durationMs`. New `useHardwareBack(handler)` walks a
5298
+ // widget's own pages on Android's back button (optional
5299
+ // `ctx.navigation.onHardwareBack`; unsupported on the web).
5300
+ // 1.159.0: additive (sc-10721) — a package may ship images and sounds in a
5301
+ // flat `assets/` directory (`bundledAssets`, within `packageMaxBytes`).
5302
+ // New `useBundledAsset(fileName)` resolves one through the new
5303
+ // `widget.assets` map; linter rule `bundled-asset-missing` (error).
5304
+ version: "1.159.0",
5108
5305
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5109
5306
  hooks: HOOKS,
5110
5307
  primitives: PRIMITIVES,
@@ -5114,6 +5311,8 @@ const CONTRACT = deepFreeze({
5114
5311
  actionTriggerTypes: ACTION_TRIGGER_TYPES,
5115
5312
  actionScriptGlobals: ACTION_SCRIPT_GLOBALS,
5116
5313
  actionScriptMaxBytes: ACTION_SCRIPT_MAX_BYTES,
5314
+ packageMaxBytes: PACKAGE_MAX_BYTES,
5315
+ bundledAssets: BUNDLED_ASSETS,
5117
5316
  themeTokens: DEFAULT_THEME_TOKENS,
5118
5317
  themeColorTokens: THEME_COLOR_TOKENS,
5119
5318
  themeComponents: THEME_COMPONENTS,