@colixsystems/widget-sdk 0.40.1 → 0.41.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
@@ -13,7 +13,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
13
13
 
14
14
  **Wire / casing: snake_case end to end.** The clients send and return snake_case **verbatim** (`created_at`, `group_ids`, `can_read`, `amount_cents`, `data_type`, `is_active`, …). There is **no case transform anywhere** — not on the client and not in the backend; the only casing boundary is Prisma `@map` (snake_case field → camelCase column). Author-defined record column values pass through verbatim. Every `list(...)` returns the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you.
15
15
 
16
- **Hooks read the injected clients** — they do not hold their own HTTP. This is the **complete** hook surface (18 hooks), grouped by the domain client each one reads. **CORE** hooks read host state directly off `WidgetContext` (no data client); the rest delegate to one of the four injected clients. The grouping mirrors the banner sections in [`src/hooks.js`](src/hooks.js).
16
+ **Hooks read the injected clients** — they do not hold their own HTTP. This is the **complete** hook surface (19 hooks), grouped by the domain client each one reads. **CORE** hooks read host state directly off `WidgetContext` (no data client); the rest delegate to one of the four injected clients. The grouping mirrors the banner sections in [`src/hooks.js`](src/hooks.js).
17
17
 
18
18
  | Group | Hook (signature) | Returns | Reads / scope |
19
19
  | ----- | ---------------- | ------- | ------------- |
@@ -24,6 +24,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
24
24
  | **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope |
25
25
  | **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
26
26
  | **CORE** | `useFill()` | `boolean` | `ctx.fill` — no scope. `true` when the host sized this widget to fill its page-grid tile's reserved height (containers + media fill by default; the author can override per tile). Media-style widgets switch to a `flex: 1` / `height: "100%"` layout; others ignore it. Defaults `false`. |
27
+ | **CORE** | `useRefresh(handler)` | `void` | `ctx.refresh.subscribe` — no scope. Subscribes the handler to the page-level refresh tick (pull-to-refresh on mobile). Handler may return a Promise — the host waits for `allSettled` before clearing the spinner. The three datastore hooks auto-subscribe their own `refetch`; widgets only call this directly to re-run non-datastore work. No-op on a host that doesn't implement refresh. |
27
28
  | **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
28
29
  | **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` (falls back to a CustomEvent / console) — no scope |
29
30
  | **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. |
@@ -47,7 +48,15 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
47
48
 
48
49
  ## Status
49
50
 
50
- `v0.40.1` — 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**.
51
+ `v0.41.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**.
52
+
53
+ ### What's new in 0.41.0
54
+
55
+ **New `useRefresh(handler)` hook + page-level refresh signal (sc-1179).** Pull-to-refresh on the mobile web Player + the native Expo export's `RefreshControl` now fans a page-level refresh tick out to every widget on the page. The three datastore hooks — `useDatastoreQuery`, `useDatastoreRecord`, `useAsset` — auto-subscribe their own `refetch`, so a widget built on those hooks gets refreshed for free. Widgets that need to re-run other work (a third-party `fetch`, a derived calculation) call `useRefresh(async () => { … })` directly. The handler may return a Promise — the host waits on `Promise.allSettled` of every subscriber before clearing the spinner. The slot (`ctx.refresh.subscribe`) is optional on the WidgetContext: a host that does not implement refresh (the Studio canvas preview) simply omits it and the hook collapses to a no-op. Additive — `CONTRACT.version` bumped to the next minor since the contract grew a new hook + a new (optional) context slice.
56
+
57
+ ### What's new in 0.40.2
58
+
59
+ **`useI18n().t()` no longer leaks the host's `{{t:key}}` miss placeholder.** Resolution steps 1–2 (per-widget and shared namespaces) already treated a `{{t:…}}` return from the host as a miss, but the final raw-key step did not — on the web Player (whose host resolver returns the placeholder form on a miss and ignores the fallback argument) a key absent from the tenant dictionary rendered as literal `{{t:key}}` text instead of degrading to `fallback ?? key`. The same guard now applies to all three steps. **The public contract is unchanged** — this is the behaviour `useI18n` always documented. `CONTRACT.version` is unchanged.
51
60
 
52
61
  ### What's new in 0.40.1
53
62
 
package/dist/contract.cjs CHANGED
@@ -116,6 +116,26 @@ const HOOKS = [
116
116
  requiredContextSlice: ["renderer.renderNode"],
117
117
  scopes: null,
118
118
  },
