@colixsystems/widget-sdk 0.100.0 → 0.102.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
@@ -24,6 +24,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
24
24
  | **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute, openLink }` | `ctx.navigation` — no scope (`openLink` for a link of unknown shape; a known external URL can also use the `Linking` primitive) |
25
25
  | **CORE** | `useRouteParams()` | `{ [paramKey]: value }` | `ctx.navigation.currentRoute.params` — no scope. The nav params the previous page passed via `goTo(pageId, params)`; the flat accessor for master→detail (read `recordId` on a detail page). Empty object when none. |
26
26
  | **CORE** | `usePageContext()` | `{ params, records }` | `ctx.pageContext` — no scope. The page's DECLARED parameters, resolved once by the host: `params` are coerced to their declared types, `records` holds the row already fetched for each `record` param (read it instead of fetching again). Both empty when the page declares none. |
27
+ | **CORE** | `useWidgetRoute(initial)` | `[state, setState]` | `ctx.widgetRoute` — no scope. Where YOUR WIDGET is, persisted by the host so it survives a reload and travels in a shared link (the `w_<instanceId>` query key on web, the screen's route params natively): the folder a browser has opened, a wizard step, a selected tab, a list's sort and search. `useState` semantics over an object — writes MERGE, `null` clears a key back to its `initial`, and `initial` is read once. Values are scalars or flat arrays of scalars, size- and length-capped; anything else is not stored. NOT history (Back still leaves the page, on both platforms). Degrades to component state on the Studio canvas. |
27
28
  | **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope. The hook IS the emitter: `const emitSlot = useWidgetEvent("slotChosen")`, then `emitSlot(payload)`. Never destructure the result — there is no `emit` member. |
28
29
  | **CORE** | `useWidgetInput(inputName)` | the published payload, or `undefined` | `ctx.inputs` — no scope. Reads a value ANOTHER widget on the same page published with `useWidgetEvent`. Declare the input in `manifest.inputs`; the page author wires it to one sibling's declared event. The channel retains the last payload, so a widget that mounts later still reads it. `undefined` while unwired or before the first publish — always render a sensible default. Page-scoped and ephemeral: use `useRouteParams()` for state that must survive navigation, the datastore for state that must persist. |
29
30
  | **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
@@ -99,6 +100,28 @@ Host-integration surface only: no author-facing hook, prop, primitive, or manife
99
100
 
100
101
  Host-integration surface only: no author-facing hook, prop, primitive, or manifest field changed. `CONTRACT.version` → `1.66.0`.
101
102
 
103
+ ### What's new in 0.101.0 (contract 1.75.0)
104
+
105
+ **Render an image at the size you actually show it — `file.urls` (sc-5699).** Every Filestore file record now carries a delivery **size ladder** beside `url`:
106
+
107
+ ```js
108
+ const { files } = useFilestoreFiles({ spaceType: 'public' });
109
+ // a 128px grid downloads 128px images, not the 4096px originals
110
+ files.map((f) => <Image key={f.id} source={{ uri: f.urls.thumbnail }} />);
111
+
112
+ const { url, urls } = useFilestoreFile(fileId);
113
+ <Image source={{ uri: urls?.large }} />; // a detail view
114
+ <a href={url}>Download original</a>; // the full-size bytes
115
+ ```
116
+
117
+ The rungs are `thumbnail` (128px), `card` (512px), `large` (1024px), and `hero` (2048px), each a **longest-edge** target. Pick the next rung **up** from the size you render at, so a 2x/3x screen still has enough pixels — a 100px avatar wants `thumbnail`, a 400px card wants `card`.
118
+
119
+ `urls` is **always fully populated**, so it never needs a fallback branch: a type with no ladder — SVG, an animated GIF, a PDF, a video — points every rung at the original. A rung is also never *upscaled*: ask for `hero` on a 300px image and you get the 300px original rather than a blurry 2048px copy.
120
+
121
+ `useFilestoreFile` returns `urls` alongside `url`, and — like `url` — it is `null` until the fetch resolves, so read the top-level value rather than `file.urls`.
122
+
123
+ Requires `@colixsystems/filestore-client` ≥ 0.8.0. `CONTRACT.version` → `1.75.0`. Additive — `url` and `presigned_url` are unchanged, so a widget that ignores `urls` behaves exactly as before.
124
+
102
125
  ### What's new in 0.100.0 (contract 1.74.0)
103
126
 
104
127
  **Opt out of image compression on upload — `useFilestoreUpload({ compress })` (sc-5402).** A file uploaded through `POST /api/v1/filestore/files` now has its raster images compressed to WebP again (EXIF-stripped, longest edge capped at 4096 px), which is the right default for anything the app renders. When the ORIGINAL bytes matter — a document archive, a photo the user re-downloads, anything with an exact-bytes requirement — pass `compress: false`, either on the hook or per call:
@@ -651,8 +674,8 @@ Also: `useFileSignatures(fileIds)` is now **self-scoped** (the caller's own sign
651
674
  ### What's new in 0.30.0
652
675
 
653
676
  **Filestore browsing + BankID file signing for widgets (REQ-FS / REQ-SIGN).** Three new hooks read a newly-injected `ctx.filestore` (the `@colixsystems/filestore-client`, now constructed by both the web and native hosts):
654
- - `useFilestoreFiles({ spaceType, folderId?, q?, type? })` → `{ files, loading, error, refetch }` — browses the end-user's Filestore space. The hook resolves `owner_id` from the host context (tenant for a project space, the app user for a personal space), so the widget only picks the space. Every row carries a ready-to-render `url` (its absolutized `presigned_url`), so a gallery renders `files.map(f => f.url)` directly — no per-row `useFilestoreFile` call.
655
- - `useFilestoreFile(fileId)` → `{ file, url, loading, error, refetch }` — resolves ONE file id to a displayable `url` (its `presigned_url`, absolutized for web + native) via `ctx.filestore.files.get(id)`. **Read the top-level `url`** — the returned `file` is `null` until the fetch resolves (and for an empty id), so `file.url` throws on the first render; it is correct only after a null check. **Never compose a file URL yourself** — the bytes are served only from a server-signed token URL, so a hand-built path like `/api/files/<id>` can never resolve (the linter's `no-host-api-url` rule flags it, including the `${location.origin}/api/files/…` form). A datastore `FILE` column holds — and reads back as — that **bare file-id string**; it is NOT hydrated into an object the way a `RELATION` (`{ id, label }`) or `USER` (`{ id, name }`) column is, so pass the value straight to the hook (no `{ id }` / `{ url }` unwrapping guard). Empty / deleted / not-found ids resolve to `url: null` so a display widget shows a fallback. When you render `url` in an `<Image>` that fills its container, size it with `aspectRatio` (e.g. `{ width: "100%", aspectRatio: 1 }`) or pixels — never a percentage `height`, which React Native collapses to 0 against a content-sized parent, so the image loads but is invisible. Requires `files.read:*`.
677
+ - `useFilestoreFiles({ spaceType, folderId?, q?, type? })` → `{ files, loading, error, refetch }` — browses the end-user's Filestore space. The hook resolves `owner_id` from the host context (tenant for a project space, the app user for a personal space), so the widget only picks the space. Every row carries a ready-to-render `url` (its absolutized `presigned_url`), so a gallery renders `files.map(f => f.url)` directly — no per-row `useFilestoreFile` call. Every row ALSO carries `urls` — the size ladder `{ thumbnail, card, large, hero }` at 128 / 512 / 1024 / 2048px longest edge — so a thumbnail grid should render `f.urls.thumbnail` rather than pulling the full-size original for every tile.
678
+ - `useFilestoreFile(fileId)` → `{ file, url, urls, loading, error, refetch }` — resolves ONE file id to a displayable `url` (its `presigned_url`, absolutized for web + native) via `ctx.filestore.files.get(id)`. **Read the top-level `url`** — the returned `file` is `null` until the fetch resolves (and for an empty id), so `file.url` throws on the first render; it is correct only after a null check. **Never compose a file URL yourself** — the bytes are served only from a server-signed token URL, so a hand-built path like `/api/files/<id>` can never resolve (the linter's `no-host-api-url` rule flags it, including the `${location.origin}/api/files/…` form). A datastore `FILE` column holds — and reads back as — that **bare file-id string**; it is NOT hydrated into an object the way a `RELATION` (`{ id, label }`) or `USER` (`{ id, name }`) column is, so pass the value straight to the hook (no `{ id }` / `{ url }` unwrapping guard). Empty / deleted / not-found ids resolve to `url: null` so a display widget shows a fallback. When you render `url` in an `<Image>` that fills its container, size it with `aspectRatio` (e.g. `{ width: "100%", aspectRatio: 1 }`) or pixels — never a percentage `height`, which React Native collapses to 0 against a content-sized parent, so the image loads but is invisible. Requires `files.read:*`.
656
679
  - `useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })` → `{ folders, loading, error, refetch }` — the folder-navigation companion to `useFilestoreFiles`; pass `enabled:false` to suspend fetching.
657
680
  - `useFilestoreUpload({ spaceType, folderId?, compress? })` → `{ upload, uploading, error, lastUploaded }` — POSTs a multipart upload to `ctx.filestore.files.upload`. Resolves `owner_id` from the host context (like the read hooks) so the widget only picks the space + destination folder. Uploaded images are compressed to WebP by the backend; pass `compress: false` (on the hook, or per `upload(file, { compress })`) to store the file byte-for-byte — use it whenever the original matters. Pair with the `<FilePicker>` primitive for the visible trigger. Requires the `files.write:*` scope.
658
681
  - `useFileSignature(fileId)` → `{ status, qr, signerName, verdict, initiate, refresh, cancel, verify, … }` — drives a BankID signing flow for a file (the backend hashes the bytes server-side, binds the digest into the signature, and verifies the proof offline).
@@ -815,6 +838,10 @@ The "split-implementation + vetted package list" pivot.
815
838
  - **`useDatastoreRecord(tableId, recordId)` is wired.** Returns `{ data, loading, error, refetch }` for a single record fetched through the host's `records(table).get(id)`. Sister to `useDatastoreQuery`; mirrors its ref discipline so `refetch` stays a stable callback identity. A 404 surfaces as `DatastoreError.code === "NOT_FOUND"`. Additive.
816
839
  - **`useAsset(fileId)` is wired** + new `WidgetContext.assets` slice. Returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL the widget can drop straight into `<Image source>`. Backed by a new `files.get(fileId)` host facade (web: `widgetHostFiles` through `api/client`; native: `hostFiles` in the export's `widgetHost.js`). Additive.
817
840
 
841
+ ### What's new in 0.102.0
842
+
843
+ - **`useWidgetRoute(initial)` persists your widget's own internal position.** A widget's internal navigation lived in bare `useState`, so it was neither linkable nor durable: a reload dropped the visitor back at the opening view. Use the hook exactly like `useState` with an object — `const [view, setView] = useWidgetRoute({ path: [], sort: 'name' })` — and the host persists the bag: the view survives a reload and a copied link opens the widget where the sender was. Writes MERGE, `null` clears a key back to the `initial` you declared (which is what keeps the address clean), and `initial` is read once like `useState`'s argument. Values are scalars or flat arrays of scalars — a path, a list of visited steps — and are size- and length-capped; nothing else is stored. Scoped to the PLACEMENT, so the same widget placed twice on a page keeps two independent views. Three rules worth reading twice: it is **not history** (Back still leaves the page on both platforms, because native has no equivalent stack to mirror an in-widget one onto), it is **opt-in per key** (transient UI — an open dropdown, a half-typed form — stays in ordinary `useState`), and for a **text input** you should keep the box in `useState` and push it in on a short debounce rather than rewriting the address once per keystroke. Degrades to plain component state on the Studio canvas, so it is always safe to call. Additive (v0.102.0).
844
+
818
845
  ### What's new in 0.11.0
819
846
 
820
847
  - **`useNavigation()` is wired.** Returns the host-provided navigation surface `{ goTo, goBack, push, replace, back, currentRoute, openLink }` for internal page-to-page navigation. Missing methods degrade to no-ops on the Studio canvas preview. Additive.
@@ -893,7 +920,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
893
920
 
894
921
  - `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
