@colixsystems/widget-sdk 0.99.0 → 0.101.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
@@ -99,6 +99,42 @@ Host-integration surface only: no author-facing hook, prop, primitive, or manife
99
99
 
100
100
  Host-integration surface only: no author-facing hook, prop, primitive, or manifest field changed. `CONTRACT.version` → `1.66.0`.
101
101
 
102
+ ### What's new in 0.101.0 (contract 1.75.0)
103
+
104
+ **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`:
105
+
106
+ ```js
107
+ const { files } = useFilestoreFiles({ spaceType: 'public' });
108
+ // a 128px grid downloads 128px images, not the 4096px originals
109
+ files.map((f) => <Image key={f.id} source={{ uri: f.urls.thumbnail }} />);
110
+
111
+ const { url, urls } = useFilestoreFile(fileId);
112
+ <Image source={{ uri: urls?.large }} />; // a detail view
113
+ <a href={url}>Download original</a>; // the full-size bytes
114
+ ```
115
+
116
+ 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`.
117
+
118
+ `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.
119
+
120
+ `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`.
121
+
122
+ 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.
123
+
124
+ ### What's new in 0.100.0 (contract 1.74.0)
125
+
126
+ **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:
127
+
128
+ ```js
129
+ const { upload } = useFilestoreUpload({ spaceType: 'personal', compress: false });
130
+ // …or per upload, which wins over the hook default:
131
+ await upload(file, { compress: false });
132
+ ```
133
+
134
+ Only raster images are ever compressed. SVG keeps its vector, and video, audio, and documents are stored verbatim under both values — an upload is never queued for background transcoding. The opt-out is the only value put on the wire, so the backend stays the single source of the default.
135
+
136
+ `CONTRACT.version` → `1.74.0`. Additive — existing callers are byte-for-byte unchanged on the wire.
137
+
102
138
  ### What's new in 0.98.0 (contract 1.70.0)
103
139
 
104
140
  **Three new hooks close the biggest gaps in the write-gating and query-authoring surface (sc-5206).**
@@ -637,10 +673,10 @@ Also: `useFileSignatures(fileIds)` is now **self-scoped** (the caller's own sign
637
673
  ### What's new in 0.30.0
638
674
 
639
675
  **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):
640
- - `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.
641
- - `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:*`.
676
+ - `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.
677
+ - `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:*`.
642
678
  - `useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })` → `{ folders, loading, error, refetch }` — the folder-navigation companion to `useFilestoreFiles`; pass `enabled:false` to suspend fetching.
643
- - `useFilestoreUpload({ spaceType, folderId? })` → `{ 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. Pair with the `<FilePicker>` primitive for the visible trigger. Requires the `files.write:*` scope.
679
+ - `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.
644
680
  - `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).
645
681
 
646
682
  `CONTRACT.version` → `1.20.0` (additive — no existing hook changed).
