@colixsystems/widget-sdk 0.45.0 → 0.47.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
@@ -49,7 +49,24 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
49
49
 
50
50
  ## Status
51
51
 
52
- `v0.45.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
52
+ `v0.47.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
53
+
54
+ ### What's new in 0.47.0
55
+
56
+ **`<FilePicker>` native variant is real (sc-1378 follow-up).** The Expo-export shell of `<FilePicker>` now wraps the vetted `expo-document-picker` and reports `FilePicker.isSupported = true`. Result: the Files widget's `allowUpload` toggle works on **both** the web Player and the native Expo export — no more disabled stub on mobile. `expo-document-picker` is added to `CONTRACT.vettedImports` (`platforms: ["native"]`); the compiler's `generatePackageJson` was already pinning it for the export build, so no new export-pin work was needed. `useFilestoreUpload` accepts whatever the host's FormData reads as a binary part — a browser `File` on web, the React Native `{ uri, name, type }` shape from the picker on native — so the same hook code path feeds the multipart on both platforms. `CONTRACT.version` → `1.34.0`, additive.
57
+
58
+ ### What's new in 0.46.0
59
+
60
+ **End-user upload primitive + hook (sc-1378).** Widgets can now let an end user upload a file to the Filestore from the published app. Two pieces ship together:
61
+
62
+ - **New SDK hook `useFilestoreUpload({ spaceType, folderId? })`.** Resolves `owner_id` the same way the read filestore hooks do, builds a multipart `FormData` with the snake_case fields the backend reads verbatim (`space_type`, `owner_id`, `folder_id`, plus the binary `file`), and POSTs through `ctx.filestore.files.upload`. Returns `{ upload(file, { folderId? }), uploading, error, lastUploaded }`. A 404 from the backend means the destination folder denied a write (REQ-FSH `canWrite` gate).
63
+ - **New SDK primitive `<FilePicker accept onPick>`.** Web wraps a hidden DOM `<input type="file">` (children render as the visible click target inside a `<label>` so the click reaches the file dialog without ref plumbing). Native renders a disabled trigger and exposes `FilePicker.isSupported = false` until a vetted Expo picker pin lands.
64
+ - **Files widget `allowUpload`.** The built-in Files widget grew an `Allow uploading files` toggle (manifest v2.2.0). When enabled, the toolbar renders an Upload trigger that pipes the picked file through the new hook into the currently-open folder; the `typeFilter` setting narrows the `accept` MIME so an "Images" filter shows only images in the OS dialog.
65
+ - **`CONTRACT.version` → `1.33.0`.** Additive — new hook + new primitive; no existing hook, primitive, manifest field, or token changed shape.
66
+
67
+ ### What's new in 0.45.1
68
+
69
+ **Fix `lucide-unknown-icon` false positive across adjacent imports (sc-1373).** The rule's import regex matched the brace block lazily (`[\s\S]*?`), so when another braced import preceded the lucide one — e.g. `import { View, Text, Pressable } from "react-native"` then `import { Sparkles } from "lucide-react-native"` — the capture spanned both and validated the `react-native` names (`Pressable`, …) as lucide icons, blocking a near-universal widget pattern. The capture is now `[^}]*`, which cannot cross a `}` into a neighbouring import. Fix-only; the rule's intent and the committed name set are unchanged.
53
70
 
54
71
  ### What's new in 0.45.0
55
72
 
@@ -147,6 +164,7 @@ Also: `useFileSignatures(fileIds)` is now **self-scoped** (the caller's own sign
147
164
  **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):