119
+ {
120
+ name: "useRefresh",
121
+ signature: "useRefresh(handler)",
122
+ description:
123
+ "Subscribe to the page-level refresh tick (pull-to-refresh on " +
124
+ "mobile, manual refresh button, etc.). The handler is called every " +
125
+ "time the host triggers a refresh; it may return a Promise — the " +
126
+ "host waits on all settled handlers before clearing the refresh " +
127
+ "indicator. The three datastore hooks (useDatastoreQuery, " +
128
+ "useDatastoreRecord, useAsset) already subscribe automatically — " +
129
+ "widgets only call useRefresh directly when they need to re-run " +
130
+ "non-datastore work (a third-party fetch, a derived calculation). " +
131
+ "Safe to call on a host that does not implement refresh — it is a " +
132
+ "no-op there.",
133
+ returnShape: {
134
+ "(returns)": "void",
135
+ },
136
+ requiredContextSlice: ["refresh.subscribe"],
137
+ scopes: null,
138
+ },
119
139
  {
120
140
  name: "useNavigation",
121
141
  signature: "useNavigation()",
@@ -924,6 +944,21 @@ const WIDGET_CONTEXT_SHAPE = {
924
944
  required: true,
925
945
  fields: { renderNode: "function" },
926
946
  },
947
+ // sc-1179 — page-level refresh signal. Host owns the trigger (pull-to-
948
+ // refresh on mobile, refresh button, etc.); widgets opt in via
949
+ // useRefresh(handler). The three datastore hooks auto-subscribe; other
950
+ // widgets subscribe when they need to re-run non-datastore work. Optional
951
+ // so a host that does not implement refresh can simply omit it — the hook
952
+ // collapses to a no-op there.
953
+ refresh: {
954
+ description:
955
+ "Optional page-level refresh slot. { subscribe(handler) -> unsubscribe }. " +
956
+ "Handler is called on every page refresh tick (pull-to-refresh on " +
957
+ "mobile, manual refresh button); may return a Promise the host " +
958
+ "awaits before clearing the refresh indicator. Backs useRefresh().",
959
+ required: false,
960
+ fields: { subscribe: "function" },
961
+ },
927
962
  events: {
928
963
  description: "{ emit(name, payload) }.",
929
964
  required: true,
@@ -1582,7 +1617,17 @@ const CONTRACT = deepFreeze({
1582
1617
  // manual key entry, and a `widget.<id>.<key>` override still wins per
1583
1618
  // instance. No existing hook, primitive, manifest field, or token changed
1584
1619
  // shape — minor bump on the pre-1.0 channel.
1585
- version: "1.28.0",
1620
+ //
1621
+ // 1.29.0: additive (sc-1179) — page-level refresh signal. New hook
1622
+ // `useRefresh(handler)` and new optional WidgetContext slice
1623
+ // `refresh: { subscribe(handler) -> unsubscribe }`. The web Player's
1624
+ // pull-to-refresh gesture and the native Expo export's `RefreshControl`
1625
+ // both call into the same manager so a refresh tick fans out to every
1626
+ // widget on the page; the three datastore hooks (useDatastoreQuery /
1627
+ // useDatastoreRecord / useAsset) auto-subscribe their own `refetch`.
1628
+ // No existing hook, primitive, manifest field, or token changed shape
1629
+ // — minor bump on the pre-1.0 channel.
1630
+ version: "1.29.0",
1586
1631
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1587
1632
  hooks: HOOKS,
1588
1633
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -116,6 +116,26 @@ const HOOKS = [
116
116
  requiredContextSlice: ["renderer.renderNode"],
117
117
  scopes: null,
118
118
  },
119
+ {
120
+ name: "useRefresh",
121
+ signature: "useRefresh(handler)",
122
+ description:
123
+ "Subscribe to the page-level refresh tick (pull-to-refresh on " +
124
+ "mobile, manual refresh button, etc.). The handler is called every " +
125
+ "time the host triggers a refresh; it may return a Promise — the " +
126
+ "host waits on all settled handlers before clearing the refresh " +
127
+ "indicator. The three datastore hooks (useDatastoreQuery, " +
128
+ "useDatastoreRecord, useAsset) already subscribe automatically — " +
129
+ "widgets only call useRefresh directly when they need to re-run " +
130
+ "non-datastore work (a third-party fetch, a derived calculation). " +
131
+ "Safe to call on a host that does not implement refresh — it is a " +
132
+ "no-op there.",
133
+ returnShape: {
134
+ "(returns)": "void",
135
+ },
136
+ requiredContextSlice: ["refresh.subscribe"],
137
+ scopes: null,
138
+ },
119
139
  {
120
140
  name: "useNavigation",
121
141
  signature: "useNavigation()",
@@ -924,6 +944,21 @@ const WIDGET_CONTEXT_SHAPE = {
924
944
  required: true,
925
945
  fields: { renderNode: "function" },
926
946
  },
947
+ // sc-1179 — page-level refresh signal. Host owns the trigger (pull-to-
948
+ // refresh on mobile, refresh button, etc.); widgets opt in via
949
+ // useRefresh(handler). The three datastore hooks auto-subscribe; other
950
+ // widgets subscribe when they need to re-run non-datastore work. Optional
951
+ // so a host that does not implement refresh can simply omit it — the hook
952
+ // collapses to a no-op there.
953
+ refresh: {
954
+ description:
955
+ "Optional page-level refresh slot. { subscribe(handler) -> unsubscribe }. " +
956
+ "Handler is called on every page refresh tick (pull-to-refresh on " +
957
+ "mobile, manual refresh button); may return a Promise the host " +
958
+ "awaits before clearing the refresh indicator. Backs useRefresh().",
959
+ required: false,
960
+ fields: { subscribe: "function" },
961
+ },
927
962
  events: {
928
963
  description: "{ emit(name, payload) }.",
929
964
  required: true,
@@ -1582,7 +1617,17 @@ const CONTRACT = deepFreeze({
1582
1617
  // manual key entry, and a `widget.<id>.<key>` override still wins per
1583
1618
  // instance. No existing hook, primitive, manifest field, or token changed
1584
1619
  // shape — minor bump on the pre-1.0 channel.
1585
- version: "1.28.0",
1620
+ //
1621
+ // 1.29.0: additive (sc-1179) — page-level refresh signal. New hook
1622
+ // `useRefresh(handler)` and new optional WidgetContext slice
1623
+ // `refresh: { subscribe(handler) -> unsubscribe }`. The web Player's
1624
+ // pull-to-refresh gesture and the native Expo export's `RefreshControl`
1625
+ // both call into the same manager so a refresh tick fans out to every
1626
+ // widget on the page; the three datastore hooks (useDatastoreQuery /
1627
+ // useDatastoreRecord / useAsset) auto-subscribe their own `refetch`.
1628
+ // No existing hook, primitive, manifest field, or token changed shape
1629
+ // — minor bump on the pre-1.0 channel.
1630
+ version: "1.29.0",
1586
1631
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1587
1632
  hooks: HOOKS,
1588
1633
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -188,6 +188,49 @@ export function WidgetTree({ node }) {
188
188
  return ctx.renderer.renderNode(node);
189
189
  }
190
190
 
191
+ /**
192
+ * sc-1179 — subscribe to the page-level refresh tick.
193
+ *
194
+ * Pull-to-refresh on mobile (web Player at touch widths + the native Expo
195
+ * export's `RefreshControl`) triggers a page-wide refresh. Widgets that
196
+ * need to re-run work on a refresh — re-fetch from a third-party API,
197
+ * recompute a derived value — pass a handler here:
198
+ *
199
+ * useRefresh(async () => { await refetchMyThirdPartyData(); });
200
+ *
201
+ * The handler may return a Promise — the host waits on Promise.allSettled
202
+ * of every subscriber before clearing the refresh indicator. Handler
203
+ * identity does not need to be stable: the subscription captures it in a
204
+ * ref and reads the latest on every tick.
205
+ *
206
+ * `useDatastoreQuery` / `useDatastoreRecord` / `useAsset` already
207
+ * auto-subscribe their own `refetch`, so a widget built on the data hooks
208
+ * gets refreshed for free — call `useRefresh` directly only for
209
+ * non-datastore work.
210
+ *
211
+ * Safe to call on a host that does not implement refresh — the hook
212
+ * collapses to a no-op there.
213
+ */
214
+ export function useRefresh(handler) {
215
+ const ctx = useWidgetContextOrThrow("useRefresh");
216
+ const handlerRef = useRef(handler);
217
+ handlerRef.current = handler;
218
+ const subscribe = ctx.refresh && ctx.refresh.subscribe;
219
+ const subscribeRef = useRef(subscribe);
220
+ subscribeRef.current = subscribe;
221
+ useEffect(() => {
222
+ const sub = subscribeRef.current;
223
+ if (typeof sub !== "function") return undefined;
224
+ const unsubscribe = sub(() => {
225
+ const fn = handlerRef.current;
226
+ if (typeof fn === "function") return fn();
227
+ return undefined;
228
+ });
229
+ return typeof unsubscribe === "function" ? unsubscribe : undefined;
230
+ // eslint-disable-next-line react-hooks/exhaustive-deps
231
+ }, []);
232
+ }
233
+
191
234
  /**
192
235
  * Returns the host-provided navigation surface:
193
236
  * `{ goTo, goBack, push, replace, back, currentRoute }`.
@@ -270,8 +313,17 @@ export function useI18n() {
270
313
  }
271
314
  // 3) Fall back to the raw key (shared app keys + widgets with no
272
315
  // manifest translations — unchanged from pre-1.10 behaviour).
316
+ // The same `{{t:…}}` miss guard as steps 1–2 applies: the Player's
317
+ // host t ignores the fallback arg and returns the placeholder on a
318
+ // miss, and without the guard that placeholder rendered verbatim
319
+ // instead of degrading to `fallback ?? key`.
273
320
  const out = hostT(key, fallback);
274
- if (typeof out === "string" && out.length > 0 && out !== key) {
321
+ if (
322
+ typeof out === "string" &&
323
+ out.length > 0 &&
324
+ out !== key &&
325
+ !out.startsWith("{{t:")
326
+ ) {
275
327
  return out;
276
328
  }
277
329
  // Host returned the key (unresolved) or nothing usable.
@@ -469,6 +521,10 @@ export function useDatastoreQuery(table, query) {
469
521
  await doFetch();
470
522
  }, [doFetch]);
471
523
 
524
+ // sc-1179 — auto-subscribe to the page-level refresh tick so a
525
+ // pull-to-refresh on mobile re-runs the query without per-widget code.
526
+ useRefresh(refetch);
527
+
472
528
  return { data, loading, error, refetch };
473
529
  }
474
530
 
@@ -547,6 +603,9 @@ export function useDatastoreRecord(table, recordId) {
547
603
  await doFetch();
548
604
  }, [doFetch]);
549
605
 
606
+ // sc-1179 — auto-subscribe to the page-level refresh tick.
607
+ useRefresh(refetch);
608
+
550
609
  return { data, loading, error, refetch };
551
610
  }
552
611
 
@@ -1128,6 +1187,9 @@ export function useAsset(assetId) {
1128
1187
  await doFetch();
1129
1188
  }, [doFetch]);
1130
1189
 
1190
+ // sc-1179 — auto-subscribe to the page-level refresh tick.
1191
+ useRefresh(refetch);
1192
+
1131
1193
  const url =
1132
1194
  asset && typeof asset.url === "string" && asset.url.length > 0
1133
1195
  ? asset.url
package/dist/index.d.ts CHANGED
@@ -812,6 +812,21 @@ export const Linking: {
812
812
  canOpenURL(url: string): Promise<boolean>;
813
813
  };
814
814
 
815
+ /**
816
+ * sc-1179 — subscribe to the page-level refresh tick (pull-to-refresh on
817
+ * mobile, manual refresh button, etc.). The handler is called every time
818
+ * the host triggers a refresh; it may return a Promise — the host waits
819
+ * on all settled subscribers before clearing the refresh indicator.
820
+ *
821
+ * `useDatastoreQuery` / `useDatastoreRecord` / `useAsset` already
822
+ * auto-subscribe their own `refetch`, so widgets only call this directly
823
+ * to re-run non-datastore work. Safe to call on a host that does not
824
+ * implement refresh — collapses to a no-op there.
825
+ */
826
+ export function useRefresh(
827
+ handler: () => void | Promise<unknown>,
828
+ ): void;
829
+
815
830
  /**
816
831
  * Error class thrown by useDatastoreMutation callbacks (and surfaced by
817
832
  * useDatastoreQuery in its `error` slot). The `code` is a stable
package/dist/index.js CHANGED
@@ -37,6 +37,7 @@ export {
37
37
  useFill,
38
38
  useNavigation,
39
39
  useChildRenderer,
40
+ useRefresh,
40
41
  WidgetTree,
41
42
  } from "./hooks.js";
42
43
  // REQ-WSDK-PLATFORM §6 — Tier A hooks. Each ships in a per-platform file
@@ -35,6 +35,7 @@ export {
35
35
  useFill,
36
36
  useNavigation,
37
37
  useChildRenderer,
38
+ useRefresh,
38
39
  WidgetTree,
39
40
  } from "./hooks.js";
40
41
  // REQ-WSDK-PLATFORM §6 — Tier A hooks (native variants).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.40.1",
3
+ "version": "0.41.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",