@colixsystems/widget-sdk 0.90.0 → 0.92.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
@@ -35,11 +35,13 @@ The data layer lives in **four separate domain-client packages**, each instantia
35
35
  | **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
36
36
  | **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` — wired by the Player and the Expo export; an authoring preview omits it and the call is a no-op — no scope |
37
37
  | **CORE** | `useGeolocation(options?)` | `{ latitude, longitude, accuracy, loading, error, getCurrentPosition }` | `ctx.device.geolocation` — no scope. Capture is IMPERATIVE: call `getCurrentPosition()` from a user gesture (a tap), never on mount. Resolves to `{ latitude, longitude, accuracy }`; rejects with `GeolocationError` (`.code` in `PERMISSION_DENIED \| UNAVAILABLE \| TIMEOUT \| UNSUPPORTED \| INTERNAL`). Identical on web (`navigator.geolocation`) and the Expo export (`expo-location`). |
38
+ | **CORE** | `useSpeechToText(options?)` | `{ transcript, partial, listening, supported, error, start, stop, abort, reset }` | `ctx.device.speech` — no scope. Dictation with the device's **on-device** recogniser: no audio is uploaded and no AI credit is spent. Capture is IMPERATIVE: call `start()` from a user gesture (a tap), never on mount. `transcript` accumulates finalised speech, `partial` holds the uncommitted guess (needs `options.interimResults`); `stop()` keeps it, `abort()` discards it. Rejects with `SpeechToTextError` (`.code` in `PERMISSION_DENIED \| NO_SPEECH \| LANGUAGE_UNSUPPORTED \| NETWORK \| ABORTED \| UNSUPPORTED \| INTERNAL`). **Gate your mic button on `supported`** — Firefox ships no `SpeechRecognition`. Identical on web (`SpeechRecognition`) and the Expo export (`expo-speech-recognition`). |
38
39
  | **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
39
40
  | **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
40
41
  | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options?)` | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
41
42
  | **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
42
43
  | **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