148
165
  - `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.
149
166
  - `useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })` → `{ folders, loading, error, refetch }` — the folder-navigation companion to `useFilestoreFiles`; pass `enabled:false` to suspend fetching.
167
+ - `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.
150
168
  - `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).
151
169
 
152
170
  `CONTRACT.version` → `1.20.0` (additive — no existing hook changed).
package/dist/contract.cjs CHANGED
@@ -214,6 +214,27 @@ const HOOKS = [
214
214
  requiredContextSlice: ["filestore.files"],
215
215
  scopes: ["files.read:*"],
216
216
  },
217
+ {
218
+ name: "useFilestoreUpload",
219
+ signature: "useFilestoreUpload({ spaceType, folderId? })",
220
+ description:
221
+ "Upload a file into the end-user's Filestore space. The widget passes " +
222
+ "the SPACE (`{ spaceType, folderId? }`); the hook resolves owner_id " +
223
+ "from the host context, builds a multipart FormData with the " +
224
+ "snake_case fields the backend reads verbatim (`space_type`, " +
225
+ "`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
226
+ "ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
227
+ "to the created file row or throws the wire error; a 404 means the " +
228
+ "destination folder denied a write (REQ-FSH canWrite gate).",
229
+ returnShape: {
230
+ upload: "(file, { folderId? }) => Promise<FilestoreFile>",
231
+ uploading: "boolean",
232
+ error: "Error | null",
233
+ lastUploaded: "FilestoreFile | null",
234
+ },
235
+ requiredContextSlice: ["filestore.files"],
236
+ scopes: ["files.write:*"],
237
+ },
217
238
  {
218
239
  name: "useFilestoreFolders",
219
240
  signature: "useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })",
@@ -710,6 +731,19 @@ const PRIMITIVES = [
710
731
  rnComponent: "@react-native-community/datetimepicker",
711
732
  docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
712
733
  },
734
+ // sc-1378 — `<FilePicker accept onPick>` lets a widget surface a native
735
+ // file-picker on the web Player so end users can upload files to the
736
+ // backend (paired with useFilestoreUpload or useAssets.upload). Web wraps
737
+ // a hidden DOM `<input type="file">`; native renders a disabled trigger
738
+ // and exposes `FilePicker.isSupported = false` until a vetted Expo
739
+ // picker pin lands.
740
+ {
741
+ name: "FilePicker",
742
+ description:
743
+ 'End-user file picker primitive. `<FilePicker accept="image/*" multiple={false} disabled={false} onPick={(file) => …}>…trigger…</FilePicker>`. Children render as the visible click target (a Pressable + Icon + Text is the common shape) and are wrapped in a hidden-input <label> so the click reaches the file dialog without any ref plumbing. Pair with useFilestoreUpload to upload the picked File to the end-user filestore. `FilePicker.isSupported` is `true` on the web Player and `false` on the native Expo export — widgets should branch on it to hide the trigger when uploads are not yet wired natively.',
744
+ rnComponent: null,
745
+ docsUrl: null,
746
+ },
713
747
  ];
714
748
 
