@colixsystems/widget-sdk 0.14.1 → 0.16.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
@@ -6,9 +6,33 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
6
6
 
7
7
  ## Status
8
8
 
9
- `v0.14.1` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
9
+ `v0.16.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**.
10
10
 
11
- ### What's new in 0.14.1
11
+ ### What's new in 0.16.0
12
+
13
+ REQ-THEME — the tenant's **Theme Settings** now flow all the way into `useTheme()`.
14
+
15
+ - **`themeTokens.colors` gains `secondary` + `onSecondary`.** `useTheme().colors.secondary` reflects the tenant's *Secondary Color* picker (with `onSecondary` as its readable contrast color), alongside the existing `primary` / `onPrimary`. Built-in widgets like Button use it for their secondary variant; third-party widgets can use it for a branded second accent. The full `colors` shape is now `{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info }`.
16
+ - **`colors.primary` / `colors.secondary` / `typography.fontFamily` are tenant-resolved.** The host maps the Studio Theme Settings blob (Primary Color, Secondary Color, Global Font) onto the default tokens before handing them to `useTheme()`, on both the live Player and the exported app — so a widget that reads tokens re-themes automatically. (Custom Google fonts render in the Player today; the exported app falls back to the system face for non-system fonts until font bundling lands.)
17
+ - **`CONTRACT.version` → `1.6.0`** (additive: two new `themeTokens.colors` keys). No existing export changed signature.
18
+
19
+ ### What's new in 0.15.0
20
+
21
+ REQ-WSDK-PLATFORM — the "split-implementation + vetted package list" pivot. See [`docs/design/req-widget-sdk-cross-platform-primitives.md`](../../docs/design/req-widget-sdk-cross-platform-primitives.md) for the full design and rationale.
22
+
23
+ - **`CONTRACT.vettedImports` (new).** A curated allowlist of bare specifiers a widget may import — `react`, `@colixsystems/widget-sdk`, `react-native`, `axios`, `date-fns`, `react-native-svg`, `lucide-react-native`, `react-native-maps`, `leaflet`, `react-leaflet`, `expo-av`, `@react-native-community/datetimepicker`, `expo-clipboard`, `expo-haptics`. Each entry carries `platforms` (one or both of `"web"` / `"native"`) and a `category` so the linter and the marketplace listing can render honest platform badges. `CONTRACT.allowedBareImports` (the existing field) is now derived from `vettedImports` and stays a plain `string[]` for back-compat.
24
+ - **`fetch` and `XMLHttpRequest` come off `CONTRACT.bannedApis`.** Widgets may call third-party APIs directly. Calls to the host's own `/api/*` surface will 401 because the JWT token is never shared with widget code; the linter emits a soft `no-host-api-url` warning when it sees host-URL substrings so authors learn the rule statically. Use SDK hooks (`useDatastoreQuery`, `useUsers`, `useFile`, …) for workspace data; use `axios` / `fetch` for third-party APIs.
25
+ - **`import-not-vetted` linter rule (new).** Every bare `import` specifier is validated against `CONTRACT.vettedImports`. Relative imports inside the bundle (`./shared.js`) are allowed so split-impl widgets can share helpers; `../` and absolute paths are rejected.
26
+ - **`import-platform-mismatch` linter rule (new).** A single-source widget that imports a native-only package while `manifest.supportedPlatforms` includes `"web"` fails the lint. The author either drops the platform from the manifest OR ships a `widget.web.jsx` + `widget.native.jsx` pair where the platform-specific import lives in the file that targets its platform.
27
+ - **Lint findings carry `severity`.** `"error"` (default) blocks publish; `"warning"` (currently only `no-host-api-url`) surfaces to reviewers without blocking. The `lintSource(...)` return shape stays `{ ok, findings }` — `ok` is true iff no error-severity findings exist.
28
+ - **Four Tier A SDK additions:**
29
+ - `<Icon>` primitive — `<Icon name="check" size={16} color={theme.colors.primary} />`. Wraps `lucide-react-native`; works on both platforms.
30
+ - `<DateTimePicker>` primitive — `<DateTimePicker value={iso} onChange={iso => …} mode="date" | "time" | "datetime" />`. Wraps `@react-native-community/datetimepicker` and normalizes the value to ISO 8601 strings (the datastore wire format).
31
+ - `useClipboard()` hook — `{ copy, paste, hasContent }`. Web via `navigator.clipboard`; native via `expo-clipboard`. Rejections are a structured `ClipboardError` with `.code` in `PERMISSION_DENIED | INTERNAL`.
32
+ - `useToast()` hook — `{ showToast }`. The host installs a workspace-themed renderer at `WidgetContext.toast.showToast`; if omitted, the web variant dispatches an `appstudio:widget-toast` CustomEvent and native logs to the console.
33
+ - **`CONTRACT.version` → `1.5.0`** (additive: two new contract fields — `vettedImports`, `hostApiUrlPatterns` — two banned APIs removed, two primitives + two hooks added, one optional `widgetContextShape.toast` slot). No existing export changed signature.
34
+
35
+ ### What was in 0.14.1
12
36
 
13
37
  - **`groupRef` property type (REQ-USERMGMT M4 / §4.8).** Authors can now declare `{ type: 'groupRef', label: 'Group' }` in their `propertySchema` to render a Group picker in the Studio Properties Panel. The widget receives a bare `AppUserGroup` UUID — REQ-GEN-07 compliant, so tenant-copy walks the value transparently. Used by the built-in `appstudio.user-management` widget for its `defaultGroupId` prop and available to third-party widgets that need to anchor behaviour on a specific group.
14
38
  - **Patch bump** — additive enumeration entry, no exported function signature changed. `CONTRACT.version` stays `1.4.0`.
@@ -101,9 +125,9 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
101
125
 
102
126
  - `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
103
127
  - `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
104
- - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer` — hooks that read from the host-provided `WidgetContext`. `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`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (`id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useFile(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).
128
+ - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `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`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (`id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useFile(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).
105
129
  - `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.
106
- - `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet` — re-exported from `react-native`. The web build aliases `react-native` to `react-native-web` so widgets render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. See https://reactnative.dev/docs/ for per-component props.
130
+ - `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`). The web build aliases `react-native` to `react-native-web` so widgets 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.
107
131
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
108
132
 
109
133
  ## Managing app users from a widget
@@ -0,0 +1,88 @@
1
+ // REQ-WSDK-PLATFORM §6 — `useClipboard()` hook (web implementation).
2
+ //
3
+ // Wraps the browser's `navigator.clipboard` API. Returns a stable object:
4
+ //
5
+ // const { copy, paste, hasContent } = useClipboard();
6
+ //
7
+ // - `copy(text)` → Promise<void>. Writes a string. Rejects with a
8
+ // `ClipboardError` on failure (most common code:
9
+ // "PERMISSION_DENIED" — the page lacks the
10
+ // clipboard-write permission, often because the
11
+ // call wasn't triggered by a user gesture).
12
+ // - `paste()` → Promise<string>. Reads the current clipboard
13
+ // text. Returns "" when empty, rejects with
14
+ // `ClipboardError` (code "PERMISSION_DENIED" on
15
+ // sites that haven't asked for clipboard-read
16
+ // permission).
17
+ // - `hasContent()` → Promise<boolean>. Best-effort: reads the
18
+ // clipboard and reports whether it had a
19
+ // non-empty string. Same permission caveats as
20
+ // `paste()`.
21
+ //
22
+ // The native build uses ./clipboard.native.js (Metro picks via the
23
+ // package.json `react-native` resolution path through index.native.js).
24
+
25
+ export class ClipboardError extends Error {
26
+ constructor(code, message, opts) {
27
+ super(message);
28
+ this.name = "ClipboardError";
29
+ this.code = code;
30
+ if (opts && opts.cause) this.cause = opts.cause;
31
+ }
32
+ }
33
+
34
+ function _isPermissionError(err) {
35
+ if (!err) return false;
36
+ if (err.name === "NotAllowedError" || err.name === "SecurityError") return true;
37
+ const msg = typeof err.message === "string" ? err.message.toLowerCase() : "";
38
+ return msg.includes("permission") || msg.includes("not allowed");
39
+ }
40
+
41
+ function _wrap(err, label) {
42
+ if (err instanceof ClipboardError) return err;
43
+ const code = _isPermissionError(err) ? "PERMISSION_DENIED" : "INTERNAL";
44
+ return new ClipboardError(code, `${label}: ${err && err.message ? err.message : err}`, {
45
+ cause: err,
46
+ });
47
+ }
48
+
49
+ export function useClipboard() {
50
+ // Each method is a fresh function but the returned identity is stable
51
+ // per call site — callers passing one into useEffect dep arrays should
52
+ // pin the method, not the whole object.
53
+ return {
54
+ async copy(text) {
55
+ const value = text == null ? "" : String(text);
56
+ try {
57
+ if (typeof navigator === "undefined" || !navigator.clipboard) {
58
+ throw new ClipboardError("INTERNAL", "Clipboard API unavailable.");
59
+ }
60
+ await navigator.clipboard.writeText(value);
61
+ } catch (err) {
62
+ throw _wrap(err, "clipboard.copy");
63
+ }
64
+ },
65
+ async paste() {
66
+ try {
67
+ if (typeof navigator === "undefined" || !navigator.clipboard) {
68
+ throw new ClipboardError("INTERNAL", "Clipboard API unavailable.");
69
+ }
70
+ const text = await navigator.clipboard.readText();
71
+ return typeof text === "string" ? text : "";
72
+ } catch (err) {
73
+ throw _wrap(err, "clipboard.paste");
74
+ }
75
+ },
76
+ async hasContent() {
77
+ try {
78
+ if (typeof navigator === "undefined" || !navigator.clipboard) return false;
79
+ const text = await navigator.clipboard.readText();
80
+ return typeof text === "string" && text.length > 0;
81
+ } catch {
82
+ // Permission-denied reads return false instead of throwing —
83
+ // hasContent() is the "best-effort" probe, not a write gate.
84
+ return false;
85
+ }
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,64 @@
1
+ // REQ-WSDK-PLATFORM §6 — `useClipboard()` hook (native implementation).
2
+ //
3
+ // Wraps expo-clipboard. Same return shape as the web counterpart
4
+ // (./clipboard.js) so widget code is byte-for-byte identical across
5
+ // platforms. The two implementations only differ in how `copy` / `paste`
6
+ // reach the system clipboard.
7
+ //
8
+ // Errors are normalized to a structured `ClipboardError` with a stable
9
+ // `.code`. Native clipboard reads cannot fail on permission grounds
10
+ // (Android doesn't prompt; iOS reveals a paste banner but still
11
+ // resolves), so "PERMISSION_DENIED" is web-only.
12
+
13
+ // eslint-disable-next-line no-restricted-syntax
14
+ import * as Clipboard from "expo-clipboard";
15
+
16
+ export class ClipboardError extends Error {
17
+ constructor(code, message, opts) {
18
+ super(message);
19
+ this.name = "ClipboardError";
20
+ this.code = code;
21
+ if (opts && opts.cause) this.cause = opts.cause;
22
+ }
23
+ }
24
+
25
+ function _wrap(err, label) {
26
+ if (err instanceof ClipboardError) return err;
27
+ return new ClipboardError(
28
+ "INTERNAL",
29
+ `${label}: ${err && err.message ? err.message : err}`,
30
+ { cause: err },
31
+ );
32
+ }
33
+
34
+ export function useClipboard() {
35
+ return {
36
+ async copy(text) {
37
+ const value = text == null ? "" : String(text);
38
+ try {
39
+ await Clipboard.setStringAsync(value);
40
+ } catch (err) {
41
+ throw _wrap(err, "clipboard.copy");
42
+ }
43
+ },
44
+ async paste() {
45
+ try {
46
+ const text = await Clipboard.getStringAsync();
47
+ return typeof text === "string" ? text : "";
48
+ } catch (err) {
49
+ throw _wrap(err, "clipboard.paste");
50
+ }
51
+ },
52
+ async hasContent() {
53
+ try {
54
+ if (typeof Clipboard.hasStringAsync === "function") {
55
+ return !!(await Clipboard.hasStringAsync());
56
+ }
57
+ const text = await Clipboard.getStringAsync();
58
+ return typeof text === "string" && text.length > 0;
59
+ } catch {
60
+ return false;
61
+ }
62
+ },
63
+ };
64
+ }
package/dist/contract.cjs CHANGED
@@ -12,6 +12,8 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
12
12
  colors: Object.freeze({
13
13
  primary: "#ff6b5b",
14
14
  onPrimary: "#ffffff",
15
+ secondary: "#475569",
16
+ onSecondary: "#ffffff",
15
17
  surface: "#ffffff",
16
18
  onSurface: "#111827",
17
19
  surfaceMuted: "#f8fafc",
@@ -37,7 +39,7 @@ const HOOKS = [
37
39
  signature: "useTheme()",
38
40
  returnShape: {
39
41
  colors:
40
- "{ primary, onPrimary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info }",
42
+ "{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info }",
41
43
  spacing: "{ xs, sm, md, lg, xl }",
42
44
  radii: "{ sm, md, lg, pill }",
43
45
  typography: "{ fontFamily, sizes: { xs, sm, md, lg, xl, xxl } }",
@@ -240,6 +242,42 @@ const HOOKS = [
240
242
  ],
241
243
  scopes: ["groups.read:*"],
242
244
  },
245
+ // REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
246
+ {
247
+ name: "useClipboard",
248
+ signature: "useClipboard()",
249
+ description:
250
+ "Cross-platform clipboard access. Returns { copy, paste, hasContent }. " +
251
+ "All methods return Promises; rejections surface a structured ClipboardError " +
252
+ "with .code in PERMISSION_DENIED | INTERNAL. On web the browser may " +
253
+ "require a user gesture for read access — surface the error to the " +
254
+ "user as a 'try again after clicking' prompt when code === PERMISSION_DENIED.",
255
+ returnShape: {
256
+ copy:
257
+ "(text: string) => Promise<void> // rejects with ClipboardError",
258
+ paste:
259
+ "() => Promise<string> // rejects with ClipboardError",
260
+ hasContent: "() => Promise<boolean> // best-effort, never throws",
261
+ },
262
+ requiredContextSlice: [],
263
+ scopes: null,
264
+ },
265
+ {
266
+ name: "useToast",
267
+ signature: "useToast()",
268
+ description:
269
+ "Surfaces a short auto-dismissing notification. Returns { showToast }. " +
270
+ "showToast({ kind: 'success' | 'error' | 'info' | 'warning', message }) " +
271
+ "asks the host to render a workspace-themed toast. If the host hasn't " +
272
+ "wired a renderer, the web variant dispatches an 'appstudio:widget-toast' " +
273
+ "CustomEvent on window; native logs to the console. The widget never " +
274
+ "owns the toast UI — that's the host's responsibility.",
275
+ returnShape: {
276
+ showToast: "({ kind, message }) => void",
277
+ },
278
+ requiredContextSlice: ["toast.showToast"],
279
+ scopes: null,
280
+ },
243
281
  ];
244
282
 
245
283
  // REQ-WSDK-RN-WEB: the SDK exposes the React Native primitive API
@@ -334,6 +372,22 @@ const PRIMITIVES = [
334
372
  rnComponent: "Linking",
335
373
  docsUrl: "https://reactnative.dev/docs/linking",
336
374
  },
375
+ // REQ-WSDK-PLATFORM §6 — Tier A SDK primitive.
376
+ {
377
+ name: "Icon",
378
+ description:
379
+ 'Lucide icon. `<Icon name="check" size={16} color="..." />`. Unknown names render the Square fallback so the canvas always shows something visible. Names are the lucide icon ids (`https://lucide.dev/icons`). Works on both web and native.',
380
+ rnComponent: "lucide-react-native",
381
+ docsUrl: "https://lucide.dev/icons",
382
+ },
383
+ // REQ-WSDK-PLATFORM §6 — Tier A SDK primitive.
384
+ {
385
+ name: "DateTimePicker",
386
+ description:
387
+ 'Cross-platform date / time / datetime picker. `<DateTimePicker value={iso} onChange={iso => …} mode="date" | "time" | "datetime" />`. The value prop and the onChange callback both speak ISO 8601 strings (the datastore wire format) — authors never round-trip through `new Date()`. Web renders the browser\'s native input via react-native-web; native uses @react-native-community/datetimepicker.',
388
+ rnComponent: "@react-native-community/datetimepicker",
389
+ docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
390
+ },
337
391
  ];
338
392
 
339
393
  const CATEGORIES = [
@@ -551,6 +605,17 @@ const WIDGET_CONTEXT_SHAPE = {
551
605
  error: "function",
552
606
  },
553
607
  },
608
+ // REQ-WSDK-PLATFORM §6 — backs useToast(). Mirror of contract.js.
609
+ toast: {
610
+ description:
611
+ "Optional host toast slot. { showToast({ kind, message }): void }. " +
612
+ "The host populates this to render workspace-themed notifications " +
613
+ "from any widget that calls useToast(). When omitted the SDK falls " +
614
+ "back to dispatching an 'appstudio:widget-toast' CustomEvent on web " +
615
+ "and console.log on native.",
616
+ required: false,
617
+ fields: { showToast: "function" },
618
+ },
554
619
  };
555
620
 
556
621
  const BUNDLE_EXPORT_CONTRACT = [
@@ -573,6 +638,15 @@ const BUNDLE_EXPORT_CONTRACT = [
573
638
  },
574
639
  ];
575
640
 
641
+ // REQ-WSDK-PLATFORM (docs/design/req-widget-sdk-cross-platform-primitives.md
642
+ // §3.5, §8): `fetch` and `XMLHttpRequest` are NOT banned. Widgets may call
643
+ // third-party APIs directly. Same-origin requests to the host's own
644
+ // `/api/*` surface are rejected at runtime by the WidgetContextProvider's
645
+ // network gate (`no host-api access from widgets`) — the JWT token is
646
+ // never shared with widget code, so the call would 401 anyway; the runtime
647
+ // gate makes the failure mode "blocked" instead of "401 noise". A soft
648
+ // linter warning (`no-host-api-url`) flags obvious host-URL substrings at
649
+ // submission so authors learn the rule statically.
576
650
  const BANNED_APIS = [
577
651
  { identifier: "eval", reason: "Arbitrary code evaluation." },
578
652
  {
@@ -601,23 +675,144 @@ const BANNED_APIS = [
601
675
  reason: "Same reason as localStorage.",
602
676
  },
603
677
  {
604
- identifier: "fetch",
605
- reason:
606
- "Direct network calls bypass the datastore client + tenant auth.",
678
+ identifier: "import(",
679
+ reason: "Dynamic import bypasses the loader's allowlist.",
680
+ },
681
+ { identifier: "globalThis", reason: "Host environment escape." },
682
+ ];
683
+
684
+ // REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
685
+ // specifier the widget bundle may import as a bare module specifier. The
686
+ // linter validates every `import ... from "<spec>"` line against this
687
+ // list; specifiers not on the list fail the lint. The compiler reads it
688
+ // to know which packages to add to the exported Expo app's package.json.
689
+ //
690
+ // `platforms` is one or both of "web" / "native". A widget that imports a
691
+ // native-only package implicitly drops "web" from the package's effective
692
+ // `supportedPlatforms` (and vice versa) — the marketplace upload pipeline
693
+ // surfaces the derived set so the listing shows honest badges.
694
+ //
695
+ // Adding a package is a CONTRACT change. Review burden: confirm the
696
+ // package does what it claims, has a credible maintainer, and the
697
+ // import shape (named exports, default export) is stable. After that,
698
+ // every widget using the package inherits the review — the marketplace
699
+ // reviewer only spot-checks usage, not the package source.
700
+ const VETTED_IMPORTS = [
701
+ {
702
+ specifier: "react",
703
+ platforms: ["web", "native"],
704
+ category: "core",
705
+ description: "React. Hooks, JSX, lifecycle. Unchanged.",
706
+ },
707
+ {
708
+ specifier: "@colixsystems/widget-sdk",
709
+ platforms: ["web", "native"],
710
+ category: "core",
711
+ description:
712
+ "The AppStudio widget SDK — primitives, hooks, manifest helpers. Unchanged.",
607
713
  },
608
714
  {
609
- identifier: "XMLHttpRequest",
610
- reason:
611
- "Direct network calls bypass the datastore client + tenant auth.",
715
+ specifier: "react-native",
716
+ platforms: ["web", "native"],
717
+ category: "primitive",
718
+ description:
719
+ "Direct RN imports for APIs the SDK hasn't re-exported yet. On web the host bundler aliases this to react-native-web; on native Metro resolves the real library.",
612
720
  },
613
721
  {
614
- identifier: "import(",
615
- reason: "Dynamic import bypasses the loader's allowlist.",
722
+ specifier: "axios",
723
+ platforms: ["web", "native"],
724
+ category: "network",
725
+ description:
726
+ "HTTP client for third-party APIs. Calls to the host's /api/* surface are blocked at runtime — widgets get no JWT token, so use SDK hooks for workspace data.",
727
+ },
728
+ {
729
+ specifier: "date-fns",
730
+ platforms: ["web", "native"],
731
+ category: "utility",
732
+ description: "Pure-JS date math. Works on both platforms unchanged.",
733
+ },
734
+ {
735
+ specifier: "react-native-svg",
736
+ platforms: ["web", "native"],
737
+ category: "drawing",
738
+ description:
739
+ "Cross-platform SVG drawing primitives. Used by the built-in Chart widget; works on both platforms.",
740
+ },
741
+ {
742
+ specifier: "lucide-react-native",
743
+ platforms: ["web", "native"],
744
+ category: "iconography",
745
+ description:
746
+ "Lucide icon set as React components. Used by the built-in Icon widget; works on both platforms.",
747
+ },
748
+ {
749
+ specifier: "react-native-maps",
750
+ platforms: ["native"],
751
+ category: "geo",
752
+ description:
753
+ "Native map view + markers. Native-only; pair with leaflet/react-leaflet in widget.web.jsx for a web variant.",
754
+ },
755
+ {
756
+ specifier: "leaflet",
757
+ platforms: ["web"],
758
+ category: "geo",
759
+ description:
760
+ "Web-only mapping library. Use alongside react-leaflet in widget.web.jsx as the web counterpart to react-native-maps.",
761
+ },
762
+ {
763
+ specifier: "react-leaflet",
764
+ platforms: ["web"],
765
+ category: "geo",
766
+ description: "React bindings for leaflet. Web-only.",
767
+ },
768
+ {
769
+ specifier: "expo-av",
770
+ platforms: ["native"],
771
+ category: "media",
772
+ description:
773
+ "Native audio + video playback. Native-only; pair with browser <audio>/<video> in widget.web.jsx.",
774
+ },
775
+ {
776
+ specifier: "@react-native-community/datetimepicker",
777
+ platforms: ["native"],
778
+ category: "input",
779
+ description:
780
+ "Native date/time picker. The SDK's <DateTimePicker> primitive already wraps this; reach for it directly only if you need RN-specific options.",
781
+ },
782
+ {
783
+ specifier: "expo-clipboard",
784
+ platforms: ["native"],
785
+ category: "system",
786
+ description:
787
+ "Native clipboard. The SDK's useClipboard() hook already wraps this; reach for it directly only if you need RN-specific options.",
788
+ },
789
+ {
790
+ specifier: "expo-haptics",
791
+ platforms: ["native"],
792
+ category: "system",
793
+ description:
794
+ "Native haptic feedback. Pair with navigator.vibrate in widget.web.jsx.",
616
795
  },
617
- { identifier: "globalThis", reason: "Host environment escape." },
618
796
  ];
619
797
 
620
- const ALLOWED_BARE_IMPORTS = ["react", "@colixsystems/widget-sdk"];
798
+ // Back-compat shape — every existing consumer (widgetLoader, the static
799
+ // analyzer's DEPENDENCY_ALLOWLIST, the AI prompt's "Allowed imports" line)
800
+ // reads a plain `string[]` from `CONTRACT.allowedBareImports`. Derive it
801
+ // from the rich list so a single edit in VETTED_IMPORTS propagates to
802
+ // every surface.
803
+ const ALLOWED_BARE_IMPORTS = VETTED_IMPORTS.map((v) => v.specifier);
804
+
805
+ // REQ-WSDK-PLATFORM §3.5: host-API URL patterns the linter scans for as a
806
+ // soft warning. None of these block the lint by themselves — they prompt
807
+ // the marketplace reviewer to spot-check, and they help authors learn the
808
+ // rule statically. Strings appear as literal substring matches (the URL
809
+ // is reachable in widget code by composing it at runtime, so this is
810
+ // best-effort).
811
+ const HOST_API_URL_PATTERNS = [
812
+ "/api/v1",
813
+ "/uploads/",
814
+ "Authorization: Bearer",
815
+ ];
621
816
 
622
817
  function deepFreeze(value) {
623
818
  if (value === null || typeof value !== "object") return value;
@@ -627,7 +822,22 @@ function deepFreeze(value) {
627
822
  }
628
823
 
629
824
  const CONTRACT = deepFreeze({
630
- version: "1.4.0",
825
+ // REQ-WSDK-PLATFORM bump:
826
+ // - `vettedImports` is a new field (rich allowlist with platforms +
827
+ // category + description).
828
+ // - `hostApiUrlPatterns` is a new field (soft-warning substrings).
829
+ // - `bannedApis` no longer lists `fetch` / `XMLHttpRequest`.
830
+ // - `allowedBareImports` is now derived from `vettedImports` (same
831
+ // shape; same contents grow with each vetted addition).
832
+ // Permissive-direction change: minor bump on the contract's own
833
+ // versioning (per CLAUDE.md §4, pre-1.0 minor is the breaking channel —
834
+ // the package.json version bumps accordingly).
835
+ //
836
+ // 1.6.0: additive — `themeTokens.colors` gains `secondary` + `onSecondary`
837
+ // so the tenant's Theme Settings "Secondary Color" flows through
838
+ // `useTheme().colors.secondary` (Button secondary variant + any widget
839
+ // that wants the brand's second accent).
840
+ version: "1.6.0",
631
841
  hooks: HOOKS,
632
842
  primitives: PRIMITIVES,
633
843
  manifestSchema: MANIFEST_SCHEMA,
@@ -637,7 +847,9 @@ const CONTRACT = deepFreeze({
637
847
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
638
848
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
639
849
  bannedApis: BANNED_APIS,
850
+ vettedImports: VETTED_IMPORTS,
640
851
  allowedBareImports: ALLOWED_BARE_IMPORTS,
852
+ hostApiUrlPatterns: HOST_API_URL_PATTERNS,
641
853
  });
642
854
 
643
855
  function isHookAllowed(name) {