44
+ | **DATASTORE** | `useInterpretDraft(tableId)` | `{ interpret, interpreting, error, result, available }` | `interpret(tableId, body)` — `datastore.read:<table>`. Turns ONE sentence a user typed ("walk at 11 am tomorrow") into DRAFT column values so a form can prefill itself. IMPERATIVE: call `interpret(text, { fields?, timeZone? })` from an event handler, never on mount. It DRAFTS and writes nothing — show the values for review, then submit through `useDatastoreMutation().create`. Resolves to `{ values, unresolved }`; `values` is keyed by column NAME (the shape `create()` takes) and `unresolved` names the fields the sentence did not state. Only text / number / boolean / date / datetime / array columns are drafted — `FILE`, `RELATION`, `USER` and `USER_GROUP` carry ids and are never guessed. Fails closed to an empty draft. **Every call spends the workspace's AI credits** and is rate-limited per actor, so call it once per user action (never on mount or in a render loop); once the workspace runs out the call is refused with a generic 429 — an app user is deliberately **not** told the workspace's billing state, since they have never heard of an AI credit and cannot buy one. Never surface a raw error to the person filling the form: say drafting is unavailable and keep every field editable by hand. `available` is false where the host brokers no interpreter. |
43
45
  | **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*` |
44
46
  | **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) |
45
47
  | **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
@@ -61,6 +63,30 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
61
63
 
62
64
  ## Status
63
65
 
66
+ `v0.91.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**.
67
+
68
+ ### What's new in 0.92.0 (contract 1.65.0)
69
+
70
+ **New `useInterpretDraft(tableId)` hook — turn one sentence into DRAFT record values.** A new DATASTORE hook reading a new `interpret` method on the existing `ctx.datastore` slice (`@colixsystems/datastore-client` 0.13.0). Returns `{ interpret, interpreting, error, result, available }`. Call `interpret(text, { fields, timeZone })` **imperatively** from an event handler — never on mount or in a render loop — and it resolves to `{ values, unresolved }`, where `values` is keyed by column NAME (the same shape `useDatastoreMutation().create` takes) and `unresolved` names the fields the sentence did not state.
71
+
72
+ **It DRAFTS and writes nothing.** Prefill your inputs from `values`, let the person review and correct them, then submit as usual. A model reading free text must never create a record on its own.
73
+
74
+ Only columns a sentence can honestly produce are drafted — string, text, number, float, boolean, date, datetime and array. `FILE`, `RELATION`, `USER` and `USER_GROUP` are never guessed because they carry identifiers, and encrypted columns are skipped. Every value is coerced against its column's `data_type` and dropped when it does not fit, so a value the model got wrong is reported `unresolved` rather than written through.
75
+
76
+ **Every call spends the workspace's AI credits** and is rate-limited per actor. Once the workspace runs out, the call is refused with a **generic** 429: the person filling the form is never told the workspace's billing state — they have not heard of an AI credit and cannot buy one. Never surface a raw error to them; say drafting is unavailable and keep every field editable by hand. `available` is `false` where the host brokers no interpreter (an unbound preview, or an export with no reachable backend) — hide the affordance rather than rendering a dead button.
77
+
78
+ Additive — one new hook, one new client method, one new context-slice function; no existing export changed signature.
79
+
80
+ ### What's new in 0.91.0 (contract 1.64.0)
81
+
82
+ **New `useSpeechToText()` hook — dictate into text with the device's on-device recogniser.** A new CORE hook reading a new `speech` capability on the existing `ctx.device` slice. Returns `{ transcript, partial, listening, supported, error, start, stop, abort, reset }`. Capture is **imperative** — call `start()` from a user gesture (a `Pressable.onPress`); the browser and the mobile OS gate the microphone prompt on a gesture, so it NEVER listens on mount. `transcript` accumulates finalised speech across utterances; `partial` holds the guess the recogniser has not committed yet (empty unless `options.interimResults`). `stop()` finalises and keeps what was heard, `abort()` discards the current utterance, `reset()` clears both. `options` (`{ lang, continuous, interimResults }`) pass through to the host. Rejections surface as a structured `SpeechToTextError` (new named export) with a stable `.code` (`PERMISSION_DENIED` / `NO_SPEECH` / `LANGUAGE_UNSUPPORTED` / `NETWORK` / `ABORTED` / `UNSUPPORTED` / `INTERNAL`). It needs **no manifest scope** and **no `requestedScopes` entry**.
83
+
84
+ Recognition runs **on device**: no audio is uploaded, nothing reaches our servers, and no AI credit is spent — so the hook is available to every workspace regardless of its AI data-residency policy. The web Player brokers it via the browser's `SpeechRecognition`; the Expo export via `expo-speech-recognition`, whose config plugin declares the microphone and speech permissions the runtime needs. Both hosts emit the same Web Speech error vocabulary, so one mapping serves both.
85
+
86
+ **Always gate your mic affordance on `supported`.** Firefox ships no `SpeechRecognition` at all, so `supported` is `false` there and `start()` rejects with `UNSUPPORTED` — render the plain text field instead of a dead button. The `speech` capability is optional and forwarded independently of `geolocation`, so a host that brokers one still brokers the other.
87
+
88
+ Additive — one new hook, one new optional device capability, one new error class; no existing export changed signature.
89
+
64
90
  `v0.90.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**.
65
91
 
66
92
  ### What's new in 0.89.0 (contract unchanged)
package/dist/contract.cjs CHANGED
@@ -820,6 +820,42 @@ const HOOKS = [
820
820
  requiredContextSlice: ["datastore.schema"],
821
821
  scopes: ["datastore.read:<table>"],
822
822
  },
823
+ {
824
+ name: "useInterpretDraft",
825
+ signature: "useInterpretDraft(tableId)",
826
+ description:
827
+ "sc-4932 — turns ONE sentence a user typed into DRAFT values for the " +
828
+ "table's columns (\"walk at 11 am tomorrow\" -> { title: 'Walk', " +
829
+ "due_at: '...T11:00' }). IMPERATIVE: call interpret(text, { fields, " +
830
+ "timeZone }) from an event handler (a button press), never on mount. It " +
831
+ "DRAFTS and writes NOTHING — prefill your form from `values`, let the " +
832
+ "user review and correct it, then submit through " +
833
+ "useDatastoreMutation().create as usual. `values` is keyed by column " +
834
+ "NAME, the same shape create() takes. `unresolved` lists the fields the " +
835
+ "sentence did not state — leave those blank rather than guessing. Pass " +
836
+ "`fields` to narrow the draft to the columns you actually render, each " +
837
+ "optionally carrying a dropdown's closed `options` list; pass `timeZone` " +
838
+ "(an IANA zone) so relative times resolve correctly. Only text, number, " +
839
+ "boolean, date/datetime and array columns are drafted — FILE, RELATION, " +
840
+ "USER and USER_GROUP columns are never guessed because they carry ids. " +
841
+ "Every call spends the workspace's AI CREDITS and is rate-limited per " +
842
+ "actor, so call it once per user action — never on mount or in a render " +
843
+ "loop. Once the workspace runs out of credits the call is refused with a " +
844
+ "generic 429 (an app user is never told the workspace's billing state — " +
845
+ "they have not heard of an AI credit and cannot buy one). NEVER surface a " +
846
+ "raw error to the person filling the form: say drafting is unavailable and " +
847
+ "keep every field editable by hand. Reads ctx.datastore.interpret.",
848
+ returnShape: {
849
+ interpret:
850
+ "(text, { fields?, timeZone? }) => Promise<{ values, unresolved }>",
851
+ interpreting: "boolean",
852
+ error: "DatastoreError | null",
853
+ result: "{ values, unresolved } | null",
854
+ available: "boolean // false when the host brokers no interpreter",
855
+ },
856
+ requiredContextSlice: ["datastore.interpret"],
857
+ scopes: ["datastore.read:<table>"],
858
+ },
823
859
  {
824
860
  name: "useDatastoreMutation",
825
861
  signature: "useDatastoreMutation(tableId)",
@@ -1194,6 +1230,35 @@ const HOOKS = [
1194
1230
  requiredContextSlice: [],
1195
1231
  scopes: null,
1196
1232
  },
1233
+ // Host-brokered on-device speech recognition. Optional slice; the hook
1234
+ // reports supported:false rather than throwing at render.
1235
+ {
1236
+ name: "useSpeechToText",
1237
+ signature: "useSpeechToText(options?)",
1238
+ description:
1239
+ "Dictate into text with the device's ON-DEVICE speech recogniser. Returns { transcript, partial, listening, supported, " +
1240
+ "error, start, stop, abort, reset }. Capture is IMPERATIVE — call start() from a user gesture (a tap on a mic button); " +
1241
+ "the browser and the mobile OS gate the microphone prompt on a gesture, so it NEVER listens on mount. `transcript` " +
1242
+ "accumulates finalised speech; `partial` holds the uncommitted guess (empty unless options.interimResults). stop() " +
1243
+ "finalises and keeps what was heard, abort() discards it, reset() clears both. Recognition runs ON DEVICE — no audio is " +
1244
+ "uploaded and no AI credit is spent. start() rejects with a SpeechToTextError whose .code is one of PERMISSION_DENIED | " +
1245
+ "NO_SPEECH | LANGUAGE_UNSUPPORTED | NETWORK | ABORTED | UNSUPPORTED | INTERNAL. options: { lang, continuous, " +
1246
+ "interimResults }. Check `supported` before rendering a mic button — a browser without SpeechRecognition (Firefox) " +
1247
+ "reports false. Identical on web (SpeechRecognition) and the Expo export (expo-speech-recognition).",
1248
+ returnShape: {
1249
+ transcript: "string // finalised speech, accumulated",
1250
+ partial: "string // uncommitted guess; '' unless interimResults",
1251
+ listening: "boolean",
1252
+ supported: "boolean // false when the host brokers no recogniser",
1253
+ error: "SpeechToTextError | null",
1254
+ start: "() => Promise<void> // rejects with SpeechToTextError",
1255
+ stop: "() => Promise<void> // finalise, keep the transcript",
1256
+ abort: "() => void // cancel, discard the utterance",
1257
+ reset: "() => void // clear transcript + partial + error",
1258
+ },
1259
+ requiredContextSlice: [],
1260
+ scopes: null,
1261
+ },
1197
1262
  ];
1198
1263
 
1199
1264
  // REQ-WSDK-RN-WEB: the SDK exposes the React Native primitive API
@@ -1608,13 +1673,13 @@ const WIDGET_CONTEXT_SHAPE = {
1608
1673
  datastore: {
1609
1674
  description:
1610
1675
  "Injected @colixsystems/datastore-client instance. " +
1611
- "{ tables: { list(), get(idOrName) }, schema(tableId) -> Promise<{ id, name, columns: [...] }>, " +
1676
+ "{ tables: { list(), get(idOrName), interpret(idOrName, body) }, schema(tableId) -> Promise<{ id, name, columns: [...] }>, " +
1612
1677
  "records(tableId) -> { list(query) -> Promise<{ data, meta }>, get(id), create(values), update(id, values), delete(id), aggregate(spec), " +
1613
1678
  "permissions(recordId) -> { list() -> Promise<{ data, meta }>, grant(body), update(permId, patch), revoke(permId) } } }. " +
1614
- "`records` backs the query/record/mutation hooks; `records(t).permissions(r)` backs useRecordPermissions(); `schema` backs useDatastoreSchema(). " +
1679
+ "`records` backs the query/record/mutation hooks; `records(t).permissions(r)` backs useRecordPermissions(); `schema` backs useDatastoreSchema(); `interpret` backs useInterpretDraft() (sc-4932 — drafts column values from one sentence; writes nothing). " +
1615
1680
  "List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case (author column values keep their author-given names).",
1616
1681
  required: true,
1617
- fields: { records: "function", schema: "function", tables: "object" },
1682
+ fields: { records: "function", schema: "function", tables: "object", interpret: "function" },
1618
1683
  },
1619
1684
  directory: {
1620
1685
  description:
@@ -1769,11 +1834,15 @@ const WIDGET_CONTEXT_SHAPE = {
1769
1834
  device: {
1770
1835
  description:
1771
1836
  "Optional host-brokered device capabilities. " +
1772
- "{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }> } }. " +
1773
- "Backs useGeolocation(). The web Player brokers it via navigator.geolocation; the Expo export via expo-location. " +
1774
- "getCurrentPosition rejects with a GeolocationError (.code PERMISSION_DENIED | UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL).",
1837
+ "{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }> }, " +
1838
+ "speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> } }. " +
1839
+ "Backs useGeolocation() and useSpeechToText(). The web Player brokers them via navigator.geolocation and " +
1840
+ "window.SpeechRecognition; the Expo export via expo-location and expo-speech-recognition. " +
1841
+ "getCurrentPosition rejects with a GeolocationError (.code PERMISSION_DENIED | UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL). " +
1842
+ "speech.start streams { transcript, isFinal } to onResult and runs ON DEVICE — it uploads no audio and spends no AI credit; " +
1843
+ "its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted).",
1775
1844
  required: false,
1776
- fields: { geolocation: "object" },
1845
+ fields: { geolocation: "object", speech: "object" },
1777
1846
  },
1778
1847
  };
1779
1848
 
@@ -2822,7 +2891,7 @@ const CONTRACT = deepFreeze({
2822
2891
  // published widget keeps validating. The script's `triggerType` global
2823
2892
  // now names the trigger that actually FIRED the run ('manual' and 'app'
2824
2893
  // included), which is what makes a multi-trigger script able to branch.
2825
- version: "1.63.0",
2894
+ version: "1.65.0",
2826
2895
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2827
2896
  hooks: HOOKS,
2828
2897
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -820,6 +820,42 @@ const HOOKS = [
820
820
  requiredContextSlice: ["datastore.schema"],
821
821
  scopes: ["datastore.read:<table>"],
822
822
  },
823
+ {
824
+ name: "useInterpretDraft",
825
+ signature: "useInterpretDraft(tableId)",
826
+ description:
827
+ "sc-4932 — turns ONE sentence a user typed into DRAFT values for the " +
828
+ "table's columns (\"walk at 11 am tomorrow\" -> { title: 'Walk', " +
829
+ "due_at: '...T11:00' }). IMPERATIVE: call interpret(text, { fields, " +
830
+ "timeZone }) from an event handler (a button press), never on mount. It " +
831
+ "DRAFTS and writes NOTHING — prefill your form from `values`, let the " +
832
+ "user review and correct it, then submit through " +
833
+ "useDatastoreMutation().create as usual. `values` is keyed by column " +
834
+ "NAME, the same shape create() takes. `unresolved` lists the fields the " +
835
+ "sentence did not state — leave those blank rather than guessing. Pass " +
836
+ "`fields` to narrow the draft to the columns you actually render, each " +
837
+ "optionally carrying a dropdown's closed `options` list; pass `timeZone` " +
838
+ "(an IANA zone) so relative times resolve correctly. Only text, number, " +
839
+ "boolean, date/datetime and array columns are drafted — FILE, RELATION, " +
840
+ "USER and USER_GROUP columns are never guessed because they carry ids. " +
841
+ "Every call spends the workspace's AI CREDITS and is rate-limited per " +
842
+ "actor, so call it once per user action — never on mount or in a render " +
843
+ "loop. Once the workspace runs out of credits the call is refused with a " +
844
+ "generic 429 (an app user is never told the workspace's billing state — " +
845
+ "they have not heard of an AI credit and cannot buy one). NEVER surface a " +
846
+ "raw error to the person filling the form: say drafting is unavailable and " +
847
+ "keep every field editable by hand. Reads ctx.datastore.interpret.",
848
+ returnShape: {
849
+ interpret:
850
+ "(text, { fields?, timeZone? }) => Promise<{ values, unresolved }>",
851
+ interpreting: "boolean",
852
+ error: "DatastoreError | null",
853
+ result: "{ values, unresolved } | null",
854
+ available: "boolean // false when the host brokers no interpreter",
855
+ },
856
+ requiredContextSlice: ["datastore.interpret"],
857
+ scopes: ["datastore.read:<table>"],
858
+ },
823
859
  {
824
860
  name: "useDatastoreMutation",
825
861
  signature: "useDatastoreMutation(tableId)",
@@ -1194,6 +1230,35 @@ const HOOKS = [
1194
1230
  requiredContextSlice: [],
1195
1231
  scopes: null,
1196
1232
  },
1233
+ // Host-brokered on-device speech recognition. Optional slice; the hook
1234
+ // reports supported:false rather than throwing at render.
1235
+ {
1236
+ name: "useSpeechToText",
1237
+ signature: "useSpeechToText(options?)",
1238
+ description:
1239
+ "Dictate into text with the device's ON-DEVICE speech recogniser. Returns { transcript, partial, listening, supported, " +
1240
+ "error, start, stop, abort, reset }. Capture is IMPERATIVE — call start() from a user gesture (a tap on a mic button); " +
1241
+ "the browser and the mobile OS gate the microphone prompt on a gesture, so it NEVER listens on mount. `transcript` " +
1242
+ "accumulates finalised speech; `partial` holds the uncommitted guess (empty unless options.interimResults). stop() " +
1243
+ "finalises and keeps what was heard, abort() discards it, reset() clears both. Recognition runs ON DEVICE — no audio is " +
1244
+ "uploaded and no AI credit is spent. start() rejects with a SpeechToTextError whose .code is one of PERMISSION_DENIED | " +
1245
+ "NO_SPEECH | LANGUAGE_UNSUPPORTED | NETWORK | ABORTED | UNSUPPORTED | INTERNAL. options: { lang, continuous, " +
1246
+ "interimResults }. Check `supported` before rendering a mic button — a browser without SpeechRecognition (Firefox) " +
1247
+ "reports false. Identical on web (SpeechRecognition) and the Expo export (expo-speech-recognition).",
1248
+ returnShape: {
1249
+ transcript: "string // finalised speech, accumulated",
1250
+ partial: "string // uncommitted guess; '' unless interimResults",
1251
+ listening: "boolean",
1252
+ supported: "boolean // false when the host brokers no recogniser",
1253
+ error: "SpeechToTextError | null",
1254
+ start: "() => Promise<void> // rejects with SpeechToTextError",
1255
+ stop: "() => Promise<void> // finalise, keep the transcript",
1256
+ abort: "() => void // cancel, discard the utterance",
1257
+ reset: "() => void // clear transcript + partial + error",
1258
+ },
1259
+ requiredContextSlice: [],
1260
+ scopes: null,
1261
+ },
1197
1262
  ];
1198
1263
 
1199
1264
  // REQ-WSDK-RN-WEB: the SDK exposes the React Native primitive API
@@ -1608,13 +1673,13 @@ const WIDGET_CONTEXT_SHAPE = {
1608
1673
  datastore: {
1609
1674
  description:
1610
1675
  "Injected @colixsystems/datastore-client instance. " +
1611
- "{ tables: { list(), get(idOrName) }, schema(tableId) -> Promise<{ id, name, columns: [...] }>, " +
1676
+ "{ tables: { list(), get(idOrName), interpret(idOrName, body) }, schema(tableId) -> Promise<{ id, name, columns: [...] }>, " +
1612
1677
  "records(tableId) -> { list(query) -> Promise<{ data, meta }>, get(id), create(values), update(id, values), delete(id), aggregate(spec), " +
1613
1678
  "permissions(recordId) -> { list() -> Promise<{ data, meta }>, grant(body), update(permId, patch), revoke(permId) } } }. " +
1614
- "`records` backs the query/record/mutation hooks; `records(t).permissions(r)` backs useRecordPermissions(); `schema` backs useDatastoreSchema(). " +
1679
+ "`records` backs the query/record/mutation hooks; `records(t).permissions(r)` backs useRecordPermissions(); `schema` backs useDatastoreSchema(); `interpret` backs useInterpretDraft() (sc-4932 — drafts column values from one sentence; writes nothing). " +
1615
1680
  "List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case (author column values keep their author-given names).",
1616
1681
  required: true,
1617
- fields: { records: "function", schema: "function", tables: "object" },
1682
+ fields: { records: "function", schema: "function", tables: "object", interpret: "function" },
1618
1683
  },
1619
1684
  directory: {
1620
1685
  description:
@@ -1769,11 +1834,15 @@ const WIDGET_CONTEXT_SHAPE = {
1769
1834
  device: {
1770
1835
  description:
1771
1836
  "Optional host-brokered device capabilities. " +
1772
- "{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }> } }. " +
1773
- "Backs useGeolocation(). The web Player brokers it via navigator.geolocation; the Expo export via expo-location. " +
1774
- "getCurrentPosition rejects with a GeolocationError (.code PERMISSION_DENIED | UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL).",
1837
+ "{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }> }, " +
1838
+ "speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> } }. " +
1839
+ "Backs useGeolocation() and useSpeechToText(). The web Player brokers them via navigator.geolocation and " +
1840
+ "window.SpeechRecognition; the Expo export via expo-location and expo-speech-recognition. " +
1841
+ "getCurrentPosition rejects with a GeolocationError (.code PERMISSION_DENIED | UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL). " +
1842
+ "speech.start streams { transcript, isFinal } to onResult and runs ON DEVICE — it uploads no audio and spends no AI credit; " +
1843
+ "its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted).",
1775
1844
  required: false,
1776
- fields: { geolocation: "object" },
1845
+ fields: { geolocation: "object", speech: "object" },
1777
1846
  },
1778
1847
  };
1779
1848
 
@@ -2822,7 +2891,7 @@ const CONTRACT = deepFreeze({
2822
2891
  // published widget keeps validating. The script's `triggerType` global
2823
2892
  // now names the trigger that actually FIRED the run ('manual' and 'app'
2824
2893
  // included), which is what makes a multi-trigger script able to branch.
2825
- version: "1.63.0",
2894
+ version: "1.65.0",
2826
2895
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2827
2896
  hooks: HOOKS,
2828
2897
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -916,6 +916,234 @@ export function useGeolocation(options) {
916
916
  };
917
917
  }
918
918
 
919
+ /**
920
+ * Error surfaced by `useSpeechToText()` (thrown by `start()` and stored in the
921
+ * hook's `error` slot). Carries a stable `code` so widgets branch on the error
922
+ * class without parsing message strings.
923
+ *
924
+ * `code` is one of:
925
+ * - "PERMISSION_DENIED" — the user or OS refused microphone access.
926
+ * - "NO_SPEECH" — capture ended without any speech detected.
927
+ * - "LANGUAGE_UNSUPPORTED" — no recogniser exists for the requested language.
928
+ * - "NETWORK" — the recogniser needed the network and failed.
929
+ * - "ABORTED" — capture was cancelled (abort(), or a new start()).
930
+ * - "UNSUPPORTED" — this host does not broker speech recognition.
931
+ * - "INTERNAL" — anything else.
932
+ */
933
+ export class SpeechToTextError extends Error {
934
+ constructor(code, message, opts) {
935
+ super(message);
936
+ this.name = "SpeechToTextError";
937
+ this.code = code;
938
+ if (opts && opts.cause) this.cause = opts.cause;
939
+ }
940
+ }
941
+
942
+ // Both hosts emit the Web Speech API's error vocabulary (the Expo export's
943
+ // recogniser mirrors it deliberately), so one map serves web and native.
944
+ const SPEECH_ERROR_CODES = {
945
+ "not-allowed": "PERMISSION_DENIED",
946
+ "service-not-allowed": "PERMISSION_DENIED",
947
+ PERMISSION_DENIED: "PERMISSION_DENIED",
948
+ "no-speech": "NO_SPEECH",
949
+ NO_SPEECH: "NO_SPEECH",
950
+ // Android's recogniser reports a silence timeout instead of "no-speech".
951
+ "speech-timeout": "NO_SPEECH",
952
+ "language-not-supported": "LANGUAGE_UNSUPPORTED",
953
+ LANGUAGE_UNSUPPORTED: "LANGUAGE_UNSUPPORTED",
954
+ network: "NETWORK",
955
+ NETWORK: "NETWORK",
956
+ aborted: "ABORTED",
957
+ ABORTED: "ABORTED",
958
+ UNSUPPORTED: "UNSUPPORTED",
959
+ };
960
+
961
+ /** Coerce a thrown/emitted value into a SpeechToTextError with a stable code. */
962
+ function toSpeechToTextError(err) {
963
+ if (err instanceof SpeechToTextError) return err;
964
+ const raw =
965
+ err && err.code !== undefined && err.code !== null ? err.code : null;
966
+ const code = (raw !== null && SPEECH_ERROR_CODES[raw]) || "INTERNAL";
967
+ const message =
968
+ (err && typeof err.message === "string" && err.message) ||
969
+ "Speech recognition failed";
970
+ return new SpeechToTextError(code, message, { cause: err });
971
+ }
972
+
973
+ /**
974
+ * Dictate into text with the device's on-device speech recogniser. Returns
975
+ * `{ transcript, partial, listening, supported, error, start, stop, abort,
976
+ * reset }`.
977
+ *
978
+ * Capture is IMPERATIVE — call `start()` from a user gesture (a tap on a mic
979
+ * button). Both the browser and the mobile OS gate the microphone prompt on a
980
+ * user gesture, so the hook never listens on mount.
981
+ *
982
+ * `transcript` accumulates finalised speech; `partial` holds the in-flight
983
+ * guess the recogniser has not committed yet (empty unless
984
+ * `options.interimResults`). `stop()` finalises and keeps what was heard;
985
+ * `abort()` discards it. `reset()` clears both back to empty.
986
+ *
987
+ * Recognition runs ON DEVICE — no audio is uploaded and no AI credit is spent.
988
+ * The SAME hook drives both platforms: the web Player brokers it through the
989
+ * browser's SpeechRecognition, the Expo export through
990
+ * expo-speech-recognition.
991
+ *
992
+ * Safe-by-default: on a host that does not inject `ctx.device.speech` (or a
993
+ * browser without SpeechRecognition), `supported` is false and `start()`
994
+ * rejects with `code: "UNSUPPORTED"` rather than throwing at render, so a
995
+ * widget can call the hook unconditionally and hide its mic button.
996
+ */
997
+ export function useSpeechToText(options) {
998
+ const ctx = useWidgetContextOrThrow("useSpeechToText");
999
+ const [transcript, setTranscript] = useState("");
1000
+ const [partial, setPartial] = useState("");
1001
+ const [listening, setListening] = useState(false);
1002
+ const [error, setError] = useState(null);
1003
+
1004
+ // `ctx` is a fresh identity every host render — hold the live client and
1005
+ // options in refs so the returned callbacks stay stable.
1006
+ const clientRef = useRef(ctx.device && ctx.device.speech);
1007
+ clientRef.current = ctx.device && ctx.device.speech;
1008
+ const optionsRef = useRef(options);
1009
+ optionsRef.current = options;
1010
+ // The live capture session, so stop/abort/unmount can reach it.
1011
+ const sessionRef = useRef(null);
1012
+ const runRef = useRef(0);
1013
+
1014
+ const supported = Boolean(
1015
+ clientRef.current &&
1016
+ typeof clientRef.current.start === "function" &&
1017
+ (typeof clientRef.current.isSupported !== "function" ||
1018
+ clientRef.current.isSupported()),
1019
+ );
1020
+
1021
+ // Abandoning a mounted recogniser leaves the mic hot on both hosts, so
1022
+ // release it when the widget unmounts.
1023
+ useEffect(
1024
+ () => () => {
1025
+ runRef.current += 1;
1026
+ const session = sessionRef.current;
1027
+ sessionRef.current = null;
1028
+ if (session && typeof session.abort === "function") {
1029
+ try {
1030
+ session.abort();
1031
+ } catch {
1032
+ /* the host is already tearing the session down */
1033
+ }
1034
+ }
1035
+ },
1036
+ [],
1037
+ );
1038
+
1039
+ const reset = useCallback(() => {
1040
+ setTranscript("");
1041
+ setPartial("");
1042
+ setError(null);
1043
+ }, []);
1044
+
1045
+ const stop = useCallback(async () => {
1046
+ const session = sessionRef.current;
1047
+ sessionRef.current = null;
1048
+ setListening(false);
1049
+ setPartial("");
1050
+ if (session && typeof session.stop === "function") await session.stop();
1051
+ }, []);
1052
+
1053
+ const abort = useCallback(() => {
1054
+ runRef.current += 1;
1055
+ const session = sessionRef.current;
1056
+ sessionRef.current = null;
1057
+ setListening(false);
1058
+ setPartial("");
1059
+ if (session && typeof session.abort === "function") session.abort();
1060
+ }, []);
1061
+
1062
+ const start = useCallback(async () => {
1063
+ const client = clientRef.current;
1064
+ if (
1065
+ !client ||
1066
+ typeof client.start !== "function" ||
1067
+ (typeof client.isSupported === "function" && !client.isSupported())
1068
+ ) {
1069
+ const e = new SpeechToTextError(
1070
+ "UNSUPPORTED",
1071
+ "This host does not provide speech recognition.",
1072
+ );
1073
+ setError(e);
1074
+ setListening(false);
1075
+ throw e;
1076
+ }
1077
+ // A second start() supersedes the first: drop the old session's events.
1078
+ const myRun = ++runRef.current;
1079
+ const previous = sessionRef.current;
1080
+ sessionRef.current = null;
1081
+ if (previous && typeof previous.abort === "function") previous.abort();
1082
+
1083
+ setError(null);
1084
+ setPartial("");
1085
+ setListening(true);
1086
+ try {
1087
+ const session = await client.start(optionsRef.current || {}, {
1088
+ onResult: (result) => {
1089
+ if (runRef.current !== myRun) return;
1090
+ const text =
1091
+ result && typeof result.transcript === "string"
1092
+ ? result.transcript
1093
+ : "";
1094
+ if (result && result.isFinal) {
1095
+ setPartial("");
1096
+ // Append rather than replace: a continuous session emits one final
1097
+ // result per utterance, not a growing whole.
1098
+ setTranscript((prev) => (prev ? `${prev} ${text}`.trim() : text));
1099
+ } else {
1100
+ setPartial(text);
1101
+ }
1102
+ },
1103
+ onError: (err) => {
1104
+ if (runRef.current !== myRun) return;
1105
+ sessionRef.current = null;
1106
+ setError(toSpeechToTextError(err));
1107
+ setPartial("");
1108
+ setListening(false);
1109
+ },
1110
+ onEnd: () => {
1111
+ if (runRef.current !== myRun) return;
1112
+ sessionRef.current = null;
1113
+ setPartial("");
1114
+ setListening(false);
1115
+ },
1116
+ });
1117
+ if (runRef.current !== myRun) {
1118
+ // Superseded while the host was still opening the mic.
1119
+ if (session && typeof session.abort === "function") session.abort();
1120
+ return;
1121
+ }
1122
+ sessionRef.current = session || null;
1123
+ } catch (err) {
1124
+ const se = toSpeechToTextError(err);
1125
+ if (runRef.current === myRun) {
1126
+ sessionRef.current = null;
1127
+ setError(se);
1128
+ setListening(false);
1129
+ }
1130
+ throw se;
1131
+ }
1132
+ }, []);
1133
+
1134
+ return {
1135
+ transcript,
1136
+ partial,
1137
+ listening,
1138
+ supported,
1139
+ error,
1140
+ start,
1141
+ stop,
1142
+ abort,
1143
+ reset,
1144
+ };
1145
+ }
1146
+
919
1147
  /* ============================================================================
920
1148
  * DATASTORE CLIENT — ctx.datastore (@colixsystems/datastore-client)
921
1149
  *
@@ -1383,6 +1611,83 @@ export function useDatastoreSchema(tableId) {
1383
1611
  return { schema, loading, error, refetch };
1384
1612
  }
1385
1613
 
1614
+ /**
1615
+ * sc-4932 — draft record values for `tableId` from one sentence the user typed.
1616
+ * Returns `{ interpret, interpreting, error, result, available }`.
1617
+ *
1618
+ * interpret(text, { fields?, timeZone? }) → Promise<{ values, unresolved }>
1619
+ * `values` is keyed by column NAME — the same shape
1620
+ * `useDatastoreMutation().create` takes — so a caller prefills its form
1621
+ * from it and submits through the ordinary create path. `unresolved` names
1622
+ * the fields the sentence did not state; they are left for the user.
1623
+ *
1624
+ * The hook is IMPERATIVE — it NEVER fires on mount; the widget calls
1625
+ * `interpret` from an event handler. `interpreting` tracks an in-flight call
1626
+ * and `error` holds the last DatastoreError (cleared at the start of each
1627
+ * call). `result` holds the last successful draft.
1628
+ *
1629
+ * This DRAFTS, it does not write: the user reviews every value before the
1630
+ * record is created. `fields` narrows the draft to the columns the caller
1631
+ * actually renders, each optionally carrying the closed `options` list of a
1632
+ * dropdown. `timeZone` is an IANA zone; the server resolves "at 11 am" against
1633
+ * it and falls back to UTC when it is absent or unknown.
1634
+ *
1635
+ * Routes through the injected `@colixsystems/datastore-client` at
1636
+ * `ctx.datastore.interpret`, so the Player and the Expo export resolve against
1637
+ * the identical client. A host that brokers no interpreter (an unbound canvas
1638
+ * preview) reports `available: false` rather than throwing, matching how
1639
+ * useTranslate degrades.
1640
+ */
1641
+ export function useInterpretDraft(tableId) {
1642
+ const ctx = useWidgetContextOrThrow("useInterpretDraft");
1643
+ const available =
1644
+ Boolean(ctx.datastore) && typeof ctx.datastore.interpret === "function";
1645
+
1646
+ const fnRef = useRef(available ? ctx.datastore.interpret : null);
1647
+ fnRef.current = available ? ctx.datastore.interpret : null;
1648
+ const tableIdRef = useRef(tableId);
1649
+ tableIdRef.current = tableId;
1650
+
1651
+ const [interpreting, setInterpreting] = useState(false);
1652
+ const [error, setError] = useState(null);
1653
+ const [result, setResult] = useState(null);
1654
+
1655
+ const interpret = useCallback(async (text, options) => {
1656
+ const table = tableIdRef.current;
1657
+ if (!fnRef.current || !table) {
1658
+ throw new DatastoreError(
1659
+ "UNAVAILABLE",
1660
+ "No interpreter available for this host",
1661
+ );
1662
+ }
1663
+ setInterpreting(true);
1664
+ setError(null);
1665
+ try {
1666
+ // snake_case verbatim on the wire (REQ-GEN-09) — the SDK does not
1667
+ // transform, so the camelCase `timeZone` argument is mapped here, once.
1668
+ const body = { text };
1669
+ if (options && Array.isArray(options.fields)) body.fields = options.fields;
1670
+ if (options && options.timeZone) body.time_zone = options.timeZone;
1671
+ const draft = await fnRef.current(table, body);
1672
+ const safe = {
1673
+ values: draft && draft.values ? draft.values : {},
1674
+ unresolved:
1675
+ draft && Array.isArray(draft.unresolved) ? draft.unresolved : [],
1676
+ };
1677
+ setResult(safe);
1678
+ setInterpreting(false);
1679
+ return safe;
1680
+ } catch (err) {
1681
+ const e = toDatastoreError(err);
1682
+ setError(e);
1683
+ setInterpreting(false);
1684
+ throw e;
1685
+ }
1686
+ }, []);
1687
+
1688
+ return { interpret, interpreting, error, result, available };
1689
+ }
1690
+
1386
1691
  /**
1387
1692
  * Datastore mutation hook. Returns { create, update, delete }, each method
1388
1693
  * returning a Promise. Routes through the injected
package/dist/index.d.ts CHANGED
@@ -590,8 +590,22 @@ export interface WidgetContext<TProps = unknown> {
590
590
  toast?: {
591
591
  showToast(args: { kind?: string; message: string }): void;
592
592
  };
593
- /** Optional host-brokered device capabilities; backs useGeolocation. */
593
+ /**
594
+ * Optional host-brokered device capabilities; backs useGeolocation and
595
+ * useSpeechToText.
596
+ */
594
597
  device?: {
598
+ speech?: {
599
+ isSupported?(): boolean;
600
+ start(
601
+ options: SpeechToTextOptions,
602
+ handlers: {
603
+ onResult(result: { transcript: string; isFinal: boolean }): void;
604
+ onError(error: unknown): void;
605
+ onEnd(): void;
606
+ },
607
+ ): Promise<{ stop(): void | Promise<void>; abort(): void }>;
608
+ };
595
609
  geolocation?: {
596
610
  getCurrentPosition(options?: GeolocationOptions): Promise<{
597
611
  latitude: number;
@@ -949,6 +963,50 @@ export function useDatastoreSchema(
949
963
  tableId: string | null | undefined,
950
964
  ): SchemaResult;
951
965
 
966
+ /** sc-4932 — one field a draft may target, with a dropdown's closed option set. */
967
+ export interface InterpretDraftField {
968
+ column: string;
969
+ options?: Array<string | number>;
970
+ }
971
+
972
+ /** sc-4932 — options for `useInterpretDraft().interpret`. */
973
+ export interface InterpretDraftOptions {
974
+ fields?: Array<InterpretDraftField | string>;
975
+ /** IANA zone the server resolves relative times against. Defaults to UTC. */
976
+ timeZone?: string;
977
+ }
978
+
979
+ /**
980
+ * sc-4932 — a drafted record. `values` is keyed by column NAME (the shape
981
+ * `useDatastoreMutation().create` takes); `unresolved` names the fields the
982
+ * sentence did not state.
983
+ */
984
+ export interface InterpretDraftResult {
985
+ values: Record<string, unknown>;
986
+ unresolved: string[];
987
+ }
988
+
989
+ export interface InterpretDraftApi {
990
+ interpret(
991
+ text: string,
992
+ options?: InterpretDraftOptions,
993
+ ): Promise<InterpretDraftResult>;
994
+ interpreting: boolean;
995
+ error: DatastoreError | null;
996
+ result: InterpretDraftResult | null;
997
+ /** False when the host brokers no interpreter (e.g. an unbound preview). */
998
+ available: boolean;
999
+ }
1000
+
1001
+ /**
1002
+ * sc-4932 — draft record values for `tableId` from one sentence the user typed
1003
+ * ("walk at 11 am tomorrow"). IMPERATIVE: never fires on mount, and DRAFTS
1004
+ * only — the user reviews the values before the record is created.
1005
+ */
1006
+ export function useInterpretDraft(
1007
+ tableId: string | null | undefined,
1008
+ ): InterpretDraftApi;
1009
+
952
1010
  // REQ-RT-07 realtime subscription transport state.
953
1011
  export type DatastoreSubscriptionStatus =
954
1012
  | "connecting"
@@ -1267,6 +1325,68 @@ export class GeolocationError extends Error {
1267
1325
  );
1268
1326
  }
1269
1327
 
1328
+ /** Pass-through options for `useSpeechToText(...)`. */
1329
+ export interface SpeechToTextOptions {
1330
+ /** BCP-47 tag, e.g. "sv-SE". Defaults to the host's UI language. */
1331
+ lang?: string;
1332
+ /** Keep listening across pauses instead of stopping at the first result. */
1333
+ continuous?: boolean;
1334
+ /** Emit uncommitted guesses to `partial` while the user is still speaking. */
1335
+ interimResults?: boolean;
1336
+ }
1337
+
1338
+ export interface SpeechToTextResult {
1339
+ /** Finalised speech, accumulated across utterances in this session. */
1340
+ transcript: string;
1341
+ /** The uncommitted guess; `""` unless `interimResults` was requested. */
1342
+ partial: string;
1343
+ listening: boolean;
1344
+ /** False when the host brokers no recogniser (e.g. Firefox). */
1345
+ supported: boolean;
1346
+ error: SpeechToTextError | null;
1347
+ /** Begin listening — call from a user gesture. Rejects with SpeechToTextError. */
1348
+ start(): Promise<void>;
1349
+ /** Stop listening and keep what was heard. */
1350
+ stop(): Promise<void>;
1351
+ /** Cancel listening and discard the current utterance. */
1352
+ abort(): void;
1353
+ /** Clear `transcript`, `partial`, and `error`. */
1354
+ reset(): void;
1355
+ }
1356
+
1357
+ /**
1358
+ * Dictate into text with the device's ON-DEVICE speech recogniser. Capture is
1359
+ * imperative (call `start()` from a user gesture; it never listens on mount).
1360
+ * The same hook drives both platforms — the web Player brokers it via
1361
+ * `window.SpeechRecognition`, the Expo export via `expo-speech-recognition`.
1362
+ * No audio is uploaded and no AI credit is spent. Safe to call on a host that
1363
+ * brokers no recogniser: `supported` is then false and `start()` rejects with
1364
+ * `code: "UNSUPPORTED"`, so gate the mic button on `supported`.
1365
+ */
1366
+ export function useSpeechToText(
1367
+ options?: SpeechToTextOptions,
1368
+ ): SpeechToTextResult;
1369
+
1370
+ /**
1371
+ * Error surfaced by `useSpeechToText()` — thrown by `start()` and stored in the
1372
+ * hook's `error` slot. `code` is a stable categorisation.
1373
+ */
1374
+ export class SpeechToTextError extends Error {
1375
+ code:
1376
+ | "PERMISSION_DENIED"
1377
+ | "NO_SPEECH"
1378
+ | "LANGUAGE_UNSUPPORTED"
1379
+ | "NETWORK"
1380
+ | "ABORTED"
1381
+ | "UNSUPPORTED"
1382
+ | "INTERNAL";
1383
+ constructor(
1384
+ code: SpeechToTextError["code"],
1385
+ message: string,
1386
+ opts?: { cause?: unknown },
1387
+ );
1388
+ }
1389
+
1270
1390
  /**
1271
1391
  * Error class thrown by useDatastoreMutation callbacks (and surfaced by
1272
1392
  * useDatastoreQuery in its `error` slot). The `code` is a stable
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export {
15
15
  useDatastoreQuery,
16
16
  useDatastoreRecord,
17
17
  useDatastoreSchema,
18
+ useInterpretDraft,
18
19
  useAsset,
19
20
  useAssetsByTag,
20
21
  useFilestoreFiles,
@@ -56,6 +57,8 @@ export {
56
57
  useContainerWidth,
57
58
  useGeolocation,
58
59
  GeolocationError,
60
+ useSpeechToText,
61
+ SpeechToTextError,
59
62
  WidgetTree,
60
63
  } from "./hooks.js";
61
64
  export { isNarrowWidth, NARROW_WIDTH_PX } from "./container-width.js";
@@ -15,6 +15,7 @@ export {
15
15
  useDatastoreQuery,
16
16
  useDatastoreRecord,
17
17
  useDatastoreSchema,
18
+ useInterpretDraft,
18
19
  useAsset,
19
20
  useAssetsByTag,
20
21
  useFilestoreFiles,
@@ -56,6 +57,8 @@ export {
56
57
  useContainerWidth,
57
58
  useGeolocation,
58
59
  GeolocationError,
60
+ useSpeechToText,
61
+ SpeechToTextError,
59
62
  WidgetTree,
60
63
  } from "./hooks.js";
61
64
  export { isNarrowWidth, NARROW_WIDTH_PX } from "./container-width.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.90.0",
3
+ "version": "0.92.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__/hooks-users.test.js src/__tests__/hooks-groups.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-image-height.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__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js"
51
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.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-image-height.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__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"