@colixsystems/widget-sdk 0.188.0 → 0.190.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
@@ -53,6 +53,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
53
53
  | **AGENTS** | `useAgent(agentRef)` | `{ send, cancel, reset, messages, sending, resuming, error, available }` | `ctx.agents` — `ai.invoke:*`. Hold a conversation with one of the WORKSPACE's own AI agents (an author-written name + system prompt). Sending is IMPERATIVE: call `send(text)` from an event handler, never on mount — the first send opens the conversation, so a widget nobody types into costs nothing. On mount the hook DOES reopen the caller's most recent thread with this agent, so a refresh or a navigation keeps the conversation instead of starting a blank one; that resume only READS and spends no credit. `resuming` is true while it runs — render a loading state, not an empty thread. `messages` is the running transcript, oldest first. You **cannot** set or read the system prompt, replay an edited transcript, or pick a model: the server assembles every turn from the stored prompt plus the stored history, and `content` is the only thing that reaches the wire. `reset()` drops the thread (a "new chat" affordance), and the new thread is the one a later mount resumes. **Every send spends the workspace's AI credits** and is rate-limited per caller AND per workspace. Rejects with `AgentError`; branch on `.code` — `AI_QUOTA_EXCEEDED` (429) is the workspace's credit ceiling and will NOT clear on a retry, unlike `RATE_LIMITED`, so never offer "try again" for it, and `AGENT_DISABLED` (409) means the author turned it off, so hide the affordance. Never surface a raw error or the workspace's billing state to an app user. `available` is false when no agent is bound or the host brokers none — render a visible unbound state, never a blank box. An assistant message that used a tool also carries **`activity`** — what the agent DID with the user's data: the table it read, or the table and columns it CHANGED (never the values, and never a row the permissions refused). **Render a write.** A reply saying "done!" is not evidence a row was written, and if the agent got it wrong the user has no other way to notice. A talk-only turn omits the key entirely. |