715
749
  const CATEGORIES = [
@@ -1169,6 +1203,13 @@ const VETTED_IMPORTS = [
1169
1203
  description:
1170
1204
  "Canvas-style 2D/GPU drawing & animation (games, custom graphics) on native. Native-only — author it in widget.native.jsx and pair it with a web variant in widget.web.jsx (a <canvas> or react-native-svg). There is no Skia web build wired into the Player.",
1171
1205
  },
1206
+ {
1207
+ specifier: "expo-document-picker",
1208
+ platforms: ["native"],
1209
+ category: "files",
1210
+ description:
1211
+ "Native file picker used by the SDK's <FilePicker> primitive on the Expo export (web uses a hidden DOM <input type=\"file\">, mapped to the same { accept, onPick } contract). Most widgets reach <FilePicker> through the SDK rather than importing this directly; a widget that does import it goes in widget.native.jsx and is pinned by the compiler's generatePackageJson — Expo SDK 56 ships 56.0.x.",
1212
+ },
1172
1213
  {
1173
1214
  specifier: "lucide-react-native",
1174
1215
  platforms: ["web", "native"],
@@ -1681,7 +1722,29 @@ const CONTRACT = deepFreeze({
1681
1722
  // instance — one StyleSheet/context, no double-instance conflicts. The
1682
1723
  // vetted-import SET is unchanged (react-native was already vetted); only
1683
1724
  // its web resolution + description changed — minor bump.
1684
- version: "1.32.0",
1725
+ //
1726
+ // 1.33.0: additive (sc-1378) — `useFilestoreUpload({ spaceType, folderId? })`
1727
+ // posts a multipart upload to `ctx.filestore.files.upload` after the
1728
+ // same owner_id resolution the read filestore hooks use (tenant for
1729
+ // PROJECT, app user for PERSONAL). Paired with the new `<FilePicker>`
1730
+ // primitive — web wraps a hidden DOM `<input type="file">`, native
1731
+ // renders a disabled trigger and exposes `FilePicker.isSupported =
1732
+ // false` until a vetted Expo picker pin lands. Backs the Files widget's
1733
+ // new `allowUpload` toggle. No existing hook, primitive, manifest
1734
+ // field, or token changed shape — minor bump.
1735
+ //
1736
+ // 1.34.0: additive (sc-1378 follow-up) — `<FilePicker>` native variant
1737
+ // now wraps the vetted `expo-document-picker` and reports
1738
+ // `FilePicker.isSupported = true` on the Expo export (the build was
1739
+ // already pinning the package in `generatePackageJson`). Closes the
1740
+ // REQ-FW-03 native gap; the Files widget's Upload button now works on
1741
+ // both the web Player and the Expo export. New vetted entry
1742
+ // `expo-document-picker` (`platforms: ["native"]`). `useFilestoreUpload`
1743
+ // accepts both a browser `File` and the React Native `{ uri, name,
1744
+ // type }` shape the native picker returns, so the same hook code path
1745
+ // feeds the multipart in both hosts. No existing hook, primitive, or
1746
+ // manifest field changed shape — minor bump.
1747
+ version: "1.34.0",
1685
1748
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1686
1749
  hooks: HOOKS,
1687
1750
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -214,6 +214,27 @@ const HOOKS = [
214
214
  requiredContextSlice: ["filestore.files"],
215
215
  scopes: ["files.read:*"],
216
216
  },
217
+ {
218
+ name: "useFilestoreUpload",
219
+ signature: "useFilestoreUpload({ spaceType, folderId? })",
220
+ description:
221
+ "Upload a file into the end-user's Filestore space. The widget passes " +
222
+ "the SPACE (`{ spaceType, folderId? }`); the hook resolves owner_id " +
223
+ "from the host context, builds a multipart FormData with the " +
224
+ "snake_case fields the backend reads verbatim (`space_type`, " +
225
+ "`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
226
+ "ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
227
+ "to the created file row or throws the wire error; a 404 means the " +
228
+ "destination folder denied a write (REQ-FSH canWrite gate).",
229
+ returnShape: {
230
+ upload: "(file, { folderId? }) => Promise<FilestoreFile>",
231
+ uploading: "boolean",
232
+ error: "Error | null",
233
+ lastUploaded: "FilestoreFile | null",
234
+ },
235
+ requiredContextSlice: ["filestore.files"],
236
+ scopes: ["files.write:*"],
237
+ },
217
238
  {
218
239
  name: "useFilestoreFolders",
219
240
  signature: "useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })",
@@ -710,6 +731,19 @@ const PRIMITIVES = [
710
731
  rnComponent: "@react-native-community/datetimepicker",
711
732
  docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
712
733
  },
734
+ // sc-1378 — `<FilePicker accept onPick>` lets a widget surface a native
735
+ // file-picker on the web Player so end users can upload files to the
736
+ // backend (paired with useFilestoreUpload or useAssets.upload). Web wraps
737
+ // a hidden DOM `<input type="file">`; native renders a disabled trigger
738
+ // and exposes `FilePicker.isSupported = false` until a vetted Expo
739
+ // picker pin lands.
740
+ {
741
+ name: "FilePicker",
742
+ description:
743
+ 'End-user file picker primitive. `<FilePicker accept="image/*" multiple={false} disabled={false} onPick={(file) => …}>…trigger…</FilePicker>`. Children render as the visible click target (a Pressable + Icon + Text is the common shape) and are wrapped in a hidden-input <label> so the click reaches the file dialog without any ref plumbing. Pair with useFilestoreUpload to upload the picked File to the end-user filestore. `FilePicker.isSupported` is `true` on the web Player and `false` on the native Expo export — widgets should branch on it to hide the trigger when uploads are not yet wired natively.',
744
+ rnComponent: null,
745
+ docsUrl: null,
746
+ },
713
747
  ];
714
748
 
715
749
  const CATEGORIES = [
@@ -1169,6 +1203,13 @@ const VETTED_IMPORTS = [
1169
1203
  description:
1170
1204
  "Canvas-style 2D/GPU drawing & animation (games, custom graphics) on native. Native-only — author it in widget.native.jsx and pair it with a web variant in widget.web.jsx (a <canvas> or react-native-svg). There is no Skia web build wired into the Player.",
1171
1205
  },
1206
+ {
1207
+ specifier: "expo-document-picker",
1208
+ platforms: ["native"],
1209
+ category: "files",
1210
+ description:
1211
+ "Native file picker used by the SDK's <FilePicker> primitive on the Expo export (web uses a hidden DOM <input type=\"file\">, mapped to the same { accept, onPick } contract). Most widgets reach <FilePicker> through the SDK rather than importing this directly; a widget that does import it goes in widget.native.jsx and is pinned by the compiler's generatePackageJson — Expo SDK 56 ships 56.0.x.",
1212
+ },
1172
1213
  {
1173
1214
  specifier: "lucide-react-native",
1174
1215
  platforms: ["web", "native"],
@@ -1681,7 +1722,29 @@ const CONTRACT = deepFreeze({
1681
1722
  // instance — one StyleSheet/context, no double-instance conflicts. The
1682
1723
  // vetted-import SET is unchanged (react-native was already vetted); only
1683
1724
  // its web resolution + description changed — minor bump.
1684
- version: "1.32.0",
1725
+ //
1726
+ // 1.33.0: additive (sc-1378) — `useFilestoreUpload({ spaceType, folderId? })`
1727
+ // posts a multipart upload to `ctx.filestore.files.upload` after the
1728
+ // same owner_id resolution the read filestore hooks use (tenant for
1729
+ // PROJECT, app user for PERSONAL). Paired with the new `<FilePicker>`
1730
+ // primitive — web wraps a hidden DOM `<input type="file">`, native
1731
+ // renders a disabled trigger and exposes `FilePicker.isSupported =
1732
+ // false` until a vetted Expo picker pin lands. Backs the Files widget's
1733
+ // new `allowUpload` toggle. No existing hook, primitive, manifest
1734
+ // field, or token changed shape — minor bump.
1735
+ //
1736
+ // 1.34.0: additive (sc-1378 follow-up) — `<FilePicker>` native variant
1737
+ // now wraps the vetted `expo-document-picker` and reports
1738
+ // `FilePicker.isSupported = true` on the Expo export (the build was
1739
+ // already pinning the package in `generatePackageJson`). Closes the
1740
+ // REQ-FW-03 native gap; the Files widget's Upload button now works on
1741
+ // both the web Player and the Expo export. New vetted entry
1742
+ // `expo-document-picker` (`platforms: ["native"]`). `useFilestoreUpload`
1743
+ // accepts both a browser `File` and the React Native `{ uri, name,
1744
+ // type }` shape the native picker returns, so the same hook code path
1745
+ // feeds the multipart in both hosts. No existing hook, primitive, or
1746
+ // manifest field changed shape — minor bump.
1747
+ version: "1.34.0",
1685
1748
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1686
1749
  hooks: HOOKS,
1687
1750
  primitives: PRIMITIVES,
@@ -0,0 +1,93 @@
1
+ // sc-1378 — `<FilePicker>` SDK primitive (web implementation).
2
+ //
3
+ // React Native (and react-native-web) has no native equivalent of the
4
+ // browser's `<input type="file">`, so the web build renders the DOM input
5
+ // directly (the same trick the web DateTimePicker uses: `React.createElement
6
+ // ("input", ...)`). The input is visually hidden so authors style the
7
+ // trigger themselves — children are rendered inside a label so any
8
+ // Pressable / View / Text the widget passes in becomes the click target.
9
+ //
10
+ // Public contract (mirror in filepicker.native.js — keep them in lockstep):
11
+ // accept: string | undefined — same value as the DOM `accept`
12
+ // attribute. "image/*" / "image/png,image/jpeg" narrow the
13
+ // picker; omit / "" lets the OS picker show every file type.
14
+ // multiple: boolean — default false. When true, `onPick`
15
+ // fires once with an array of File objects.
16
+ // disabled: boolean — gates the click; renders a muted
17
+ // label.
18
+ // onPick: (file | File[]) => void
19
+ // children: ReactNode — the visible trigger (a Pressable +
20
+ // an icon + a label is the common shape). Wrapped in a
21
+ // `<label>` so the click reaches the hidden input
22
+ // everywhere — no manual ref / `.click()` plumbing needed.
23
+ // accessibilityLabel: string? — written to the wrapper label.
24
+
25
+ import React, { useRef, useCallback } from "react";
26
+
27
+ const HIDDEN_INPUT_STYLE = {
28
+ position: "absolute",
29
+ width: 1,
30
+ height: 1,
31
+ padding: 0,
32
+ margin: -1,
33
+ overflow: "hidden",
34
+ clip: "rect(0, 0, 0, 0)",
35
+ whiteSpace: "nowrap",
36
+ border: 0,
37
+ };
38
+
39
+ const LABEL_STYLE = {
40
+ display: "inline-flex",
41
+ cursor: "pointer",
42
+ };
43
+
44
+ const LABEL_STYLE_DISABLED = {
45
+ ...LABEL_STYLE,
46
+ cursor: "not-allowed",
47
+ opacity: 0.5,
48
+ };
49
+
50
+ export function FilePicker({
51
+ accept,
52
+ multiple = false,
53
+ disabled = false,
54
+ onPick,
55
+ children,
56
+ accessibilityLabel,
57
+ }) {
58
+ const inputRef = useRef(null);
59
+
60
+ const handleChange = useCallback(
61
+ (event) => {
62
+ const list = event && event.target && event.target.files;
63
+ if (!list || list.length === 0) return;
64
+ if (typeof onPick === "function") {
65
+ onPick(multiple ? Array.from(list) : list[0]);
66
+ }
67
+ // Reset the input so picking the SAME file twice in a row still fires
68
+ // onChange — DOM dedupes by value, the SDK contract does not.
69
+ event.target.value = "";
70
+ },
71
+ [onPick, multiple],
72
+ );
73
+
74
+ return React.createElement(
75
+ "label",
76
+ {
77
+ style: disabled ? LABEL_STYLE_DISABLED : LABEL_STYLE,
78
+ "aria-label": accessibilityLabel,
79
+ },
80
+ React.createElement("input", {
81
+ ref: inputRef,
82
+ type: "file",
83
+ accept: accept || undefined,
84
+ multiple,
85
+ disabled,
86
+ onChange: handleChange,
87
+ style: HIDDEN_INPUT_STYLE,
88
+ }),
89
+ children,
90
+ );
91
+ }
92
+
93
+ FilePicker.isSupported = true;
@@ -0,0 +1,100 @@
1
+ // sc-1378 — `<FilePicker>` SDK primitive (native — Expo export).
2
+ //
3
+ // Wraps `expo-document-picker` (a vetted import, native-only; pinned by
4
+ // the compiler's generatePackageJson at expo-document-picker ~56.0.x).
5
+ // The web variant in ./filepicker.js wraps a hidden DOM
6
+ // `<input type="file">` because react-native-web has no equivalent of the
7
+ // system file picker; this native variant calls the OS document picker
8
+ // directly. Both honour the same public contract — `accept` / `multiple`
9
+ // / `disabled` / `onPick` / `children` / `accessibilityLabel`.
10
+ //
11
+ // `onPick` receives the same shape on both platforms:
12
+ // - web → the browser's `File` object (a `Blob` with name + type).
13
+ // - native → `{ uri, name, type, size }` — React Native's
14
+ // FormData-friendly file part. `useFilestoreUpload` appends whichever
15
+ // verbatim into the multipart body; the React Native fetch
16
+ // implementation reads `uri` and streams the bytes.
17
+
18
+ import React, { useCallback, useMemo } from "react";
19
+ import { Pressable, View } from "react-native";
20
+ // eslint-disable-next-line no-restricted-syntax
21
+ import { getDocumentAsync } from "expo-document-picker";
22
+
23
+ // Map an HTML `accept` string (the web contract — "image/*", "audio/*",
24
+ // "image/png,image/jpeg", …) to expo-document-picker's `type` arg, which
25
+ // accepts either a single MIME string or an array of MIME strings. We
26
+ // pass the array form unchanged so a comma-separated list narrows the
27
+ // same way it does on web. An empty / falsy accept maps to "*/*"
28
+ // (DocumentPicker's wide-open default — every file type is offered).
29
+ function _mapAccept(accept) {
30
+ if (!accept || typeof accept !== "string") return "*/*";
31
+ const parts = accept
32
+ .split(",")
33
+ .map((s) => s.trim())
34
+ .filter(Boolean);
35
+ if (parts.length === 0) return "*/*";
36
+ if (parts.length === 1) return parts[0];
37
+ return parts;
38
+ }
39
+
40
+ // expo-document-picker returns `{ canceled, assets: [{ uri, name,
41
+ // mimeType, size }] }`. Normalise each asset to the FormData-friendly
42
+ // `{ uri, name, type, size }` shape useFilestoreUpload appends into the
43
+ // multipart body — `type` is the field React Native's FormData reads
44
+ // for the part's content-type, mirroring the browser File's `.type`.
45
+ function _asset(picked) {
46
+ return {
47
+ uri: picked.uri,
48
+ name: picked.name || "upload",
49
+ type: picked.mimeType || "application/octet-stream",
50
+ size: typeof picked.size === "number" ? picked.size : undefined,
51
+ };
52
+ }
53
+
54
+ export function FilePicker({
55
+ accept,
56
+ multiple = false,
57
+ disabled = false,
58
+ onPick,
59
+ children,
60
+ accessibilityLabel,
61
+ }) {
62
+ const type = useMemo(() => _mapAccept(accept), [accept]);
63
+
64
+ const handlePress = useCallback(async () => {
65
+ if (disabled) return;
66
+ let result;
67
+ try {
68
+ result = await getDocumentAsync({
69
+ type,
70
+ multiple,
71
+ copyToCacheDirectory: true,
72
+ });
73
+ } catch {
74
+ return;
75
+ }
76
+ if (!result || result.canceled) return;
77
+ const assets = Array.isArray(result.assets) ? result.assets : [];
78
+ if (assets.length === 0) return;
79
+ if (typeof onPick !== "function") return;
80
+ if (multiple) {
81
+ onPick(assets.map(_asset));
82
+ } else {
83
+ onPick(_asset(assets[0]));
84
+ }
85
+ }, [disabled, type, multiple, onPick]);
86
+
87
+ return React.createElement(
88
+ Pressable,
89
+ {
90
+ onPress: handlePress,
91
+ disabled,
92
+ accessibilityRole: "button",
93
+ accessibilityLabel,
94
+ accessibilityState: { disabled },
95
+ },
96
+ React.createElement(View, { style: disabled ? { opacity: 0.5 } : undefined }, children),
97
+ );
98
+ }
99
+
100
+ FilePicker.isSupported = true;
package/dist/hooks.js CHANGED
@@ -1364,6 +1364,82 @@ export function useFilestoreFiles(options) {
1364
1364
  return { files, loading, error, refetch };
1365
1365
  }
1366
1366
 
1367
+ /**
1368
+ * sc-1378 — upload a file into the end-user's Filestore space. Returns
1369
+ * `{ upload, uploading, error, lastUploaded }`. The widget passes the
1370
+ * SPACE (`{ spaceType, folderId? }`); the hook resolves `owner_id` the
1371
+ * same way the read hooks do (tenant for PROJECT, app user for PERSONAL)
1372
+ * and posts a multipart FormData to `ctx.filestore.files.upload`.
1373
+ *
1374
+ * `upload(file, { folderId? })` accepts whatever the host's FormData
1375
+ * implementation reads as a binary file part — on the web Player a
1376
+ * browser `File`, on the Expo export the React Native shape
1377
+ * `{ uri, name, type }` returned by `<FilePicker>` (which wraps
1378
+ * `expo-document-picker` natively and a hidden DOM `<input type="file">`
1379
+ * on web). FormData serialises both verbatim, so the same hook code path
1380
+ * feeds the multipart on both platforms.
1381
+ *
1382
+ * A 404 from the backend means the destination folder denied a write —
1383
+ * the widget should surface a friendly "you can't upload here" message
1384
+ * rather than the raw error.
1385
+ */
1386
+ export function useFilestoreUpload(options) {
1387
+ const ctx = useWidgetContextOrThrow("useFilestoreUpload");
1388
+ if (
1389
+ !ctx.filestore ||
1390
+ !ctx.filestore.files ||
1391
+ typeof ctx.filestore.files.upload !== "function"
1392
+ ) {
1393
+ throw new Error(
1394
+ "useFilestoreUpload: host did not inject a filestore client (ctx.filestore.files.upload)",
1395
+ );
1396
+ }
1397
+ const { spaceType = "project", folderId: defaultFolderId = null } = options || {};
1398
+ const ownerId = _filestoreOwnerId(ctx, spaceType);
1399
+
1400
+ const [uploading, setUploading] = useState(false);
1401
+ const [error, setError] = useState(null);
1402
+ const [lastUploaded, setLastUploaded] = useState(null);
1403
+
1404
+ const filesRef = useRef(ctx.filestore.files);
1405
+ filesRef.current = ctx.filestore.files;
1406
+
1407
+ const upload = useCallback(
1408
+ async (file, overrides) => {
1409
+ if (!file) throw new Error("useFilestoreUpload: file is required");
1410
+ if (!ownerId) {
1411
+ const err = new Error("Sign in to upload");
1412
+ setError(err);
1413
+ throw err;
1414
+ }
1415
+ const folderId =
1416
+ overrides && Object.prototype.hasOwnProperty.call(overrides, "folderId")
1417
+ ? overrides.folderId
1418
+ : defaultFolderId;
1419
+ const form = new FormData();
1420
+ form.append("space_type", String(spaceType || "project").toUpperCase());
1421
+ form.append("owner_id", ownerId);
1422
+ if (folderId) form.append("folder_id", folderId);
1423
+ form.append("file", file);
1424
+ setUploading(true);
1425
+ setError(null);
1426
+ try {
1427
+ const created = await filesRef.current.upload(form);
1428
+ setLastUploaded(created || null);
1429
+ setUploading(false);
1430
+ return created;
1431
+ } catch (err) {
1432
+ setError(err);
1433
+ setUploading(false);
1434
+ throw err;
1435
+ }
1436
+ },
1437
+ [ownerId, defaultFolderId, spaceType],
1438
+ );
1439
+
1440
+ return { upload, uploading, error, lastUploaded };
1441
+ }
1442
+
1367
1443
  /**
1368
1444
  * Browse the end-user's Filestore folders. Returns { folders, loading, error,
1369
1445
  * refetch }. Mirrors useFilestoreFiles for subfolder navigation: the widget
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  useAsset,
18
18
  useAssetsByTag,
19
19
  useFilestoreFiles,
20
+ useFilestoreUpload,
20
21
  useFilestoreFolders,
21
22
  useFileSignature,
22
23
  useFileSignatures,
@@ -61,6 +62,7 @@ export {
61
62
  Linking,
62
63
  Icon,
63
64
  DateTimePicker,
65
+ FilePicker,
64
66
  } from "./primitives.js";
65
67
  export { lintSource, bannedIdentifiers } from "./linter.js";
66
68
  export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
@@ -17,6 +17,7 @@ export {
17
17
  useAsset,
18
18
  useAssetsByTag,
19
19
  useFilestoreFiles,
20
+ useFilestoreUpload,
20
21
  useFilestoreFolders,
21
22
  useFileSignature,
22
23
  useFileSignatures,
@@ -57,6 +58,7 @@ export {
57
58
  Linking,
58
59
  Icon,
59
60
  DateTimePicker,
61
+ FilePicker,
60
62
  } from "./primitives.native.js";
61
63
  export { lintSource, bannedIdentifiers } from "./linter.js";
62
64
  export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
package/dist/linter.cjs CHANGED
@@ -240,11 +240,31 @@ function _classifySpecifier(spec) {
240
240
  return "bare";
241
241
  }
242
242
 
243
+ // The package root of a bare specifier: `leaflet/dist/leaflet.css` → "leaflet",
244
+ // `@scope/pkg/sub` → "@scope/pkg". A subpath/asset import of a vetted package
245
+ // belongs to that package, so the root carries the vetting + platforms.
246
+ // Mirror of linter.js.
247
+ function _packageRoot(spec) {
248
+ const parts = spec.split("/");
249
+ // A traversal / empty segment is a non-canonical specifier — never resolve it
250
+ // to a vetted root, or "leaflet/../evil" would pass the gate as "leaflet".
251
+ if (parts.some((p) => p === "" || p === "." || p === "..")) return null;
252
+ return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
253
+ }
254
+
243
255
  function _importRules(source, manifest) {
244
256
  const findings = [];
245
257
  const allowed = new Map(
246
258
  CONTRACT.vettedImports.map((v) => [v.specifier, v]),
247
259
  );
260
+ // Core-infra specifiers (react-dom, react-dom/client) are host-shimmed on
261
+ // both runtimes but kept off the author-facing vetted list — accept them so
262
+ // a bundle's transitive react-dom import isn't flagged import-not-vetted.
263
+ for (const spec of CONTRACT.allowedBareImports) {
264
+ if (!allowed.has(spec)) {
265
+ allowed.set(spec, { specifier: spec, platforms: ["web", "native"] });
266
+ }
267
+ }
248
268
  const declaredPlatforms =
249
269
  manifest &&
250
270
  Array.isArray(manifest.supportedPlatforms) &&
@@ -279,7 +299,7 @@ function _importRules(source, manifest) {
279
299
  });
280
300
  continue;
281
301
  }
282
- const entry = allowed.get(spec);
302
+ const entry = allowed.get(spec) || allowed.get(_packageRoot(spec));
283
303
  if (!entry) {
284
304
  findings.push({
285
305
  rule: "import-not-vetted",
@@ -523,8 +543,11 @@ function _manifestActionRules(manifest) {
523
543
  // so the agent's repair loop fixes them. The valid set is committed data
524
544
  // (lucideIconNames.cjs) because the linter runs where lucide is not installed.
525
545
  const LUCIDE_NAME_SET = new Set(LUCIDE_ICON_NAMES);
546
+ // `[^}]*` (not `[\s\S]*?`) so the brace capture can never cross a `}` into a
547
+ // neighbouring import — otherwise a preceding `import { View, Pressable } from
548
+ // "react-native"` is swallowed and its names get validated as lucide icons.
526
549
  const LUCIDE_IMPORT_RE =
527
- /import\s+(?:[A-Za-z0-9_$]+\s*,\s*)?\{([\s\S]*?)\}\s*from\s*["']lucide-react-native["']/g;
550
+ /import\s+(?:[A-Za-z0-9_$]+\s*,\s*)?\{([^}]*)\}\s*from\s*["']lucide-react-native["']/g;
528
551
 
529
552
  // lucide re-exports each base icon as `<Name>`, `<Name>Icon`, and `Lucide<Name>`;
530
553
  // the committed set holds only the base names, so normalise the alias forms
@@ -571,6 +594,20 @@ function _lucideIconRules(source) {
571
594
  return findings;
572
595
  }
573
596
 
597
+ // Narrow a split-impl widget's manifest to the platform a single bundle file
598
+ // ships to, so `import-platform-mismatch` lints each file against what it
599
+ // actually targets. Mirror of linter.js.
600
+ function narrowManifestForFile(manifest, filename) {
601
+ if (!manifest || typeof manifest !== "object") return manifest;
602
+ if (filename === "widget.web.jsx") {
603
+ return { ...manifest, supportedPlatforms: ["web"] };
604
+ }
605
+ if (filename === "widget.native.jsx") {
606
+ return { ...manifest, supportedPlatforms: ["native"] };
607
+ }
608
+ return manifest;
609
+ }
610
+
574
611
  function lintSource(source, options) {
575
612
  if (typeof source !== "string") {
576
613
  return {
@@ -627,4 +664,4 @@ function lintSource(source, options) {
627
664
  return { ok: !hasErrors, findings };
628
665
  }
629
666
 
630
- module.exports = { lintSource, bannedIdentifiers };
667
+ module.exports = { lintSource, bannedIdentifiers, narrowManifestForFile };
package/dist/linter.js CHANGED
@@ -266,9 +266,29 @@ function _classifySpecifier(spec) {
266
266
  return "bare";
267
267
  }
268
268
 
269
+ // The package root of a bare specifier: `leaflet/dist/leaflet.css` → "leaflet",
270
+ // `@scope/pkg/sub` → "@scope/pkg". A subpath/asset import of a vetted package
271
+ // (its CSS, a marker PNG, react-dom/client) belongs to that package, so the
272
+ // root carries the vetting + platform metadata.
273
+ function _packageRoot(spec) {
274
+ const parts = spec.split("/");
275
+ // A traversal / empty segment is a non-canonical specifier — never resolve it
276
+ // to a vetted root, or "leaflet/../evil" would pass the gate as "leaflet".
277
+ if (parts.some((p) => p === "" || p === "." || p === "..")) return null;
278
+ return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
279
+ }
280
+
269
281
  function _importRules(source, manifest) {
270
282
  const findings = [];
271
283
  const allowed = new Map(CONTRACT.vettedImports.map((v) => [v.specifier, v]));
284
+ // Core-infra specifiers (react-dom, react-dom/client) are host-shimmed on
285
+ // both runtimes but kept off the author-facing vetted list — accept them so
286
+ // a bundle's transitive react-dom import isn't flagged import-not-vetted.
287
+ for (const spec of CONTRACT.allowedBareImports) {
288
+ if (!allowed.has(spec)) {
289
+ allowed.set(spec, { specifier: spec, platforms: ["web", "native"] });
290
+ }
291
+ }
272
292
  // Track declared `supportedPlatforms` so a widget that claims "web only"
273
293
  // doesn't import a native-only package (and vice versa) without the
274
294
  // marketplace listing being honest about which platforms ship.
@@ -307,8 +327,9 @@ function _importRules(source, manifest) {
307
327
  });
308
328
  continue;
309
329
  }
310
- // Bare specifier — validate against the vetted list.
311
- const entry = allowed.get(spec);
330
+ // Bare specifier — validate against the vetted list. A subpath/asset
331
+ // import (leaflet/dist/leaflet.css) resolves to its vetted package root.
332
+ const entry = allowed.get(spec) || allowed.get(_packageRoot(spec));
312
333
  if (!entry) {
313
334
  findings.push({
314
335
  rule: "import-not-vetted",
@@ -611,8 +632,11 @@ function _manifestActionRules(manifest) {
611
632
  // so the agent's repair loop fixes them. The valid set is committed data
612
633
  // (lucideIconNames.js) because the linter runs where lucide is not installed.
613
634
  const LUCIDE_NAME_SET = new Set(LUCIDE_ICON_NAMES);
635
+ // `[^}]*` (not `[\s\S]*?`) so the brace capture can never cross a `}` into a
636
+ // neighbouring import — otherwise a preceding `import { View, Pressable } from
637
+ // "react-native"` is swallowed and its names get validated as lucide icons.
614
638
  const LUCIDE_IMPORT_RE =
615
- /import\s+(?:[A-Za-z0-9_$]+\s*,\s*)?\{([\s\S]*?)\}\s*from\s*["']lucide-react-native["']/g;
639
+ /import\s+(?:[A-Za-z0-9_$]+\s*,\s*)?\{([^}]*)\}\s*from\s*["']lucide-react-native["']/g;
616
640
 
617
641
  // lucide re-exports each base icon as `<Name>`, `<Name>Icon`, and `Lucide<Name>`;
618
642
  // the committed set holds only the base names, so normalise the alias forms
@@ -659,6 +683,24 @@ function _lucideIconRules(source) {
659
683
  return findings;
660
684
  }
661
685
 
686
+ /**
687
+ * Narrow a split-impl widget's manifest to the platform a single bundle file
688
+ * ships to, so `import-platform-mismatch` lints each file against what it
689
+ * actually targets. `widget.web.jsx` → ["web"], `widget.native.jsx` →
690
+ * ["native"], cross-platform `widget.jsx` keeps the manifest's union claim.
691
+ * Returns a shallow clone; the original is untouched.
692
+ */
693
+ export function narrowManifestForFile(manifest, filename) {
694
+ if (!manifest || typeof manifest !== "object") return manifest;
695
+ if (filename === "widget.web.jsx") {
696
+ return { ...manifest, supportedPlatforms: ["web"] };
697
+ }
698
+ if (filename === "widget.native.jsx") {
699
+ return { ...manifest, supportedPlatforms: ["native"] };
700
+ }
701
+ return manifest;
702
+ }
703
+
662
704
  export function lintSource(source, options) {
663
705
  if (typeof source !== "string") {
664
706
  return {
@@ -70,3 +70,9 @@ export { Icon } from "./icon.js";
70
70
  // react-native-web mapping. Both implementations honour the same
71
71
  // ISO 8601 value / onChange contract.
72
72
  export { DateTimePicker } from "./datetimepicker.js";
73
+ // sc-1378 — `<FilePicker>` (web). Wraps a hidden DOM `<input type="file">`
74
+ // because react-native-web has no native equivalent. The native build's
75
+ // counterpart (./filepicker.native.js) degrades to a disabled trigger until
76
+ // a vetted expo-document-picker pin lands. `isSupported` is a static
77
+ // boolean widgets branch on to hide the trigger on native.
78
+ export { FilePicker } from "./filepicker.js";
@@ -31,3 +31,8 @@ export { Icon } from "./icon.js";
31
31
  // `./datetimepicker.js`. Both files honour the same ISO 8601 value /
32
32
  // onChange contract.
33
33
  export { DateTimePicker } from "./datetimepicker.native.js";
34
+ // sc-1378 — `<FilePicker>` (native). Web counterpart (./filepicker.js)
35
+ // wraps a hidden DOM `<input type="file">`; native has no portable
36
+ // equivalent yet, so this build renders a disabled trigger and exposes
37
+ // `FilePicker.isSupported = false` for widgets to branch on.
38
+ export { FilePicker } from "./filepicker.native.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.45.0",
3
+ "version": "0.47.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",
@@ -42,7 +42,7 @@
42
42
  ],
43
43
  "scripts": {
44
44
  "build": "node scripts/build.js",
45
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js"
45
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js"
46
46
  },
47
47
  "engines": {
48
48
  "node": ">=18"