package/dist/contract.cjs CHANGED
@@ -746,7 +746,15 @@ const HOOKS = [
746
746
  "logged-out Player visitors. Reads ctx.filestore.files.list and unwraps " +
747
747
  "{ data, meta } to the files array. Every row carries a ready-to-render " +
748
748
  "`url` (its absolutized presigned_url, aliased by the filestore client) " +
749
- "so a gallery renders `files.map(f => f.url)` directly.",
749
+ "so a gallery renders `files.map(f => f.url)` directly. Every row ALSO " +
750
+ "carries `urls` — the delivery size ladder `{ thumbnail, card, large, " +
751
+ "hero }` at 128 / 512 / 1024 / 2048px longest edge. PREFER a rung over " +
752
+ "`url` whenever the image is rendered smaller than full size: a grid of " +
753
+ "thumbnails should read `f.urls.thumbnail`, a card list `f.urls.card`. " +
754
+ "Pick the next rung UP from the rendered size so a 2x/3x screen still " +
755
+ "has enough pixels. `urls` is always fully populated — a type with no " +
756
+ "ladder (SVG, GIF, a PDF) points every rung at the original — so it " +
757
+ "never needs a fallback branch.",
750
758
  returnShape: {
751
759
  files: "FilestoreFile[]",
752
760
  loading: "boolean",
@@ -773,10 +781,14 @@ const HOOKS = [
773
781
  "stays null for an empty id), so `file.url` throws on the first render. " +
774
782
  "Every file record the client returns does carry the same `url` alias, " +
775
783
  "so `file.url` is correct once you have null-checked `file`. " +
784
+ "`file.urls` carries the size ladder `{ thumbnail, card, large, hero }` " +
785
+ "(128 / 512 / 1024 / 2048px longest edge) — reach for a rung whenever " +
786
+ "the image renders smaller than full size, picking the next rung UP. " +
776
787
  "NEVER build a file URL by hand from an id — no route serves one.",
777
788
  returnShape: {
778
789
  file: "FilestoreFile | null",
779
790
  url: "string | null",
791
+ urls: "{ thumbnail, card, large, hero } | null",
780
792
  loading: "boolean",
781
793
  error: "Error | null",
782
794
  refetch: "() => Promise<void>",
@@ -786,16 +798,21 @@ const HOOKS = [
786
798
  },
787
799
  {
788
800
  name: "useFilestoreUpload",
789
- signature: "useFilestoreUpload({ spaceType, folderId? })",
801
+ signature: "useFilestoreUpload({ spaceType, folderId?, compress? })",
790
802
  description:
791
803
  "Upload a file into the end-user's Filestore space. The widget passes " +
792
804
  "the SPACE (`{ spaceType, folderId? }`); the hook resolves owner_id " +
793
805
  "from the host context, builds a multipart FormData with the " +
794
806
  "snake_case fields the backend reads verbatim (`space_type`, " +
795
807
  "`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
796
- "ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
797
- "to the created file row or throws the wire error; a 404 means the " +
798
- "destination folder denied a write (REQ-FSH canWrite gate). " +
808
+ "ctx.filestore.files.upload. `upload(file, { folderId?, compress? })` " +
809
+ "resolves to the created file row or throws the wire error; a 404 " +
810
+ "means the destination folder denied a write (REQ-FSH canWrite gate). " +
811
+ "Uploaded images are compressed to WebP by the backend; pass " +
812
+ "`compress: false` (on the hook or per upload) to store the file " +
813
+ "byte-for-byte as the user provided it — use that whenever the " +
814
+ "ORIGINAL matters (a document archive, a photo the user re-downloads, " +
815
+ "anything with an exact-bytes requirement). " +
799
816
  "ALWAYS pass spaceType explicitly — omitting it falls back to " +
800
817
  "'project', which is unreadable by a logged-out visitor. Choose it by " +
801
818
  "who must SEE the file: 'public' for content the app displays to " +
@@ -803,7 +820,7 @@ const HOOKS = [
803
820
  "'project' for content restricted to signed-in workspace users, " +
804
821
  "'personal' for a file private to the uploading app user.",
805
822
  returnShape: {
806
- upload: "(file, { folderId? }) => Promise<FilestoreFile>",
823
+ upload: "(file, { folderId?, compress? }) => Promise<FilestoreFile>",
807
824
  uploading: "boolean",
808
825
  error: "Error | null",
809
826
  lastUploaded: "FilestoreFile | null",
@@ -3199,7 +3216,12 @@ const CONTRACT = deepFreeze({
3199
3216
  // `ctx.datastore.myPermissions` client method as a write-permission
3200
3217
  // FLOOR, replacing the previous prompt-only "read useUser().groupIds /
3201
3218
  // .roles" guidance. No existing export changed signature.
3202
- version: "1.73.0",
3219
+ // 1.74.0: additive (sc-5402) — `useFilestoreUpload` accepts `compress`, on
3220
+ // the hook options and per `upload(file, { compress })`. Uploaded images
3221
+ // are compressed to WebP by default; `compress: false` stores the file
3222
+ // byte-for-byte. Existing callers are unaffected — the field is only sent
3223
+ // when the opt-out is chosen.
3224
+ version: "1.75.0",
3203
3225
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3204
3226
  hooks: HOOKS,
3205
3227
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -746,7 +746,15 @@ const HOOKS = [
746
746
  "logged-out Player visitors. Reads ctx.filestore.files.list and unwraps " +
747
747
  "{ data, meta } to the files array. Every row carries a ready-to-render " +
748
748
  "`url` (its absolutized presigned_url, aliased by the filestore client) " +
749
- "so a gallery renders `files.map(f => f.url)` directly.",
749
+ "so a gallery renders `files.map(f => f.url)` directly. Every row ALSO " +
750
+ "carries `urls` — the delivery size ladder `{ thumbnail, card, large, " +
751
+ "hero }` at 128 / 512 / 1024 / 2048px longest edge. PREFER a rung over " +
752
+ "`url` whenever the image is rendered smaller than full size: a grid of " +
753
+ "thumbnails should read `f.urls.thumbnail`, a card list `f.urls.card`. " +
754
+ "Pick the next rung UP from the rendered size so a 2x/3x screen still " +
755
+ "has enough pixels. `urls` is always fully populated — a type with no " +
756
+ "ladder (SVG, GIF, a PDF) points every rung at the original — so it " +
757
+ "never needs a fallback branch.",
750
758
  returnShape: {
751
759
  files: "FilestoreFile[]",
752
760
  loading: "boolean",
@@ -773,10 +781,14 @@ const HOOKS = [
773
781
  "stays null for an empty id), so `file.url` throws on the first render. " +
774
782
  "Every file record the client returns does carry the same `url` alias, " +
775
783
  "so `file.url` is correct once you have null-checked `file`. " +
784
+ "`file.urls` carries the size ladder `{ thumbnail, card, large, hero }` " +
785
+ "(128 / 512 / 1024 / 2048px longest edge) — reach for a rung whenever " +
786
+ "the image renders smaller than full size, picking the next rung UP. " +
776
787
  "NEVER build a file URL by hand from an id — no route serves one.",
777
788
  returnShape: {
778
789
  file: "FilestoreFile | null",
779
790
  url: "string | null",
791
+ urls: "{ thumbnail, card, large, hero } | null",
780
792
  loading: "boolean",
781
793
  error: "Error | null",
782
794
  refetch: "() => Promise<void>",
@@ -786,16 +798,21 @@ const HOOKS = [
786
798
  },
787
799
  {
788
800
  name: "useFilestoreUpload",
789
- signature: "useFilestoreUpload({ spaceType, folderId? })",
801
+ signature: "useFilestoreUpload({ spaceType, folderId?, compress? })",
790
802
  description:
791
803
  "Upload a file into the end-user's Filestore space. The widget passes " +
792
804
  "the SPACE (`{ spaceType, folderId? }`); the hook resolves owner_id " +
793
805
  "from the host context, builds a multipart FormData with the " +
794
806
  "snake_case fields the backend reads verbatim (`space_type`, " +
795
807
  "`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
796
- "ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
797
- "to the created file row or throws the wire error; a 404 means the " +
798
- "destination folder denied a write (REQ-FSH canWrite gate). " +
808
+ "ctx.filestore.files.upload. `upload(file, { folderId?, compress? })` " +
809
+ "resolves to the created file row or throws the wire error; a 404 " +
810
+ "means the destination folder denied a write (REQ-FSH canWrite gate). " +
811
+ "Uploaded images are compressed to WebP by the backend; pass " +
812
+ "`compress: false` (on the hook or per upload) to store the file " +
813
+ "byte-for-byte as the user provided it — use that whenever the " +
814
+ "ORIGINAL matters (a document archive, a photo the user re-downloads, " +
815
+ "anything with an exact-bytes requirement). " +
799
816
  "ALWAYS pass spaceType explicitly — omitting it falls back to " +
800
817
  "'project', which is unreadable by a logged-out visitor. Choose it by " +
801
818
  "who must SEE the file: 'public' for content the app displays to " +
@@ -803,7 +820,7 @@ const HOOKS = [
803
820
  "'project' for content restricted to signed-in workspace users, " +
804
821
  "'personal' for a file private to the uploading app user.",
805
822
  returnShape: {
806
- upload: "(file, { folderId? }) => Promise<FilestoreFile>",
823
+ upload: "(file, { folderId?, compress? }) => Promise<FilestoreFile>",
807
824
  uploading: "boolean",
808
825
  error: "Error | null",
809
826
  lastUploaded: "FilestoreFile | null",
@@ -3199,7 +3216,12 @@ const CONTRACT = deepFreeze({
3199
3216
  // `ctx.datastore.myPermissions` client method as a write-permission
3200
3217
  // FLOOR, replacing the previous prompt-only "read useUser().groupIds /
3201
3218
  // .roles" guidance. No existing export changed signature.
3202
- version: "1.73.0",
3219
+ // 1.74.0: additive (sc-5402) — `useFilestoreUpload` accepts `compress`, on
3220
+ // the hook options and per `upload(file, { compress })`. Uploaded images
3221
+ // are compressed to WebP by default; `compress: false` stores the file
3222
+ // byte-for-byte. Existing callers are unaffected — the field is only sent
3223
+ // when the opt-out is chosen.
3224
+ version: "1.75.0",
3203
3225
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3204
3226
  hooks: HOOKS,
3205
3227
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -2768,7 +2768,11 @@ export function useFilestoreFile(fileId) {
2768
2768
 
2769
2769
  const url =
2770
2770
  file && typeof file.presigned_url === "string" ? file.presigned_url : null;
2771
- return { file, url, loading, error, refetch };
2771
+ // REQ-FS-15: the size ladder, surfaced beside `url` for the same reason —
2772
+ // `file` is null until the fetch resolves, so `file.urls` throws on first
2773
+ // render. Null until then, exactly like `url`.
2774
+ const urls = file && file.urls && typeof file.urls === "object" ? file.urls : null;
2775
+ return { file, url, urls, loading, error, refetch };
2772
2776
  }
2773
2777
 
2774
2778
  /**
@@ -2801,7 +2805,11 @@ export function useFilestoreUpload(options) {
2801
2805
  "useFilestoreUpload: host did not inject a filestore client (ctx.filestore.files.upload)",
2802
2806
  );
2803
2807
  }
2804
- const { spaceType = "project", folderId: defaultFolderId = null } = options || {};
2808
+ const {
2809
+ spaceType = "project",
2810
+ folderId: defaultFolderId = null,
2811
+ compress: defaultCompress = true,
2812
+ } = options || {};
2805
2813
  const ownerId = _filestoreOwnerId(ctx, spaceType);
2806
2814
 
2807
2815
  const [uploading, setUploading] = useState(false);
@@ -2823,10 +2831,17 @@ export function useFilestoreUpload(options) {
2823
2831
  overrides && Object.prototype.hasOwnProperty.call(overrides, "folderId")
2824
2832
  ? overrides.folderId
2825
2833
  : defaultFolderId;
2834
+ const compress =
2835
+ overrides && Object.prototype.hasOwnProperty.call(overrides, "compress")
2836
+ ? overrides.compress
2837
+ : defaultCompress;
2826
2838
  const form = new FormData();
2827
2839
  form.append("space_type", String(spaceType || "project").toUpperCase());
2828
2840
  form.append("owner_id", ownerId);
2829
2841
  if (folderId) form.append("folder_id", folderId);
2842
+ // sc-5402: only the opt-out is sent, so the backend stays the single
2843
+ // source of the default (it compresses images to WebP).
2844
+ if (compress === false) form.append("compress", "false");
2830
2845
  form.append("file", file);
2831
2846
  setUploading(true);
2832
2847
  setError(null);
@@ -2841,7 +2856,7 @@ export function useFilestoreUpload(options) {
2841
2856
  throw err;
2842
2857
  }
2843
2858
  },
2844
- [ownerId, defaultFolderId, spaceType],
2859
+ [ownerId, defaultFolderId, defaultCompress, spaceType],
2845
2860
  );
2846
2861
 
2847
2862
  return { upload, uploading, error, lastUploaded };
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.99.0",
3
+ "version": "0.101.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"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"