54
54
  | **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*`. **To CLEAR a cell, pass it empty** — `update(id, { when: null })` (also `""` or `[]`) empties that cell and it reads back as `null` (`[]` on an array column). There is no separate clear call, so never write a sentinel such as `0` or a placeholder date to mean "no value". `false` and `0` are values, not clears, and a REQUIRED column cannot be cleared. |
55
55
  | **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: 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. |
56
+ | **DATASTORE** | `useTyping(tableId, recordId)` | `{ typingUserIds, setTyping, status }` — status `"connecting" \| "live" \| "unavailable"` | `records(table).typing` — `datastore.read:<table>`. Who else is typing in one record, over the same socket as `useDatastoreSubscription`. Call `setTyping(true)` on every keystroke (throttled to one signal per 3 s; `false` is sent after 5 s idle) and `setTyping(false)` on send, clear or blur. `typingUserIds` lists the OTHER users' ids; each drops out 6 s after their last signal. Needs a signed-in user who can read the record. Never throws: an anonymous visitor, a refused subscription or a host whose client lacks `typing` reads `{ typingUserIds: [], status: "unavailable" }`. Re-subscribes when either id changes. |
56
57
  | **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) |
57
58
  | **DATASTORE** | `useCanWrite(tableId, options)` (optional: 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. |
58
59
  | **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
@@ -75,7 +76,32 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
75
76
 
76
77
  ## Status
77
78
 
78
- `v0.188.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**.
79
+ `v0.190.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**.
80
+
81
+ ### What's new in 0.190.0 (contract 1.151.0)
82
+
83
+ **New `useTyping(tableId, recordId)` hook: a typing indicator for one record.** It returns `{ typingUserIds, setTyping, status }`. Call `setTyping(true)` on every keystroke in your composer and `setTyping(false)` when the message is sent, the field is cleared or it loses focus. The hook throttles for you: it sends at most one signal every 3 s, and sends `false` on its own after 5 s without a keystroke. `typingUserIds` lists the other users typing in that record (never yourself); each one drops out 6 s after their last signal, so a closed tab never leaves a stale indicator. Signals carry no message content and are never stored.
84
+
85
+ Key it on the record the conversation lives in (a thread or channel row), which the user must be able to read. It rides the same realtime connection as `useDatastoreSubscription`, so a widget holds one socket for both. `status` is `"connecting"`, `"live"` or `"unavailable"`; it is `"unavailable"` for an anonymous visitor, a refused subscription, a preview with no backend, and a host whose datastore client predates typing. The hook never throws, so render the indicator only when `typingUserIds` is non-empty:
86
+
87
+ ```jsx
88
+ const { typingUserIds, setTyping } = useTyping(threadsTableId, threadId);
89
+ // <TextInput onChangeText={(t) => { setDraft(t); setTyping(t.length > 0); }} onBlur={() => setTyping(false)} />
90
+ // {typingUserIds.length > 0 && <Text>{typingUserIds.length === 1 ? "Someone is typing…" : "Several people are typing…"}</Text>}
91
+ ```
92
+
93
+ Declare `datastore.read` for the table. Additive; works identically in the web Player and the native export.
94
+
95
+ ### What's new in 0.189.0 (contract 1.150.0)
96
+
97
+ **The import check now catches two shapes that passed lint and then failed to load in the web Player.** This narrows `import-not-vetted`:
98
+
99
+ - **Imports wrapped over several lines.** The check read one line at a time, so a Prettier-wrapped import of a package that is not on the vetted list was never seen. It now reads the whole file and reports the finding on the `from` line. `export { … } from "pkg"` and `export * from "pkg"` are checked too, and so are forms written without spaces (`import{a}from"pkg"`) or inside an expression (`x = require("pkg")`).
100
+ - **Subpaths of packages the web Player provides.** The web Player supplies some packages itself and loads them by their exact name only. `CONTRACT.hostExternalSpecifiers` lists them; today they are `react` (and its JSX runtimes), `react-dom`, `react-native`, `@colixsystems/widget-sdk`, `lucide-react-native`, `react-native-svg`, `react-native-qrcode-svg`, `date-fns`, `decimal.js`, `d3-scale`, `d3-shape`, `d3-array` and `simple-statistics`. A subpath of one of them, such as `date-fns/format` or `react-native/Libraries/…`, cannot load on web. It is now `import-not-vetted` in any file that ships to web. Import from the package itself: `import { format } from "date-fns"`.
101
+
102
+ A subpath of a package your bundle includes is still allowed, for example `leaflet/dist/leaflet.css`, a Leaflet marker image or `react-native-vector-icons/MaterialIcons`. A file that ships only to native (`widget.native.jsx`, or a manifest whose `supportedPlatforms` is `["native"]`) keeps every vetted subpath, because the native build resolves them.
103
+
104
+ `CONTRACT.subpathImportRoots` lists the vetted packages whose subpaths load on web, and the new `subpathImportRootsFor(manifest)` export of `@colixsystems/widget-sdk/linter` returns the roots that load for a file with that manifest. `appstudio-widget lint --manifest` now checks `widget.web.jsx` and `widget.native.jsx` against the platform each one ships to, as the submission check already did.
79
105
 
80
106
  ### What's new in 0.188.0 (contract 1.149.0)
81
107
 
package/dist/cli.js CHANGED
@@ -5,10 +5,15 @@
5
5
  // — REQ-WSDK-DEVKIT local dev server
6
6
 
7
7
  import { readFileSync } from "node:fs";
8
- import { resolve } from "node:path";
8
+ import { basename, resolve } from "node:path";
9
9
  import { pathToFileURL } from "node:url";
10
10
  import { argv, exit, stderr, stdout } from "node:process";
11
- import { lintSource, lintStyleWiring, lintEditability } from "./linter.js";
11
+ import {
12
+ lintSource,
13
+ lintStyleWiring,
14
+ lintEditability,
15
+ narrowManifestForFile,
16
+ } from "./linter.js";
12
17
  import { startDevServer } from "./devserver.js";
13
18
 
14
19
  function usage() {
@@ -99,9 +104,15 @@ async function runLint(rest) {
99
104
  }
100
105
  }
101
106
  const label = positionals.join(", ");
107
+ // Narrow per split-impl file, as the submission gate does.
102
108
  const perFile = Object.entries(files).map(([filePath, source]) => [
103
109
  filePath,
104
- lintSource(source, manifest ? { manifest } : undefined),
110
+ lintSource(
111
+ source,
112
+ manifest
113
+ ? { manifest: narrowManifestForFile(manifest, basename(filePath)) }
114
+ : undefined,
115
+ ),
105
116
  ]);
106
117
  const wiring = manifest
107
118
  ? lintStyleWiring(manifest, files)
package/dist/contract.cjs CHANGED
@@ -2043,6 +2043,40 @@ const HOOKS = [
2043
2043
  requiredContextSlice: ["datastore.records"],
2044
2044
  scopes: ["datastore.read:<table>"],
2045
2045
  },
2046
+ // REQ-RT-08 — typing signals for one record.
2047
+ {
2048
+ name: "useTyping",
2049
+ signature: "useTyping(tableId, recordId)",
2050
+ description:
2051
+ "Who else is typing in one record, and announcing your own typing, via " +
2052
+ "ctx.datastore.records(tableId).typing(recordId, ...). It shares the " +
2053
+ "datastore client's one realtime socket with useDatastoreSubscription. " +
2054
+ "Call setTyping(true) on every keystroke (throttled inside: one signal " +
2055
+ "per 3 s at most, and false is sent after 5 s without a keystroke) and " +
2056
+ "setTyping(false) on send, clear or blur. typingUserIds lists the OTHER " +
2057
+ "users typing (user ids, never yourself); each drops out 6 s after their " +
2058
+ "last signal. Signals carry no message content and are never stored. " +
2059
+ "Typing needs a signed-in user who can read the record; key it on the " +
2060
+ "record the conversation lives in (a thread or channel row). Gate the " +
2061
+ "composer that calls setTyping on useCanWrite as usual. status is " +
2062
+ "'connecting' | 'live' | 'unavailable'. " +
2063
+ "Never throws: with a falsy id, an anonymous visitor, a refused " +
2064
+ "subscription, a host whose datastore client predates typing, or a " +
2065
+ "preview with no backend, it returns { typingUserIds: [], setTyping: no-op, " +
2066
+ "status: 'unavailable' }, so render the indicator only when " +
2067
+ "typingUserIds is non-empty. Re-subscribes when tableId or recordId " +
2068
+ "changes. Declare datastore.read for the table.",
2069
+ returnShape: {
2070
+ typingUserIds:
2071
+ "string[] // other users typing in this record; empty when unavailable",
2072
+ setTyping:
2073
+ "(active: boolean) => void // true per keystroke, false on send/clear/blur",
2074
+ status:
2075
+ "'connecting' | 'live' | 'unavailable' // 'unavailable' → hide the indicator",
2076
+ },
2077
+ requiredContextSlice: ["datastore.records"],
2078
+ scopes: ["datastore.read:<table>"],
2079
+ },
2046
2080
  // REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
2047
2081
  {
2048
2082
  name: "useClipboard",
@@ -3505,6 +3539,49 @@ const ALLOWED_BARE_IMPORTS = [
3505
3539
  ...CORE_INFRA_BARE_IMPORTS,
3506
3540
  ];
3507
3541
 
3542
+ // sc-10502: the bare specifiers the web host RESOLVES for a loaded widget
3543
+ // instead of letting its bundle inline a copy. The web-entry bundler marks
3544
+ // exactly these `external` and the Studio loader shims each by EXACT name, so
3545
+ // a subpath of one (`date-fns/format`) cannot load in the web Player.
3546
+ // `dev-shims.hostExternalSpecifiers()` returns this list; the loader's
3547
+ // module-load guard (frontend widgetLoader.js) throws if its shims drift.
3548
+ //
3549
+ // Why each entry is host-resolved, not bundled:
3550
+ // * react + its JSX runtimes: one React instance (hooks, context).
3551
+ // * react-dom, react-dom/client: react-leaflet portals through react-dom;
3552
+ // a second renderer beside the host's React crashes (sc-1064).
3553
+ // * @colixsystems/widget-sdk: the host-built SDK carries datastore, theme
3554
+ // and navigation.
3555
+ // * react-native, lucide-react-native, react-native-svg,
3556
+ // react-native-qrcode-svg: share the host's react-native-web instance
3557
+ // (sc-1265, sc-6105).
3558
+ // * date-fns and the maths set: an agent widget is transpiled, not
3559
+ // bundled, so the host must resolve its bare imports (sc-7191).
3560
+ const HOST_EXTERNAL_SPECIFIERS = [
3561
+ "react",
3562
+ "react/jsx-runtime",
3563
+ "react/jsx-dev-runtime",
3564
+ "react-dom",
3565
+ "react-dom/client",
3566
+ "@colixsystems/widget-sdk",
3567
+ "lucide-react-native",
3568
+ "react-native-svg",
3569
+ "date-fns",
3570
+ "react-native-qrcode-svg",
3571
+ "react-native",
3572
+ "decimal.js",
3573
+ "d3-scale",
3574
+ "d3-shape",
3575
+ "d3-array",
3576
+ "simple-statistics",
3577
+ ];
3578
+
3579
+ // Vetted roots whose subpaths load on web (`leaflet/dist/leaflet.css`): the
3580
+ // bundle inlines them, so no exact-name host shim stands in the way.
3581
+ const SUBPATH_IMPORT_ROOTS = ALLOWED_BARE_IMPORTS.filter(
3582
+ (spec) => !HOST_EXTERNAL_SPECIFIERS.includes(spec),
3583
+ );
3584
+
3508
3585
  // REQ-WSDK-PLATFORM §3.5: host-API URL patterns the linter scans for as a
3509
3586
  // soft warning. None of these block the lint by themselves — they prompt
3510
3587
  // the marketplace reviewer to spot-check, and they help authors learn the
@@ -4871,7 +4948,20 @@ const CONTRACT = deepFreeze({
4871
4948
  // (error): a file importing leaflet / react-leaflet must import
4872
4949
  // "leaflet/dist/leaflet.css". `leaflet-default-marker-icon` (warning)
4873
4950
  // flags a react-leaflet <Marker> with no `icon`.
4874
- version: "1.149.0",
4951
+ // 1.150.0: narrowing (sc-10502) — the linter scans imports across the whole
4952
+ // file, so a wrapped `import { … } from` (or an `export … from`) of an
4953
+ // unvetted package is `import-not-vetted` on its `from` line. A file
4954
+ // that ships to web may import a SUBPATH only of a bundled vetted
4955
+ // package (`subpathImportRoots`, e.g. "leaflet/dist/leaflet.css"); a
4956
+ // host-provided package's subpath ("date-fns/format") is flagged. A
4957
+ // native-only file keeps every vetted subpath (Metro resolves them).
4958
+ // New `hostExternalSpecifiers` / `subpathImportRoots` lists and the
4959
+ // linter export `subpathImportRootsFor(manifest)`.
4960
+ // 1.151.0: additive (sc-10538) — new `useTyping(tableId, recordId)` returns
4961
+ // `{ typingUserIds, setTyping, status }`: the other users typing in one
4962
+ // record, over the datastore client's single realtime socket. A host
4963
+ // whose client lacks `records(t).typing` reads `status: "unavailable"`.
4964
+ version: "1.151.0",
4875
4965
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4876
4966
  hooks: HOOKS,
4877
4967
  primitives: PRIMITIVES,
@@ -4898,6 +4988,8 @@ const CONTRACT = deepFreeze({
4898
4988
  bannedApis: BANNED_APIS,
4899
4989
  vettedImports: VETTED_IMPORTS,
4900
4990
  allowedBareImports: ALLOWED_BARE_IMPORTS,
4991
+ hostExternalSpecifiers: HOST_EXTERNAL_SPECIFIERS,
4992
+ subpathImportRoots: SUBPATH_IMPORT_ROOTS,
4901
4993
  hostApiUrlPatterns: HOST_API_URL_PATTERNS,
4902
4994
  payloadValueTypes: PAYLOAD_VALUE_TYPES,
4903
4995
  chargeCurrency: CHARGE_CURRENCY,
package/dist/contract.js CHANGED
@@ -2043,6 +2043,40 @@ const HOOKS = [
2043
2043
  requiredContextSlice: ["datastore.records"],
2044
2044
  scopes: ["datastore.read:<table>"],
2045
2045
  },
2046
+ // REQ-RT-08 — typing signals for one record.
2047
+ {
2048
+ name: "useTyping",
2049
+ signature: "useTyping(tableId, recordId)",
2050
+ description:
2051
+ "Who else is typing in one record, and announcing your own typing, via " +
2052
+ "ctx.datastore.records(tableId).typing(recordId, ...). It shares the " +
2053
+ "datastore client's one realtime socket with useDatastoreSubscription. " +
2054
+ "Call setTyping(true) on every keystroke (throttled inside: one signal " +
2055
+ "per 3 s at most, and false is sent after 5 s without a keystroke) and " +
2056
+ "setTyping(false) on send, clear or blur. typingUserIds lists the OTHER " +
2057
+ "users typing (user ids, never yourself); each drops out 6 s after their " +
2058
+ "last signal. Signals carry no message content and are never stored. " +
2059
+ "Typing needs a signed-in user who can read the record; key it on the " +
2060
+ "record the conversation lives in (a thread or channel row). Gate the " +
2061
+ "composer that calls setTyping on useCanWrite as usual. status is " +
2062
+ "'connecting' | 'live' | 'unavailable'. " +
2063
+ "Never throws: with a falsy id, an anonymous visitor, a refused " +
2064
+ "subscription, a host whose datastore client predates typing, or a " +
2065
+ "preview with no backend, it returns { typingUserIds: [], setTyping: no-op, " +
2066
+ "status: 'unavailable' }, so render the indicator only when " +
2067
+ "typingUserIds is non-empty. Re-subscribes when tableId or recordId " +
2068
+ "changes. Declare datastore.read for the table.",
2069
+ returnShape: {
2070
+ typingUserIds:
2071
+ "string[] // other users typing in this record; empty when unavailable",
2072
+ setTyping:
2073
+ "(active: boolean) => void // true per keystroke, false on send/clear/blur",
2074
+ status:
2075
+ "'connecting' | 'live' | 'unavailable' // 'unavailable' → hide the indicator",
2076
+ },
2077
+ requiredContextSlice: ["datastore.records"],
2078
+ scopes: ["datastore.read:<table>"],
2079
+ },
2046
2080
  // REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
2047
2081
  {
2048
2082
  name: "useClipboard",
@@ -3501,6 +3535,49 @@ const ALLOWED_BARE_IMPORTS = [
3501
3535
  ...CORE_INFRA_BARE_IMPORTS,
3502
3536
  ];
3503
3537
 
3538
+ // sc-10502: the bare specifiers the web host RESOLVES for a loaded widget
3539
+ // instead of letting its bundle inline a copy. The web-entry bundler marks
3540
+ // exactly these `external` and the Studio loader shims each by EXACT name, so
3541
+ // a subpath of one (`date-fns/format`) cannot load in the web Player.
3542
+ // `dev-shims.hostExternalSpecifiers()` returns this list; the loader's
3543
+ // module-load guard (frontend widgetLoader.js) throws if its shims drift.
3544
+ //
3545
+ // Why each entry is host-resolved, not bundled:
3546
+ // * react + its JSX runtimes: one React instance (hooks, context).
3547
+ // * react-dom, react-dom/client: react-leaflet portals through react-dom;
3548
+ // a second renderer beside the host's React crashes (sc-1064).
3549
+ // * @colixsystems/widget-sdk: the host-built SDK carries datastore, theme
3550
+ // and navigation.
3551
+ // * react-native, lucide-react-native, react-native-svg,
3552
+ // react-native-qrcode-svg: share the host's react-native-web instance
3553
+ // (sc-1265, sc-6105).
3554
+ // * date-fns and the maths set: an agent widget is transpiled, not
3555
+ // bundled, so the host must resolve its bare imports (sc-7191).
3556
+ const HOST_EXTERNAL_SPECIFIERS = [
3557
+ "react",
3558
+ "react/jsx-runtime",
3559
+ "react/jsx-dev-runtime",
3560
+ "react-dom",
3561
+ "react-dom/client",
3562
+ "@colixsystems/widget-sdk",
3563
+ "lucide-react-native",
3564
+ "react-native-svg",
3565
+ "date-fns",
3566
+ "react-native-qrcode-svg",
3567
+ "react-native",
3568
+ "decimal.js",
3569
+ "d3-scale",
3570
+ "d3-shape",
3571
+ "d3-array",
3572
+ "simple-statistics",
3573
+ ];
3574
+
3575
+ // Vetted roots whose subpaths load on web (`leaflet/dist/leaflet.css`): the
3576
+ // bundle inlines them, so no exact-name host shim stands in the way.
3577
+ const SUBPATH_IMPORT_ROOTS = ALLOWED_BARE_IMPORTS.filter(
3578
+ (spec) => !HOST_EXTERNAL_SPECIFIERS.includes(spec),
3579
+ );
3580
+
3504
3581
  // REQ-WSDK-PLATFORM §3.5: host-API URL patterns the linter scans for as a
3505
3582
  // soft warning. None of these block the lint by themselves — they prompt
3506
3583
  // the marketplace reviewer to spot-check, and they help authors learn the
@@ -4867,7 +4944,20 @@ const CONTRACT = deepFreeze({
4867
4944
  // (error): a file importing leaflet / react-leaflet must import
4868
4945
  // "leaflet/dist/leaflet.css". `leaflet-default-marker-icon` (warning)
4869
4946
  // flags a react-leaflet <Marker> with no `icon`.
4870
- version: "1.149.0",
4947
+ // 1.150.0: narrowing (sc-10502) — the linter scans imports across the whole
4948
+ // file, so a wrapped `import { … } from` (or an `export … from`) of an
4949
+ // unvetted package is `import-not-vetted` on its `from` line. A file
4950
+ // that ships to web may import a SUBPATH only of a bundled vetted
4951
+ // package (`subpathImportRoots`, e.g. "leaflet/dist/leaflet.css"); a
4952
+ // host-provided package's subpath ("date-fns/format") is flagged. A
4953
+ // native-only file keeps every vetted subpath (Metro resolves them).
4954
+ // New `hostExternalSpecifiers` / `subpathImportRoots` lists and the
4955
+ // linter export `subpathImportRootsFor(manifest)`.
4956
+ // 1.151.0: additive (sc-10538) — new `useTyping(tableId, recordId)` returns
4957
+ // `{ typingUserIds, setTyping, status }`: the other users typing in one
4958
+ // record, over the datastore client's single realtime socket. A host
4959
+ // whose client lacks `records(t).typing` reads `status: "unavailable"`.
4960
+ version: "1.151.0",
4871
4961
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4872
4962
  hooks: HOOKS,
4873
4963
  primitives: PRIMITIVES,
@@ -4894,6 +4984,8 @@ const CONTRACT = deepFreeze({
4894
4984
  bannedApis: BANNED_APIS,
4895
4985
  vettedImports: VETTED_IMPORTS,
4896
4986
  allowedBareImports: ALLOWED_BARE_IMPORTS,
4987
+ hostExternalSpecifiers: HOST_EXTERNAL_SPECIFIERS,
4988
+ subpathImportRoots: SUBPATH_IMPORT_ROOTS,
4897
4989
  hostApiUrlPatterns: HOST_API_URL_PATTERNS,
4898
4990
  payloadValueTypes: PAYLOAD_VALUE_TYPES,
4899
4991
  chargeCurrency: CHARGE_CURRENCY,
package/dist/dev-shims.js CHANGED
@@ -19,6 +19,8 @@
19
19
  // runtime-enumerates the host's namespace). Imports of these packages from a
20
20
  // sibling .jsx file return `422` from the dev server with a clear message.
21
21
 
22
+ import { CONTRACT } from "./contract.js";
23
+
22
24
  // React named exports the Studio re-exposes through the shim. Mirrors the
23
25
  // browser's `react` named-export surface. Adding a new hook in a React minor
24
26
  // release means appending it here.
@@ -61,81 +63,16 @@ export const REACT_NAMED_EXPORTS = [
61
63
  // React's automatic JSX runtime: jsx / jsxs / Fragment, plus the dev variant.
62
64
  export const REACT_JSX_RUNTIME_EXPORTS = ["jsx", "jsxs", "Fragment", "jsxDEV"];
63
65
 
64
- // REQ-WSDK-PLATFORM bundle (sc-1064): the canonical list of bare specifiers the
65
- // runtime web host RESOLVES for a loaded widget — i.e. the ones the Studio's
66
- // `widgetLoader.buildShims()` re-exports from `globalThis.__appstudioWidgetHost__`
67
- // rather than letting a bundle inline a second copy. The web-entry bundler
68
- // (`webbundle.bundleWebEntry`, used by both the publish packer and the dev
69
- // server) marks EXACTLY these `external`, so everything else the entry imports
70
- // (`react-leaflet`, `leaflet`, CSS, PNG assets, …) gets inlined.
71
- //
72
- // This is the SINGLE source of truth (CLAUDE.md §3). It MUST stay in lockstep
73
- // with the frontend's `widgetLoader._hostVettedModules()` + the core React/SDK
74
- // shims it builds. Two backstops enforce the equality: (1) this list's
75
- // contents are pinned by the SDK-side importable test
76
- // (`packages/widget-sdk/src/__tests__/host-externals.test.js`), and (2) the
77
- // loader's own module-load guard in `frontend/src/services/widgetLoader.js`
78
- // throws at import time if `hostShimmedSpecifiers()` drifts from
79
- // `hostExternalSpecifiers()` — the loader's shimmed set can't be asserted from
80
- // a Node test (it imports `react-dom` etc.), so that equality is enforced by
81
- // the load-time guard rather than a separate frontend test.
82
- //
83
- // Why each entry is host-resolved (NOT bundled):
84
- // * `react`, `react/jsx-runtime`, `react/jsx-dev-runtime` — the widget MUST
85
- // share the host's ONE React instance (hooks + context); a bundled second
86
- // React breaks both.
87
- // * `react-dom`, `react-dom/client` — `react-leaflet`'s Popup/Pane render
88
- // through `createPortal` from `react-dom`. A bundled second react-dom
89
- // paired with the host's external React is a mismatched renderer and
90
- // crashes. The Studio is a React-DOM app, so it shims its own react-dom.
91
- // On React 19 the two specifiers expose DIFFERENT surfaces — createPortal /
92
- // flushSync / version live on `react-dom`, createRoot / hydrateRoot on
93
- // `react-dom/client` — so the loader stashes + shims each from its own real
94
- // host namespace (sc-1064).
95
- // * `@colixsystems/widget-sdk` — the widget must route hooks through the SAME
96
- // SDK instance the host built (datastore/theme/navigation live there).
97
- // * `lucide-react-native`, `react-native-svg`, `date-fns`,
98
- // `react-native-qrcode-svg` — vetted packages the host already bundles +
99
- // shims (`_hostVettedModules`); sharing the host copy keeps icons/SVG on
100
- // the host's react-native-web instance. qrcode-svg draws through
101
- // react-native-svg, so externalising it keeps the QR on that same
102
- // instance instead of a bundle inlining a second one (sc-6105).
103
- const HOST_EXTERNAL_SPECIFIERS = [
104
- "react",
105
- "react/jsx-runtime",
106
- "react/jsx-dev-runtime",
107
- "react-dom",
108
- "react-dom/client",
109
- "@colixsystems/widget-sdk",
110
- "lucide-react-native",
111
- "react-native-svg",
112
- "date-fns",
113
- "react-native-qrcode-svg",
114
- // sc-1265: a bundled widget (and any vetted RN package it inlines, e.g.
115
- // react-native-gesture-handler) imports `react-native`; the host resolves it
116
- // to its OWN single react-native-web instance through a blob shim, so the
117
- // bundle shares one RN-web (StyleSheet/context) instead of inlining a second
118
- // copy. On native, Metro resolves the real react-native in the export.
119
- "react-native",
120
- // sc-7191: the vetted pure-JS maths set. Externalised for the same reason as
121
- // date-fns — an AI-agent widget is transpiled, never bundled, so the host
122
- // must resolve the bare import at runtime.
123
- "decimal.js",
124
- "d3-scale",
125
- "d3-shape",
126
- "d3-array",
127
- "simple-statistics",
128
- ];
129
-
130
66
  /**
131
67
  * The canonical "host-resolved at runtime" bare-specifier set — the externals
132
- * the web-entry bundler must NOT inline. Returns a fresh array each call so a
133
- * caller can't mutate the source of truth.
68
+ * the web-entry bundler must NOT inline. Sourced from
69
+ * `CONTRACT.hostExternalSpecifiers`, which documents each entry. Returns a
70
+ * fresh array each call so a caller can't mutate the source of truth.
134
71
  *
135
72
  * @returns {string[]}
136
73
  */
137
74
  export function hostExternalSpecifiers() {
138
- return [...HOST_EXTERNAL_SPECIFIERS];
75
+ return [...CONTRACT.hostExternalSpecifiers];
139
76
  }
140
77
 
141
78
  function reactShimSrc() {
package/dist/hooks.js CHANGED
@@ -3834,6 +3834,71 @@ export function useDatastoreSubscription(table, handlers, options) {
3834
3834
  return { status };
3835
3835
  }
3836
3836
 
3837
+ const _NO_TYPERS = Object.freeze([]);
3838
+
3839
+ /**
3840
+ * Who else is typing in one record; call `setTyping` per keystroke.
3841
+ * Never throws: a client without typing reports status "unavailable".
3842
+ *
3843
+ * @param {string} tableId
3844
+ * @param {string} recordId
3845
+ * @returns {{ typingUserIds: string[], setTyping: (active: boolean) => void, status: "connecting" | "live" | "unavailable" }}
3846
+ */
3847
+ export function useTyping(tableId, recordId) {
3848
+ const ctx = useWidgetContextOrThrow("useTyping");
3849
+ const [typingUserIds, setTypingUserIds] = useState(_NO_TYPERS);
3850
+ const [status, setStatus] = useState("connecting");
3851
+ const handleRef = useRef(null);
3852
+ // `ctx` is a fresh identity per host render; the effect keys on the ids.
3853
+ const recordsRef = useRef(null);
3854
+ recordsRef.current =
3855
+ ctx.datastore && typeof ctx.datastore.records === "function"
3856
+ ? ctx.datastore.records
3857
+ : null;
3858
+
3859
+ useEffect(() => {
3860
+ setTypingUserIds(_NO_TYPERS);
3861
+ setStatus("connecting");
3862
+ const unavailable = () => {
3863
+ setStatus("unavailable");
3864
+ return undefined;
3865
+ };
3866
+ if (!tableId || !recordId || typeof recordsRef.current !== "function") {
3867
+ return unavailable();
3868
+ }
3869
+ try {
3870
+ const ns = recordsRef.current(tableId);
3871
+ if (!ns || typeof ns.typing !== "function") return unavailable();
3872
+ let current = true;
3873
+ const handle = ns.typing(recordId, {
3874
+ onChange: (ids) => {
3875
+ if (current) setTypingUserIds(ids);
3876
+ },
3877
+ onStatus: (s) => {
3878
+ if (current) setStatus(s);
3879
+ },
3880
+ });
3881
+ handleRef.current = handle;
3882
+ return () => {
3883
+ current = false;
3884
+ handleRef.current = null;
3885
+ if (handle && typeof handle.stop === "function") handle.stop();
3886
+ };
3887
+ } catch (_) {
3888
+ return unavailable();
3889
+ }
3890
+ }, [tableId, recordId]);
3891
+
3892
+ const setTyping = useCallback((active) => {
3893
+ const handle = handleRef.current;
3894
+ if (handle && typeof handle.setTyping === "function") {
3895
+ handle.setTyping(active === true);
3896
+ }
3897
+ }, []);
3898
+
3899
+ return { typingUserIds, setTyping, status };
3900
+ }
3901
+
3837
3902
  /* ============================================================================
3838
3903
  * ASSETS CLIENT — ctx.assets (@colixsystems/assets-client)
3839
3904
  *
package/dist/index.d.ts CHANGED
@@ -496,6 +496,14 @@ export interface DatastoreClient {
496
496
  ): Promise<unknown>;
497
497
  revoke(permissionId: string): Promise<void>;
498
498
  };
499
+ /** REQ-RT-08 — backs `useTyping`; optional, the hook degrades without it. */
500
+ typing?(
501
+ recordId: string,
502
+ handlers: {
503
+ onChange?(typingUserIds: string[]): void;
504
+ onStatus?(status: TypingStatus): void;
505
+ },
506
+ ): { setTyping(active: boolean): void; stop(): void };
499
507
  };
500
508
  }
501
509
 
@@ -1410,6 +1418,27 @@ export function useDatastoreSubscription(
1410
1418
  options?: DatastoreSubscriptionOptions,
1411
1419
  ): { status: DatastoreSubscriptionStatus };
1412
1420
 
1421
+ export type TypingStatus = "connecting" | "live" | "unavailable";
1422
+
1423
+ /**
1424
+ * REQ-RT-08: who else is typing in one record, and announcing your own typing,
1425
+ * via `ctx.datastore.records(tableId).typing(recordId, ...)`. Call
1426
+ * `setTyping(true)` on every keystroke (throttled inside; `false` is sent
1427
+ * after 5 s idle) and `setTyping(false)` on send, clear or blur.
1428
+ * `typingUserIds` lists the other users typing; each drops out 6 s after
1429
+ * their last signal. Needs a signed-in user who can read the record.
1430
+ * Re-subscribes when `tableId` or `recordId` changes. Never throws — a host
1431
+ * whose datastore client lacks typing reports `status: "unavailable"`.
1432
+ */
1433
+ export function useTyping(
1434
+ tableId: string | null | undefined,
1435
+ recordId: string | null | undefined,
1436
+ ): {
1437
+ typingUserIds: string[];
1438
+ setTyping(active: boolean): void;
1439
+ status: TypingStatus;
1440
+ };
1441
+
1413
1442
  /**
1414
1443
  * Stateful file-asset resolver hook. Returns `{ url, file, loading, error,
1415
1444
  * refetch }`. The `url` is an absolute URL composed against the host's API
@@ -3380,6 +3409,10 @@ export interface AiWidgetContract {
3380
3409
  readonly bundleExportContract: ReadonlyArray<ContractBundleShape>;
3381
3410
  readonly bannedApis: ReadonlyArray<ContractBannedApi>;
3382
3411
  readonly allowedBareImports: ReadonlyArray<string>;
3412
+ /** Bare specifiers the web host provides by exact name; a widget bundle never inlines them. */
3413
+ readonly hostExternalSpecifiers: ReadonlyArray<string>;
3414
+ /** Bundled vetted package roots: the only roots whose subpaths load in the web Player. */
3415
+ readonly subpathImportRoots: ReadonlyArray<string>;
3383
3416
  /** sc-4686 — the render-time fallback currency when the host supplies none. */
3384
3417
  readonly chargeCurrency: string;
3385
3418
  /** sc-4686 — how each currency is written; read by `formatMoney`, never Intl. */
package/dist/index.js CHANGED
@@ -60,6 +60,7 @@ export {
60
60
  IdentificationError,
61
61
  useRecordPermissions,
62
62
  useDatastoreSubscription,
63
+ useTyping,
63
64
  useWidgetEvent,
64
65
  useWidgetInput,
65
66
  usePayments,
@@ -60,6 +60,7 @@ export {
60
60
  IdentificationError,
61
61
  useRecordPermissions,
62
62
  useDatastoreSubscription,
63
+ useTyping,
63
64
  useWidgetEvent,
64
65
  useWidgetInput,
65
66
  usePayments,