895
922
  - `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
896
- - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError` carrying `code`, the server's user-safe `message`, and `retryable` (`false` = this charge cannot succeed until the workspace, manifest, or amount changes — show the message, not a retry). `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute, openLink }` for internal page navigation; `openLink(link)` follows an author- or data-supplied link of unknown shape through the host's shared resolver (in-app page → internal route, off-app http(s) → opened outside, unsafe → refused) and is the safe choice for any value your code did not construct, while `Linking.openURL(url)` is for an external URL you built yourself. `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case). `useWidgetInput(inputName)` returns the latest payload a sibling widget published on the event the page author wired to this widget's declared `inputs` entry (`undefined` when unwired or not yet published).
923
+ - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useWidgetRoute`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError` carrying `code`, the server's user-safe `message`, and `retryable` (`false` = this charge cannot succeed until the workspace, manifest, or amount changes — show the message, not a retry). `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute, openLink }` for internal page navigation; `openLink(link)` follows an author- or data-supplied link of unknown shape through the host's shared resolver (in-app page → internal route, off-app http(s) → opened outside, unsafe → refused) and is the safe choice for any value your code did not construct, while `Linking.openURL(url)` is for an external URL you built yourself. `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case). `useWidgetInput(inputName)` returns the latest payload a sibling widget published on the event the page author wired to this widget's declared `inputs` entry (`undefined` when unwired or not yet published).
897
924
  - `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
898
925
  - `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`; `DateTimePicker` wraps `@react-native-community/datetimepicker` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
899
926
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
package/dist/contract.cjs CHANGED
@@ -688,6 +688,20 @@ const HOOKS = [
688
688
  requiredContextSlice: ["pageContext"],
689
689
  scopes: null,
690
690
  },
691
+ {
692
+ name: "useWidgetRoute",
693
+ signature: "useWidgetRoute(initial)",
694
+ returnShape: {
695
+ "[0]":
696
+ "the widget INSTANCE's own internal position, merged over the initial object you declared — the folder a browser is in, a wizard step, a list's sort/search. Survives a reload and travels in a shared link.",
697
+ "[1]":
698
+ "setState(patchOrUpdater) — merges like a class setState. A null value clears that key back to its initial. Scalars only (string, finite number, boolean); anything else is dropped.",
699
+ },
700
+ description:
701
+ "The widget's OWN internal position, persisted by the host so it survives a reload and travels in a shared link — the folder a file browser is in, a wizard step, a list's sort and search. Use it like useState with an object: writes MERGE, null clears a key back to its initial, and initial is read once. Values are scalars or flat arrays of scalars (a path, a list of visited steps). Only for state a visitor would link to or expect to survive a reload; transient UI stays in plain useState. It is NOT history — Back still leaves the page, on both platforms.",
702
+ requiredContextSlice: ["widgetRoute"],
703
+ scopes: null,
704
+ },
691
705
  {
692
706
  name: "useDatastoreRecord",
693
707
  signature: "useDatastoreRecord(tableId, recordId)",
@@ -746,7 +760,15 @@ const HOOKS = [
746
760
  "logged-out Player visitors. Reads ctx.filestore.files.list and unwraps " +
747
761
  "{ data, meta } to the files array. Every row carries a ready-to-render " +
748
762
  "`url` (its absolutized presigned_url, aliased by the filestore client) " +
749
- "so a gallery renders `files.map(f => f.url)` directly.",
763
+ "so a gallery renders `files.map(f => f.url)` directly. Every row ALSO " +
764
+ "carries `urls` — the delivery size ladder `{ thumbnail, card, large, " +
765
+ "hero }` at 128 / 512 / 1024 / 2048px longest edge. PREFER a rung over " +
766
+ "`url` whenever the image is rendered smaller than full size: a grid of " +
767
+ "thumbnails should read `f.urls.thumbnail`, a card list `f.urls.card`. " +
768
+ "Pick the next rung UP from the rendered size so a 2x/3x screen still " +
769
+ "has enough pixels. `urls` is always fully populated — a type with no " +
770
+ "ladder (SVG, GIF, a PDF) points every rung at the original — so it " +
771
+ "never needs a fallback branch.",
750
772
  returnShape: {
751
773
  files: "FilestoreFile[]",
752
774
  loading: "boolean",
@@ -773,10 +795,14 @@ const HOOKS = [
773
795
  "stays null for an empty id), so `file.url` throws on the first render. " +
774
796
  "Every file record the client returns does carry the same `url` alias, " +
775
797
  "so `file.url` is correct once you have null-checked `file`. " +
798
+ "`file.urls` carries the size ladder `{ thumbnail, card, large, hero }` " +
799
+ "(128 / 512 / 1024 / 2048px longest edge) — reach for a rung whenever " +
800
+ "the image renders smaller than full size, picking the next rung UP. " +
776
801
  "NEVER build a file URL by hand from an id — no route serves one.",
777
802
  returnShape: {
778
803
  file: "FilestoreFile | null",
779
804
  url: "string | null",
805
+ urls: "{ thumbnail, card, large, hero } | null",
780
806
  loading: "boolean",
781
807
  error: "Error | null",
782
808
  refetch: "() => Promise<void>",
@@ -1926,6 +1952,15 @@ const WIDGET_CONTEXT_SHAPE = {
1926
1952
  required: true,
1927
1953
  fields: { params: "object", records: "object" },
1928
1954
  },
1955
+ widgetRoute: {
1956
+ description:
1957
+ "sc-5717 — the widget INSTANCE's own persisted internal position. " +
1958
+ "{ params: { <key>: scalar }, set(bag) }. " +
1959
+ "params is what the host read back for THIS placement (the w_<instanceId> query key on web, the screen's route params on native); set writes the changed-from-initial bag back. " +
1960
+ "Absent on a host with nowhere to persist (the Studio canvas), where useWidgetRoute() degrades to component state. Backs useWidgetRoute().",
1961
+ required: false,
1962
+ fields: { params: "object", set: "function" },
1963
+ },
1929
1964
  datastore: {
1930
1965
  description:
1931
1966
  "Injected @colixsystems/datastore-client instance. " +
@@ -2553,6 +2588,21 @@ function normaliseCurrencyCode(value) {
2553
2588
  * A non-finite amount renders as zero rather than "NaN": a price whose data has
2554
2589
  * not loaded should read as an amount, never as a broken string in a checkout.
2555
2590
  */
2591
+ /**
2592
+ * sc-5717 — value equality for a stored widget-route value: is this key still
2593
+ * at the default its widget declared?
2594
+ *
2595
+ * Structural for arrays, because two equal arrays are never Object.is-equal —
2596
+ * identity alone would report an array key as permanently moved-from-default,
2597
+ * so its key could never be cleared from the address again. Lives here rather
2598
+ * than beside the codec so hooks.js and widget-route.js share ONE rule.
2599
+ */
2600
+ function sameWidgetRouteValue(a, b) {
2601
+ if (Array.isArray(a) && Array.isArray(b)) {
2602
+ return a.length === b.length && a.every((item, i) => Object.is(item, b[i]));
2603
+ }
2604
+ return Object.is(a, b);
2605
+ }
2556
2606
  function formatMoneyIn(currency, minorUnits) {
2557
2607
  const code = normaliseCurrencyCode(currency);
2558
2608
  const fmt = CURRENCY_FORMATS[code] || {
@@ -3209,7 +3259,7 @@ const CONTRACT = deepFreeze({
3209
3259
  // are compressed to WebP by default; `compress: false` stores the file
3210
3260
  // byte-for-byte. Existing callers are unaffected — the field is only sent
3211
3261
  // when the opt-out is chosen.
3212
- version: "1.74.0",
3262
+ version: "1.76.0",
3213
3263
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3214
3264
  hooks: HOOKS,
3215
3265
  primitives: PRIMITIVES,
@@ -3509,6 +3559,7 @@ module.exports = {
3509
3559
  normaliseComponentGradient,
3510
3560
  formatMoneyIn,
3511
3561
  normaliseCurrencyCode,
3562
+ sameWidgetRouteValue,
3512
3563
  widgetTranslationPrefix,
3513
3564
  widgetTranslationKey,
3514
3565
  sharedTranslationPrefix,
package/dist/contract.js CHANGED
@@ -688,6 +688,20 @@ const HOOKS = [
688
688
  requiredContextSlice: ["pageContext"],
689
689
  scopes: null,
690
690
  },
691
+ {
692
+ name: "useWidgetRoute",
693
+ signature: "useWidgetRoute(initial)",
694
+ returnShape: {
695
+ "[0]":
696
+ "the widget INSTANCE's own internal position, merged over the initial object you declared — the folder a browser is in, a wizard step, a list's sort/search. Survives a reload and travels in a shared link.",
697
+ "[1]":
698
+ "setState(patchOrUpdater) — merges like a class setState. A null value clears that key back to its initial. Scalars only (string, finite number, boolean); anything else is dropped.",
699
+ },
700
+ description:
701
+ "The widget's OWN internal position, persisted by the host so it survives a reload and travels in a shared link — the folder a file browser is in, a wizard step, a list's sort and search. Use it like useState with an object: writes MERGE, null clears a key back to its initial, and initial is read once. Values are scalars or flat arrays of scalars (a path, a list of visited steps). Only for state a visitor would link to or expect to survive a reload; transient UI stays in plain useState. It is NOT history — Back still leaves the page, on both platforms.",
702
+ requiredContextSlice: ["widgetRoute"],
703
+ scopes: null,
704
+ },
691
705
  {
692
706
  name: "useDatastoreRecord",
693
707
  signature: "useDatastoreRecord(tableId, recordId)",
@@ -746,7 +760,15 @@ const HOOKS = [
746
760
  "logged-out Player visitors. Reads ctx.filestore.files.list and unwraps " +
747
761
  "{ data, meta } to the files array. Every row carries a ready-to-render " +
748
762
  "`url` (its absolutized presigned_url, aliased by the filestore client) " +
749
- "so a gallery renders `files.map(f => f.url)` directly.",
763
+ "so a gallery renders `files.map(f => f.url)` directly. Every row ALSO " +
764
+ "carries `urls` — the delivery size ladder `{ thumbnail, card, large, " +
765
+ "hero }` at 128 / 512 / 1024 / 2048px longest edge. PREFER a rung over " +
766
+ "`url` whenever the image is rendered smaller than full size: a grid of " +
767
+ "thumbnails should read `f.urls.thumbnail`, a card list `f.urls.card`. " +
768
+ "Pick the next rung UP from the rendered size so a 2x/3x screen still " +
769
+ "has enough pixels. `urls` is always fully populated — a type with no " +
770
+ "ladder (SVG, GIF, a PDF) points every rung at the original — so it " +
771
+ "never needs a fallback branch.",
750
772
  returnShape: {
751
773
  files: "FilestoreFile[]",
752
774
  loading: "boolean",
@@ -773,10 +795,14 @@ const HOOKS = [
773
795
  "stays null for an empty id), so `file.url` throws on the first render. " +
774
796
  "Every file record the client returns does carry the same `url` alias, " +
775
797
  "so `file.url` is correct once you have null-checked `file`. " +
798
+ "`file.urls` carries the size ladder `{ thumbnail, card, large, hero }` " +
799
+ "(128 / 512 / 1024 / 2048px longest edge) — reach for a rung whenever " +
800
+ "the image renders smaller than full size, picking the next rung UP. " +
776
801
  "NEVER build a file URL by hand from an id — no route serves one.",
777
802
  returnShape: {
778
803
  file: "FilestoreFile | null",
779
804
  url: "string | null",
805
+ urls: "{ thumbnail, card, large, hero } | null",
780
806
  loading: "boolean",
781
807
  error: "Error | null",
782
808
  refetch: "() => Promise<void>",
@@ -1926,6 +1952,15 @@ const WIDGET_CONTEXT_SHAPE = {
1926
1952
  required: true,
1927
1953
  fields: { params: "object", records: "object" },
1928
1954
  },
1955
+ widgetRoute: {
1956
+ description:
1957
+ "sc-5717 — the widget INSTANCE's own persisted internal position. " +
1958
+ "{ params: { <key>: scalar }, set(bag) }. " +
1959
+ "params is what the host read back for THIS placement (the w_<instanceId> query key on web, the screen's route params on native); set writes the changed-from-initial bag back. " +
1960
+ "Absent on a host with nowhere to persist (the Studio canvas), where useWidgetRoute() degrades to component state. Backs useWidgetRoute().",
1961
+ required: false,
1962
+ fields: { params: "object", set: "function" },
1963
+ },
1929
1964
  datastore: {
1930
1965
  description:
1931
1966
  "Injected @colixsystems/datastore-client instance. " +
@@ -2553,6 +2588,21 @@ function normaliseCurrencyCode(value) {
2553
2588
  * A non-finite amount renders as zero rather than "NaN": a price whose data has
2554
2589
  * not loaded should read as an amount, never as a broken string in a checkout.
2555
2590
  */
2591
+ /**
2592
+ * sc-5717 — value equality for a stored widget-route value: is this key still
2593
+ * at the default its widget declared?
2594
+ *
2595
+ * Structural for arrays, because two equal arrays are never Object.is-equal —
2596
+ * identity alone would report an array key as permanently moved-from-default,
2597
+ * so its key could never be cleared from the address again. Lives here rather
2598
+ * than beside the codec so hooks.js and widget-route.js share ONE rule.
2599
+ */
2600
+ function sameWidgetRouteValue(a, b) {
2601
+ if (Array.isArray(a) && Array.isArray(b)) {
2602
+ return a.length === b.length && a.every((item, i) => Object.is(item, b[i]));
2603
+ }
2604
+ return Object.is(a, b);
2605
+ }
2556
2606
  function formatMoneyIn(currency, minorUnits) {
2557
2607
  const code = normaliseCurrencyCode(currency);
2558
2608
  const fmt = CURRENCY_FORMATS[code] || {
@@ -3209,7 +3259,7 @@ const CONTRACT = deepFreeze({
3209
3259
  // are compressed to WebP by default; `compress: false` stores the file
3210
3260
  // byte-for-byte. Existing callers are unaffected — the field is only sent
3211
3261
  // when the opt-out is chosen.
3212
- version: "1.74.0",
3262
+ version: "1.76.0",
3213
3263
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3214
3264
  hooks: HOOKS,
3215
3265
  primitives: PRIMITIVES,
@@ -3509,6 +3559,7 @@ export {
3509
3559
  normaliseComponentGradient,
3510
3560
  formatMoneyIn,
3511
3561
  normaliseCurrencyCode,
3562
+ sameWidgetRouteValue,
3512
3563
  widgetTranslationPrefix,
3513
3564
  widgetTranslationKey,
3514
3565
  sharedTranslationPrefix,
package/dist/hooks.js CHANGED
@@ -43,6 +43,9 @@ import {
43
43
  isSharedTranslationKey,
44
44
  formatMoneyIn,
45
45
  normaliseCurrencyCode,
46
+ // sc-5717 — "is this key still at its declared default", shared with the
47
+ // host codec so the hook and the encoder can never disagree about it.
48
+ sameWidgetRouteValue,
46
49
  } from "./contract.js";
47
50
 
48
51
  /** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
@@ -479,6 +482,103 @@ export function usePageContext() {
479
482
  };
480
483
  }
481
484
 
485
+ const EMPTY_WIDGET_ROUTE = Object.freeze({ params: EMPTY_PARAMS, set: null });
486
+
487
+ /**
488
+ * sc-5717 — `[state, setState]` for the widget's OWN internal position, which
489
+ * the host persists so it survives a reload and travels in a shared link.
490
+ *
491
+ * Use it exactly like `useState` with an object, for the state that answers
492
+ * "where is this widget right now": the folder a browser is in, a wizard's
493
+ * step, a list's sort and search.
494
+ *
495
+ * const [view, setView] = useWidgetRoute({ folder: null, sort: "name" });
496
+ * setView({ folder: row.id }); // merges, like a class setState
497
+ * setView((v) => ({ sort: flip(v.sort) }));
498
+ *
499
+ * Semantics worth knowing:
500
+ *
501
+ * - **Merging, not replacing.** A partial write leaves the other keys alone.
502
+ * - **Values are scalars** — string, finite number, boolean. `null` clears a key
503
+ * back to the initial value you declared, which is also what keeps the URL
504
+ * clean rather than carrying `{"folder":null}` forever.
505
+ * - **`initial` is read once**, like `useState`'s initial argument. Re-rendering
506
+ * with a different literal does not reset anything.
507
+ * - **It is not history.** Persisting the position is not the same as making
508
+ * Back step through it: pressing Back leaves the page on both platforms, the
509
+ * same way it did before. Native has no URL to step through and no
510
+ * host-neutral way to intercept its back gesture per widget, so an
511
+ * in-widget back stack would behave one way on the web Player and another in
512
+ * the exported app — the divergence CLAUDE.md §8 exists to prevent.
513
+ * - **Opt in per key.** Only put state here that a visitor would reasonably
514
+ * link to or expect to survive a reload. Transient UI — an open dropdown, a
515
+ * half-typed form — belongs in ordinary `useState`.
516
+ *
517
+ * On a host with nowhere to persist (the Studio canvas preview) it degrades to
518
+ * plain component state, so a widget behaves the same in every surface.
519
+ */
520
+ export function useWidgetRoute(initial) {
521
+ const ctx = useWidgetContextOrThrow("useWidgetRoute");
522
+ const slice = ctx.widgetRoute || EMPTY_WIDGET_ROUTE;
523
+ const stored = slice.params || EMPTY_PARAMS;
524
+
525
+ // `initial` is the DECLARED default, captured once — a caller passing an
526
+ // object literal re-creates it every render, and re-resolving from the newest
527
+ // one would let an unrelated re-render silently redefine "cleared".
528
+ const initialRef = useRef(null);
529
+ if (initialRef.current === null) {
530
+ initialRef.current =
531
+ initial && typeof initial === "object" && !Array.isArray(initial)
532
+ ? { ...initial }
533
+ : {};
534
+ }
535
+
536
+ // The canvas has no host store; fall back to component state so the widget
537
+ // still works, just without persistence.
538
+ const [local, setLocal] = useState(initialRef.current);
539
+ const persisted = typeof slice.set === "function";
540
+
541
+ const state = useMemo(
542
+ () => (persisted ? { ...initialRef.current, ...stored } : local),
543
+ [persisted, stored, local],
544
+ );
545
+
546
+ // `state` is read inside, so the callback must see the current one; a ref
547
+ // keeps `setState` referentially stable for widgets that pass it to a memoised
548
+ // child or list an effect dependency on it.
549
+ const stateRef = useRef(state);
550
+ stateRef.current = state;
551
+ const setRef = useRef(slice.set);
552
+ setRef.current = slice.set;
553
+
554
+ const setState = useCallback((patch) => {
555
+ const current = stateRef.current;
556
+ const next = typeof patch === "function" ? patch(current) : patch;
557
+ if (!next || typeof next !== "object" || Array.isArray(next)) return;
558
+ const merged = { ...current, ...next };
559
+ const write = setRef.current;
560
+ if (typeof write !== "function") {
561
+ setLocal(merged);
562
+ return;
563
+ }
564
+ // Only the DIFFERENCE from the declared initial is handed to the host. A key
565
+ // sitting at its default is restored from `initial` anyway, so storing it
566
+ // would spend URL budget to say nothing — and returning a widget to its
567
+ // opening view leaves a clean address rather than a bag full of defaults.
568
+ const defaults = initialRef.current;
569
+ const changed = {};
570
+ for (const key of Object.keys(merged)) {
571
+ if (key in defaults && sameWidgetRouteValue(merged[key], defaults[key])) {
572
+ continue;
573
+ }
574
+ changed[key] = merged[key];
575
+ }
576
+ write(changed);
577
+ }, []);
578
+
579
+ return [state, setState];
580
+ }
581
+
482
582
  /**
483
583
  * Returns { t, locale }. `t(key, fallback)` resolves `{{t:key}}` against
484
584
  * the host's translation table and falls back to `fallback ?? key` when
@@ -2768,7 +2868,11 @@ export function useFilestoreFile(fileId) {
2768
2868
 
2769
2869
  const url =
2770
2870
  file && typeof file.presigned_url === "string" ? file.presigned_url : null;
2771
- return { file, url, loading, error, refetch };
2871
+ // REQ-FS-15: the size ladder, surfaced beside `url` for the same reason —
2872
+ // `file` is null until the fetch resolves, so `file.urls` throws on first
2873
+ // render. Null until then, exactly like `url`.
2874
+ const urls = file && file.urls && typeof file.urls === "object" ? file.urls : null;
2875
+ return { file, url, urls, loading, error, refetch };
2772
2876
  }
2773
2877
 
2774
2878
  /**
package/dist/host.d.ts CHANGED
@@ -182,3 +182,43 @@ export interface FooterTokens {
182
182
  * renders exactly as it did before the block existed.
183
183
  */
184
184
  export function resolveFooterTokens(theme: unknown): FooterTokens;
185
+
186
+ /** A value a widget may persist: a scalar, or a flat array of them. */
187
+ export type WidgetRouteScalar = string | number | boolean;
188
+ export type WidgetRouteValue = WidgetRouteScalar | WidgetRouteScalar[];
189
+
190
+ /** How many entries one array value may carry. */
191
+ export const WIDGET_ROUTE_MAX_ITEMS: number;
192
+
193
+ /** Value equality for "is this key still at its declared default" — structural
194
+ * for arrays, so an array key can be cleared from the address. */
195
+ export function sameWidgetRouteValue(a: unknown, b: unknown): boolean;
196
+
197
+ /** Query-string / route-param key prefix for a widget's stored position. */
198
+ export const WIDGET_ROUTE_PREFIX: string;
199
+
200
+ /** Cap on ONE instance's encoded bag, so a single widget cannot spend the
201
+ * whole URL budget. An over-cap write is dropped, never truncated. */
202
+ export const WIDGET_ROUTE_MAX_CHARS: number;
203
+
204
+ /** The key one placed widget instance stores its route bag under. */
205
+ export function widgetRouteKey(instanceId: string): string;
206
+
207
+ /** Whether a raw query-string key belongs to a widget rather than the page. */
208
+ export function isWidgetRouteKey(key: unknown): boolean;
209
+
210
+ /** Encode a bag for the address. Returns "" when there is nothing to store or
211
+ * the bag exceeds `WIDGET_ROUTE_MAX_CHARS` — the caller removes the key. */
212
+ export function encodeWidgetRoute(
213
+ bag: Record<string, unknown> | null | undefined,
214
+ ): string;
215
+
216
+ /** Decode what the address carried. Total: any malformed input yields `{}`. */
217
+ export function decodeWidgetRoute(
218
+ raw: unknown,
219
+ ): Record<string, WidgetRouteValue>;
220
+
221
+ /** Every widget's stored bag out of a raw query-string map, keyed by instance id. */
222
+ export function collectWidgetRoutes(
223
+ rawParams: Record<string, string> | null | undefined,
224
+ ): Record<string, Record<string, WidgetRouteValue>>;
package/dist/host.js CHANGED
@@ -55,3 +55,21 @@ export {
55
55
  quickBarCap,
56
56
  resolveFooterTokens,
57
57
  } from "./navigation.js";
58
+
59
+ // sc-5717: the codec behind `useWidgetRoute()` — how a widget's own internal
60
+ // position is written into the address the host carries (the query string on
61
+ // web, the screen's route params on native). Host-only by necessity as well as
62
+ // by taste: a widget may not touch the URL at all, so the encoding cannot live
63
+ // on the author side. Both hosts import THIS module, so a widget's stored
64
+ // position can never mean one thing in the Player and another in the export.
65
+ export {
66
+ WIDGET_ROUTE_PREFIX,
67
+ WIDGET_ROUTE_MAX_CHARS,
68
+ WIDGET_ROUTE_MAX_ITEMS,
69
+ widgetRouteKey,
70
+ isWidgetRouteKey,
71
+ encodeWidgetRoute,
72
+ decodeWidgetRoute,
73
+ collectWidgetRoutes,
74
+ sameWidgetRouteValue,
75
+ } from "./widget-route.js";
package/dist/index.d.ts CHANGED
@@ -1278,6 +1278,28 @@ export function usePageContext(): {
1278
1278
  records: Record<string, Record<string, unknown> | null>;
1279
1279
  };
1280
1280
 
1281
+ /** A value `useWidgetRoute` can persist: a scalar, or a flat array of them. */
1282
+ export type WidgetRouteScalar = string | number | boolean;
1283
+ export type WidgetRouteValue = WidgetRouteScalar | WidgetRouteScalar[];
1284
+
1285
+ /**
1286
+ * sc-5717 — `[state, setState]` for the widget's OWN internal position, which
1287
+ * the host persists so it survives a reload and travels in a shared link: the
1288
+ * folder a browser is in, a wizard's step, a list's sort and search.
1289
+ *
1290
+ * Use it like `useState` with an object. Writes MERGE, `null` clears a key back
1291
+ * to the value you declared in `initial`, and `initial` itself is read once.
1292
+ * Only put state here that a visitor would reasonably link to or expect to
1293
+ * survive a reload — transient UI belongs in ordinary `useState`.
1294
+ *
1295
+ * It is not history: pressing Back leaves the page, on both platforms.
1296
+ */
1297
+ export function useWidgetRoute<
1298
+ T extends Record<string, WidgetRouteValue | null>,
1299
+ >(
1300
+ initial: T,
1301
+ ): [T, (patch: Partial<T> | ((current: T) => Partial<T>)) => void];
1302
+
1281
1303
  /**
1282
1304
  * Static API for external URLs. `openURL(url)` opens a URL with the OS
1283
1305
  * handler (web: react-native-web maps to `window.open` / `location.href`;
package/dist/index.js CHANGED
@@ -55,6 +55,7 @@ export {
55
55
  useNavigation,
56
56
  useRouteParams,
57
57
  usePageContext,
58
+ useWidgetRoute,
58
59
  useChildRenderer,
59
60
  useRefresh,
60
61
  useSectionEmpty,
@@ -55,6 +55,7 @@ export {
55
55
  useNavigation,
56
56
  useRouteParams,
57
57
  usePageContext,
58
+ useWidgetRoute,
58
59
  useChildRenderer,
59
60
  useRefresh,
60
61
  useSectionEmpty,
package/dist/linter.cjs CHANGED
@@ -392,6 +392,33 @@ function _hostApiUrlRules(source) {
392
392
  return findings;
393
393
  }
394
394
 
395
+ // sc-5619 — see linter.js for the rationale comment. The two files must stay
396
+ // in lockstep (the contract test asserts behaviour-equivalence).
397
+ const PAGE_URL_RE = /["'`][^"'`]*\/play\/|\/play\/\$\{/;
398
+
399
+ function _handBuiltPageUrlRules(source) {
400
+ const findings = [];
401
+ const lines = _stripNonCode(source, { keepStrings: true }).split(/\r?\n/);
402
+ for (let i = 0; i < lines.length; i += 1) {
403
+ if (!PAGE_URL_RE.test(lines[i])) continue;
404
+ findings.push({
405
+ rule: "no-hand-built-page-url",
406
+ severity: "error",
407
+ label:
408
+ `source builds a "/play/…" app URL by hand — the URL shape belongs ` +
409
+ `to the host, not the widget, so a hardcoded one breaks on a ` +
410
+ `workspace's own domain (where a page is "/page/<slug>", with no ` +
411
+ `workspace id) and leaks the workspace id into the address bar. ` +
412
+ `Navigate with useNavigation().goTo(pageId) — the page id comes from ` +
413
+ `a pageRef prop — and follow a link you did not build with ` +
414
+ `useNavigation().openLink(link).`,
415
+ line: i + 1,
416
+ snippet: lines[i].trim().slice(0, 200),
417
+ });
418
+ }
419
+ return findings;
420
+ }
421
+
395
422
  // sc-4085 — see linter.js for the rationale comment. The two files must stay
396
423
  // in lockstep (the contract test asserts behaviour-equivalence).
397
424
  function _translationApiRules(source) {
@@ -1166,6 +1193,7 @@ function lintSource(source, options) {
1166
1193
  );
1167
1194
  findings.push(..._hostApiUrlRules(source));
1168
1195
  findings.push(..._translationApiRules(source));
1196
+ findings.push(..._handBuiltPageUrlRules(source));
1169
1197
  findings.push(..._lucideIconRules(source));
1170
1198
  findings.push(..._reactInScopeRules(source));
1171
1199
  findings.push(..._imagePercentHeightRules(source));
package/dist/linter.js CHANGED
@@ -437,6 +437,49 @@ function _hostApiUrlRules(source) {
437
437
  return findings;
438
438
  }
439
439
 
440
+ // sc-5619 — no-hand-built-page-url.
441
+ //
442
+ // A published app answers on two URL shapes: `/play/<tenantId>/page/<slug>` on
443
+ // the platform host, and `/page/<slug>` at the workspace's own domain. The host
444
+ // owns that choice (`playerPath.js` on web, react-navigation natively), so a
445
+ // widget that builds the path itself — almost always from the `workspace.id` the
446
+ // SDK hands it — hardcodes ONE shape and breaks on the other: on a custom domain
447
+ // it rewrites the customer's clean domain back to the platform form and
448
+ // republishes their workspace id in the address bar.
449
+ //
450
+ // An `error` with no opt-out directive, like `no-external-translation-api`: the
451
+ // SDK covers every legitimate case, so there is no correct code to rescue.
452
+ // Navigate with `useNavigation().goTo(pageId)`, and follow a link whose shape
453
+ // the widget does NOT control (a datastore value, a notification link) with
454
+ // `useNavigation().openLink(link)` — that routes through the shared resolver,
455
+ // which already accepts either shape and rebuilds the one this host uses.
456
+ const PAGE_URL_RE = /["'`][^"'`]*\/play\/|\/play\/\$\{/;
457
+
458
+ function _handBuiltPageUrlRules(source) {
459
+ const findings = [];
460
+ // Strings kept, comments blanked: the literal lives IN a string, but a comment
461
+ // or doc line quoting `/play/<id>` must not block a publish.
462
+ const lines = _stripNonCode(source, { keepStrings: true }).split(/\r?\n/);
463
+ for (let i = 0; i < lines.length; i += 1) {
464
+ if (!PAGE_URL_RE.test(lines[i])) continue;
465
+ findings.push({
466
+ rule: "no-hand-built-page-url",
467
+ severity: "error",
468
+ label:
469
+ `source builds a "/play/…" app URL by hand — the URL shape belongs ` +
470
+ `to the host, not the widget, so a hardcoded one breaks on a ` +
471
+ `workspace's own domain (where a page is "/page/<slug>", with no ` +
472
+ `workspace id) and leaks the workspace id into the address bar. ` +
473
+ `Navigate with useNavigation().goTo(pageId) — the page id comes from ` +
474
+ `a pageRef prop — and follow a link you did not build with ` +
475
+ `useNavigation().openLink(link).`,
476
+ line: i + 1,
477
+ snippet: lines[i].trim().slice(0, 200),
478
+ });
479
+ }
480
+ return findings;
481
+ }
482
+
440
483
  // sc-4085 — no-external-translation-api.
441
484
  //
442
485
  // Translation is a platform capability, not a third-party API: `useTranslate()`
@@ -1327,6 +1370,7 @@ export function lintSource(source, options) {
1327
1370
  // REQ-WSDK-PLATFORM §3.5: soft host-API URL warning (does not block).
1328
1371
  findings.push(..._hostApiUrlRules(source));
1329
1372
  findings.push(..._translationApiRules(source));
1373
+ findings.push(..._handBuiltPageUrlRules(source));
1330
1374
  findings.push(..._lucideIconRules(source));
1331
1375
  // sc-2353 — widget source must be self-contained (reference React ⇒ import it).
1332
1376
  findings.push(..._reactInScopeRules(source));
@@ -0,0 +1,151 @@
1
+ // sc-5717 — the codec for a widget's own internal navigation state.
2
+ //
3
+ // A widget's internal position (the folder a file browser is in, a list's sort
4
+ // and search) lived in bare `useState`, so it was neither linkable nor durable:
5
+ // a reload dropped the visitor back at the widget's initial view. `useWidgetRoute`
6
+ // gives that state a home the host persists — the URL on web, the screen's route
7
+ // params on native.
8
+ //
9
+ // Everything here is HOST plumbing. A widget never sees a key or an encoded
10
+ // string: it reads and writes a plain object, and the host decides how that
11
+ // object is carried. Widgets cannot touch the URL at all (`window.location` is
12
+ // in CONTRACT.bannedApis), which is exactly why the codec lives on this side.
13
+ //
14
+ // One implementation for both hosts. The web Player imports it from
15
+ // `@colixsystems/widget-sdk/host`; the generated Expo app imports the SAME
16
+ // module from the SAME entry point, so there is no mirrored copy to drift
17
+ // (CLAUDE.md §3/§8).
18
+
19
+ /** Query-string / route-param key prefix. */
20
+ export const WIDGET_ROUTE_PREFIX = "w_";
21
+
22
+ /**
23
+ * Cap on ONE instance's encoded bag.
24
+ *
25
+ * Browsers stop being reliable somewhere around 2000 characters of URL, and a
26
+ * page may hold several widgets that each store state. Capping per instance
27
+ * keeps one widget from spending the whole budget; an over-cap write is dropped
28
+ * rather than truncated, because half a JSON document decodes to nothing and
29
+ * would silently reset the widget instead of just failing to persist.
30
+ */
31
+ export const WIDGET_ROUTE_MAX_CHARS = 512;
32
+
33
+ /** The key one placed widget instance stores its route bag under. */
34
+ export function widgetRouteKey(instanceId) {
35
+ return `${WIDGET_ROUTE_PREFIX}${instanceId}`;
36
+ }
37
+
38
+ /** Whether a raw query-string key belongs to a widget rather than the page. */
39
+ export function isWidgetRouteKey(key) {
40
+ return typeof key === "string" && key.startsWith(WIDGET_ROUTE_PREFIX);
41
+ }
42
+
43
+ /**
44
+ * How many entries one array value may carry. Paths and step lists are short;
45
+ * the length cap rejects an abusive value before the character cap has to.
46
+ */
47
+ export const WIDGET_ROUTE_MAX_ITEMS = 64;
48
+
49
+ function isScalar(value) {
50
+ const type = typeof value;
51
+ if (type === "string" || type === "boolean") return true;
52
+ return type === "number" && Number.isFinite(value);
53
+ }
54
+
55
+ /**
56
+ * A scalar, or a flat array of them.
57
+ *
58
+ * Not a general serializer on purpose. The bag round-trips through a URL a
59
+ * visitor can edit, so the shape is bounded to what the host can validate
60
+ * cheaply and completely. Arrays earn their place because the common
61
+ * "where am I" state IS a path — a file browser's open folders, a wizard's
62
+ * visited steps — and modelling that as a scalar would push widgets into
63
+ * encoding their own delimited strings, which is the codec this module exists
64
+ * to be.
65
+ */
66
+ function isStorableValue(value) {
67
+ if (Array.isArray(value)) {
68
+ return value.length <= WIDGET_ROUTE_MAX_ITEMS && value.every(isScalar);
69
+ }
70
+ return isScalar(value);
71
+ }
72
+
73
+ // Value equality for "is this key still at its declared default" lives in
74
+ // contract.js, so the hook and this codec share one rule; re-exported here so a
75
+ // host has the whole route surface from one import.
76
+ export { sameWidgetRouteValue } from "./contract.js";
77
+
78
+ /**
79
+ * Encode a widget's route bag for the URL.
80
+ *
81
+ * `null` / `undefined` values are DROPPED rather than stored. That is what makes
82
+ * "back to the initial view" produce a clean URL instead of `?w_…={"folder":null}`
83
+ * — the hook resolves an absent key to the widget's declared initial value, so
84
+ * absence and default are the same state by construction.
85
+ *
86
+ * @returns {string} the encoded bag, or "" when there is nothing to store (the
87
+ * caller removes the key) or the bag exceeds WIDGET_ROUTE_MAX_CHARS.
88
+ */
89
+ export function encodeWidgetRoute(bag) {
90
+ if (!bag || typeof bag !== "object" || Array.isArray(bag)) return "";
91
+ const storable = {};
92
+ let count = 0;
93
+ for (const key of Object.keys(bag).sort()) {
94
+ const value = bag[key];
95
+ if (value === undefined || value === null) continue;
96
+ if (!isStorableValue(value)) continue;
97
+ storable[key] = value;
98
+ count += 1;
99
+ }
100
+ if (count === 0) return "";
101
+ // Keys are sorted above so an unchanged bag always encodes to the same string
102
+ // — the host compares encodings to decide whether the URL needs rewriting,
103
+ // and key order must not make an identical state look like a change.
104
+ const encoded = JSON.stringify(storable);
105
+ return encoded.length > WIDGET_ROUTE_MAX_CHARS ? "" : encoded;
106
+ }
107
+
108
+ /**
109
+ * Decode what the URL carried back into a bag.
110
+ *
111
+ * Deliberately total: the input is whatever a visitor left in the address bar,
112
+ * so every malformed shape — bad JSON, an array, a nested object, a NaN —
113
+ * resolves to "no stored state" and the widget renders its initial view. It
114
+ * never throws, and it never yields a value a widget could not have written.
115
+ */
116
+ export function decodeWidgetRoute(raw) {
117
+ if (typeof raw !== "string" || raw === "") return {};
118
+ if (raw.length > WIDGET_ROUTE_MAX_CHARS) return {};
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(raw);
122
+ } catch {
123
+ return {};
124
+ }
125
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
126
+ const out = {};
127
+ for (const key of Object.keys(parsed)) {
128
+ const value = parsed[key];
129
+ if (!isStorableValue(value)) continue;
130
+ // A copy, so a widget mutating the array it read cannot reach back into
131
+ // whatever the host is holding.
132
+ out[key] = Array.isArray(value) ? value.slice() : value;
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * Collect every widget's stored bag out of a raw query-string map, keyed by
139
+ * instance id. The host calls this once per navigation and hands each widget
140
+ * only its own slice.
141
+ */
142
+ export function collectWidgetRoutes(rawParams) {
143
+ const out = {};
144
+ if (!rawParams || typeof rawParams !== "object") return out;
145
+ for (const key of Object.keys(rawParams)) {
146
+ if (!isWidgetRouteKey(key)) continue;
147
+ const instanceId = key.slice(WIDGET_ROUTE_PREFIX.length);
148
+ if (instanceId) out[instanceId] = decodeWidgetRoute(rawParams[key]);
149
+ }
150
+ return out;
151
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.100.0",
3
+ "version": "0.102.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-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-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__/navigation-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 src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.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-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-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__/navigation-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 src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"