@colixsystems/widget-sdk 0.44.0 → 0.45.1

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,10 +49,28 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
49
49
 
50
50
  ## Status
51
51
 
52
- `v0.44.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.45.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**.
53
+
54
+ ### What's new in 0.45.1
55
+
56
+ **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.
57
+
58
+ ### What's new in 0.45.0
59
+
60
+ **New linter rule `lucide-unknown-icon` (sc-1366).** `lucide-react-native` is a vetted import the bundler **externalises**, so esbuild never checks the *named* imports — a widget that imports an icon name absent from the pinned `lucide-react-native@0.368.0` (e.g. `import { House } from "lucide-react-native"`, when that version only ships `Home` — `House` was added to lucide later) bundles and publishes cleanly, then fails only at **runtime load** ("does not provide an export named 'House'") and renders blank. The linter now rejects such imports at publish so the failure becomes a repair-loop finding instead of a broken shipped widget. The valid set is committed data (`src/lucideIconNames.{cjs,js}` — the 1451 base export names + the generated-from `LUCIDE_VERSION`) because the linter runs in zero-dependency contexts where `lucide-react-native` (and its `react-native` peer) is not installed; alias forms (`<Name>Icon`, `Lucide<Name>`) are normalised. Regenerate after a lucide bump with `node scripts/generate-lucide-icon-names.cjs`; a test asserts `LUCIDE_VERSION` matches the frontend pin so the set can't go stale. The `CONTRACT` object is unchanged (no new field), so `CONTRACT.version` stays at `1.32.0`. The author/agent-facing mirror of this rule is the "Icons" note in the AI agent's `DEFAULT_SYSTEM_PROMPT` (shipped in the companion sc-1273/sc-1362 prompt PR).
53
61
 
54
62
  ### What's new in 0.44.0
55
63
 
64
+ **AI-generated widgets are bundled at publish (sc-1265).** Until this release the AI agent's publish path served the validated LLM source **verbatim** (transpiled, not bundled), which meant an AI widget could only import the host-shimmed specifiers — a draft that imported, say, `react-native-gesture-handler` published cleanly but threw "unresolvable bare imports" at load. Marketplace widgets, in contrast, are esbuild-**bundled** by the publish packer and get the full vetted-import surface. This release closes that gap: every AI publish now ships through the **`@appstudio/widget-bundler`** sidecar (`services/widget-bundler` — internal HTTP service that wraps the SAME `bundleWebEntry` the marketplace packer uses), so the bundled output is byte-shape identical regardless of who published.
65
+
66
+ - **`react-native` is now host-resolved on web.** A bundled widget — AI or marketplace — that imports `react-native` (directly, or via a vetted RN package like `react-native-gesture-handler` that imports it internally) leaves the specifier as a bare import; the Studio loader shims it to the host's **single** `react-native-web` instance. That keeps the bundle small (gesture-handler probes dropped from 762 KB to ~248 KB) and avoids two RN-web copies in the same page (`StyleSheet` + context conflicts).
67
+ - **`hostExternalSpecifiers()` grew `react-native`.** The canonical "host-resolved at runtime" set the bundler externalises now lists react family + `react-dom` + `react-dom/client` + `@colixsystems/widget-sdk` + the vetted shimmed packages (lucide / svg / date-fns) + `react-native`.
68
+ - **`bundleWebEntry` resolves RN web builds.** `resolveExtensions` is `.web.js`-first, `mainFields` is `[browser, module, main]`, and the `browser` export condition is active — so esbuild picks the web build of an RN-shape package automatically. No alias step; `react-native` stays external.
69
+ - **Contract description fix.** The vetted-imports entry for `react-native` previously claimed "the host bundler aliases this to react-native-web" — that was never true and the misclaim leaked into the AI system prompt. The description now reads: "`react-native` is host-resolved to the host's single react-native-web instance (external, shimmed) — NOT aliased at bundle time".
70
+ - **AI publish storage shape.** Every AI publish now writes the `bundleFiles` JSONB column with the bundled web entry under `widget.web.jsx` and the transpiled RN source under `widget.native.jsx`. `bundleSource` (the legacy single-file column) stays NULL on AI rows; `resolveWebBundleSource` / `resolveNativeBundleSource` pick the right file at load time.
71
+ - **Bundle failure → repair loop.** A bundler error is now a `bundle.web` finding the existing publish repair loop folds into the next attempt's prompt, so the model can self-correct a draft whose imports the bundler refused.
72
+ - **`CONTRACT.version` → `1.32.0`** (additive: the `react-native` host-resolution behaviour + a corrected vetted-imports description; the SET of vetted imports is unchanged).
73
+
56
74
  **Vetted `@shopify/react-native-skia` for canvas-style graphics & games (sc-1270).** Widgets that need true 2D/GPU canvas drawing (games, custom visualisations) can now `import` Skia. It is **native-only** (`platforms: ["native"]`, like `react-native-maps` / `lottie-react-native`): author it in `widget.native.jsx` and pair it with a web variant in `widget.web.jsx` — a browser `<canvas>` or `react-native-svg`. This closes the gap where a raw `<canvas>` rendered in the web Player but crashed the Expo export ("View config getter callback for component `canvas` … received undefined") because React Native has no `<canvas>`. The compiler pins `@shopify/react-native-skia` in the exported app's `package.json`; no host shim is added (native-only packages are never shimmed, and there is no Skia web build wired into the Player). Unified Skia-on-web (CanvasKit/WASM) is a documented follow-up. Additive — `CONTRACT.version` bumped to 1.31.0.
57
75
 
58
76
  ### What's new in 0.43.0
@@ -488,6 +506,46 @@ The manifest declares the matching scope:
488
506
 
489
507
  The server-side gate is `canGrant` on the target record — Studio owners pass automatically; an APP_USER holds `canGrant` as the record's creator or via a delegated grant. A caller without `canGrant` receives `PermissionError { code: "FORBIDDEN" }`. The hook collapses to a stable no-op when `tableId` or `recordId` is null/empty — so a widget can render its picker first, then bind to the picked record without conditional hook tricks.
490
508
 
509
+ ## Cross-platform widgets (single-file vs split)
510
+
511
+ Every widget runs in **both** the web Player and the exported native (Expo) app.
512
+ There are two ways to author one, and the **file set you ship decides
513
+ `supportedPlatforms`** — you don't hand-declare it, the platform derives it.
514
+
515
+ **1. Single file — `widget.jsx` (the default).** Works on web AND native. Pick
516
+ this when the widget needs only the SDK primitives + hooks, or only packages
517
+ that support both platforms (every `["web", "native"]` entry in
518
+ `CONTRACT.vettedImports` — `react-native`, `react-native-svg`, `date-fns`,
519
+ `react-native-paper`, `@shopify/flash-list`, `react-native-reanimated`,
520
+ `react-native-gesture-handler`, `expo-linear-gradient`, …). Most widgets land
521
+ here. Derives `supportedPlatforms: ["web", "native"]`.
522
+
523
+ **2. Split implementation — `widget.web.jsx` + `widget.native.jsx`.** Pick this
524
+ when the widget needs a package that only runs on ONE platform. Each file
525
+ targets its platform and imports the package that works there; shared logic
526
+ lives in a sibling `./helper.js` both files import. This is the **canonical
527
+ pattern** for graphics, media, and any platform-divergent library:
528
+
529
+ | Capability | `widget.native.jsx` | `widget.web.jsx` |
530
+ | ---------- | ------------------- | ---------------- |
531
+ | Maps | `react-native-maps` | `react-leaflet` / `leaflet` |
532
+ | Canvas / 2D-GPU drawing | `@shopify/react-native-skia` | `<canvas>` or `react-native-svg` |
533
+ | Video | `expo-video` | the browser `<video>` element |
534
+ | Audio | `expo-audio` | the browser `<audio>` element |
535
+ | Lottie animation | `lottie-react-native` | `lottie-react` |
536
+ | Embedded web content | `react-native-webview` | an `<iframe>` |
537
+
538
+ A few native-only packages (`@react-native-community/datetimepicker`,
539
+ `expo-clipboard`) are already wrapped by an SDK primitive/hook
540
+ (`<DateTimePicker>`, `useClipboard()`) that resolves the right implementation on
541
+ each platform — reach for those first and you stay single-file.
542
+
543
+ **The linter enforces honesty at publish.** A single-file widget that imports a
544
+ native-only package while claiming web (or vice-versa) fails the
545
+ `import-platform-mismatch` rule — move the import into the file that targets its
546
+ platform, or ship the split pair. So you can't accidentally publish a widget
547
+ that renders on one platform and blanks on the other.
548
+
491
549
  ## Linter
492
550
 
493
551
  ```sh
package/dist/contract.cjs CHANGED
@@ -1140,7 +1140,7 @@ const VETTED_IMPORTS = [
1140
1140
  platforms: ["web", "native"],
1141
1141
  category: "primitive",
1142
1142
  description:
1143
- "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.",
1143
+ "Direct RN imports for APIs the SDK hasn't re-exported yet. On web `react-native` is host-resolved to the host's single react-native-web instance (external, shimmed) — NOT aliased at bundle time — so a bundled widget shares the host's RN-web (one StyleSheet/context, no double-instance conflicts). On native Metro resolves the real library.",
1144
1144
  },
1145
1145
  {
1146
1146
  specifier: "axios",
@@ -1672,7 +1672,16 @@ const CONTRACT = deepFreeze({
1672
1672
  // variant in widget.web.jsx. Pinned in the compiler's export
1673
1673
  // package.json; no host shim (native-only is never shimmed). No hook,
1674
1674
  // primitive, manifest field, or token changed shape — minor bump.
1675
- version: "1.31.0",
1675
+ //
1676
+ // 1.32.0: behaviour (sc-1265) — `react-native` is now host-resolved on web
1677
+ // as an EXTERNAL, loader-shimmed to the host's single react-native-web
1678
+ // instance, instead of being aliased at bundle time. AI publishes now
1679
+ // bundle through the widget-bundler sidecar (byte-shape identical to the
1680
+ // marketplace packer), so a bundled widget shares the host's one RN-web
1681
+ // instance — one StyleSheet/context, no double-instance conflicts. The
1682
+ // vetted-import SET is unchanged (react-native was already vetted); only
1683
+ // its web resolution + description changed — minor bump.
1684
+ version: "1.32.0",
1676
1685
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1677
1686
  hooks: HOOKS,
1678
1687
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1140,7 +1140,7 @@ const VETTED_IMPORTS = [
1140
1140
  platforms: ["web", "native"],
1141
1141
  category: "primitive",
1142
1142
  description:
1143
- "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.",
1143
+ "Direct RN imports for APIs the SDK hasn't re-exported yet. On web `react-native` is host-resolved to the host's single react-native-web instance (external, shimmed) — NOT aliased at bundle time — so a bundled widget shares the host's RN-web (one StyleSheet/context, no double-instance conflicts). On native Metro resolves the real library.",
1144
1144
  },
1145
1145
  {
1146
1146
  specifier: "axios",
@@ -1672,7 +1672,16 @@ const CONTRACT = deepFreeze({
1672
1672
  // variant in widget.web.jsx. Pinned in the compiler's export
1673
1673
  // package.json; no host shim (native-only is never shimmed). No hook,
1674
1674
  // primitive, manifest field, or token changed shape — minor bump.
1675
- version: "1.31.0",
1675
+ //
1676
+ // 1.32.0: behaviour (sc-1265) — `react-native` is now host-resolved on web
1677
+ // as an EXTERNAL, loader-shimmed to the host's single react-native-web
1678
+ // instance, instead of being aliased at bundle time. AI publishes now
1679
+ // bundle through the widget-bundler sidecar (byte-shape identical to the
1680
+ // marketplace packer), so a bundled widget shares the host's one RN-web
1681
+ // instance — one StyleSheet/context, no double-instance conflicts. The
1682
+ // vetted-import SET is unchanged (react-native was already vetted); only
1683
+ // its web resolution + description changed — minor bump.
1684
+ version: "1.32.0",
1676
1685
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1677
1686
  hooks: HOOKS,
1678
1687
  primitives: PRIMITIVES,
package/dist/dev-shims.js CHANGED
@@ -107,6 +107,12 @@ const HOST_EXTERNAL_SPECIFIERS = [
107
107
  "lucide-react-native",
108
108
  "react-native-svg",
109
109
  "date-fns",
110
+ // sc-1265: a bundled widget (and any vetted RN package it inlines, e.g.
111
+ // react-native-gesture-handler) imports `react-native`; the host resolves it
112
+ // to its OWN single react-native-web instance through a blob shim, so the
113
+ // bundle shares one RN-web (StyleSheet/context) instead of inlining a second
114
+ // copy. On native, Metro resolves the real react-native in the export.
115
+ "react-native",
110
116
  ];
111
117
 
112
118
  /**
package/dist/linter.cjs CHANGED
@@ -11,6 +11,7 @@
11
11
  "use strict";
12
12
 
13
13
  const { CONTRACT } = require("./contract.cjs");
14
+ const { LUCIDE_ICON_NAMES, LUCIDE_VERSION } = require("./lucideIconNames.cjs");
14
15
 
15
16
  function _ruleForIdentifier(identifier, reason) {
16
17
  const id = identifier;
@@ -513,6 +514,66 @@ function _manifestActionRules(manifest) {
513
514
  return findings;
514
515
  }
515
516
 
517
+ // sc-1366 — invalid lucide icon import gate. lucide-react-native is a vetted
518
+ // import that the bundler EXTERNALISES, so esbuild never checks the named
519
+ // imports; a name that does not exist in the pinned version (`House` was added
520
+ // to lucide AFTER the pinned 0.368.0, which only has `Home`) bundles + publishes
521
+ // cleanly and fails only at runtime load ("does not provide an export named
522
+ // 'House'"), leaving the widget blank. This rule rejects such imports at publish
523
+ // so the agent's repair loop fixes them. The valid set is committed data
524
+ // (lucideIconNames.cjs) because the linter runs where lucide is not installed.
525
+ const LUCIDE_NAME_SET = new Set(LUCIDE_ICON_NAMES);
526
+ // `[^}]*` (not `[\s\S]*?`) so the brace capture can never cross a `}` into a
527
+ // neighbouring import — otherwise a preceding `import { View, Pressable } from
528
+ // "react-native"` is swallowed and its names get validated as lucide icons.
529
+ const LUCIDE_IMPORT_RE =
530
+ /import\s+(?:[A-Za-z0-9_$]+\s*,\s*)?\{([^}]*)\}\s*from\s*["']lucide-react-native["']/g;
531
+
532
+ // lucide re-exports each base icon as `<Name>`, `<Name>Icon`, and `Lucide<Name>`;
533
+ // the committed set holds only the base names, so normalise the alias forms
534
+ // before the membership check. No base name ends in "Icon" or starts with
535
+ // "Lucide" (asserted by lucide-icon-names.test.js), so stripping is unambiguous.
536
+ function _isValidLucideName(name) {
537
+ if (LUCIDE_NAME_SET.has(name)) return true;
538
+ if (name.endsWith("Icon") && LUCIDE_NAME_SET.has(name.slice(0, -4))) return true;
539
+ if (name.startsWith("Lucide") && LUCIDE_NAME_SET.has(name.slice(6))) return true;
540
+ return false;
541
+ }
542
+
543
+ function _lucideIconRules(source) {
544
+ const findings = [];
545
+ const sourceLines = source.split(/\r?\n/);
546
+ LUCIDE_IMPORT_RE.lastIndex = 0;
547
+ let m;
548
+ while ((m = LUCIDE_IMPORT_RE.exec(source))) {
549
+ const line = source.slice(0, m.index).split(/\r?\n/).length;
550
+ for (const raw of (m[1] || "").split(",")) {
551
+ // Strip any inline comment from the specifier token so a `{ Home, // x`
552
+ // comment can't glue onto and hide the next specifier (e.g. `House`).
553
+ const cleaned = raw
554
+ .replace(/\/\*[\s\S]*?\*\//g, "")
555
+ .replace(/\/\/[^\n]*/g, "");
556
+ const imported = cleaned.trim().split(/\s+as\s+/)[0].trim();
557
+ if (!imported || imported === "type") continue;
558
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(imported)) continue;
559
+ if (_isValidLucideName(imported)) continue;
560
+ findings.push({
561
+ rule: "lucide-unknown-icon",
562
+ severity: "error",
563
+ label:
564
+ `lucide-react-native has no export "${imported}" in the pinned ` +
565
+ `lucide-react-native@${LUCIDE_VERSION}. The widget bundles cleanly but FAILS at ` +
566
+ `runtime load ("does not provide an export named '${imported}'") and never ` +
567
+ `renders — replace it with an icon name that exists in that version (lucide ` +
568
+ `adds and renames icons across releases, so a newer name may not exist here).`,
569
+ line,
570
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
571
+ });
572
+ }
573
+ }
574
+ return findings;
575
+ }
576
+
516
577
  function lintSource(source, options) {
517
578
  if (typeof source !== "string") {
518
579
  return {
@@ -557,6 +618,7 @@ function lintSource(source, options) {
557
618
  })),
558
619
  );
559
620
  findings.push(..._hostApiUrlRules(source));
621
+ findings.push(..._lucideIconRules(source));
560
622
  findings.push(
561
623
  ..._scopeRules(source, options && options.manifest).map((f) => ({
562
624
  ...f,
package/dist/linter.js CHANGED
@@ -7,6 +7,7 @@
7
7
  // system prompt, the linter, and the runtime allowlist agree.
8
8
 
9
9
  import { CONTRACT } from "./contract.js";
10
+ import { LUCIDE_ICON_NAMES, LUCIDE_VERSION } from "./lucideIconNames.js";
10
11
 
11
12
  // Per-identifier match rule. Most banned identifiers compile to a
12
13
  // whole-word match; a few have special syntax (`Function(`, `new Function`,
@@ -601,6 +602,66 @@ function _manifestActionRules(manifest) {
601
602
  return findings;
602
603
  }
603
604
 
605
+ // sc-1366 — invalid lucide icon import gate. lucide-react-native is a vetted
606
+ // import that the bundler EXTERNALISES, so esbuild never checks the named
607
+ // imports; a name that does not exist in the pinned version (`House` was added
608
+ // to lucide AFTER the pinned 0.368.0, which only has `Home`) bundles + publishes
609
+ // cleanly and fails only at runtime load ("does not provide an export named
610
+ // 'House'"), leaving the widget blank. This rule rejects such imports at publish
611
+ // so the agent's repair loop fixes them. The valid set is committed data
612
+ // (lucideIconNames.js) because the linter runs where lucide is not installed.
613
+ const LUCIDE_NAME_SET = new Set(LUCIDE_ICON_NAMES);
614
+ // `[^}]*` (not `[\s\S]*?`) so the brace capture can never cross a `}` into a
615
+ // neighbouring import — otherwise a preceding `import { View, Pressable } from
616
+ // "react-native"` is swallowed and its names get validated as lucide icons.
617
+ const LUCIDE_IMPORT_RE =
618
+ /import\s+(?:[A-Za-z0-9_$]+\s*,\s*)?\{([^}]*)\}\s*from\s*["']lucide-react-native["']/g;
619
+
620
+ // lucide re-exports each base icon as `<Name>`, `<Name>Icon`, and `Lucide<Name>`;
621
+ // the committed set holds only the base names, so normalise the alias forms
622
+ // before the membership check. No base name ends in "Icon" or starts with
623
+ // "Lucide" (asserted by lucide-icon-names.test.js), so stripping is unambiguous.
624
+ function _isValidLucideName(name) {
625
+ if (LUCIDE_NAME_SET.has(name)) return true;
626
+ if (name.endsWith("Icon") && LUCIDE_NAME_SET.has(name.slice(0, -4))) return true;
627
+ if (name.startsWith("Lucide") && LUCIDE_NAME_SET.has(name.slice(6))) return true;
628
+ return false;
629
+ }
630
+
631
+ function _lucideIconRules(source) {
632
+ const findings = [];
633
+ const sourceLines = source.split(/\r?\n/);
634
+ LUCIDE_IMPORT_RE.lastIndex = 0;
635
+ let m;
636
+ while ((m = LUCIDE_IMPORT_RE.exec(source))) {
637
+ const line = source.slice(0, m.index).split(/\r?\n/).length;
638
+ for (const raw of (m[1] || "").split(",")) {
639
+ // Strip any inline comment from the specifier token so a `{ Home, // x`
640
+ // comment can't glue onto and hide the next specifier (e.g. `House`).
641
+ const cleaned = raw
642
+ .replace(/\/\*[\s\S]*?\*\//g, "")
643
+ .replace(/\/\/[^\n]*/g, "");
644
+ const imported = cleaned.trim().split(/\s+as\s+/)[0].trim();
645
+ if (!imported || imported === "type") continue;
646
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(imported)) continue;
647
+ if (_isValidLucideName(imported)) continue;
648
+ findings.push({
649
+ rule: "lucide-unknown-icon",
650
+ severity: "error",
651
+ label:
652
+ `lucide-react-native has no export "${imported}" in the pinned ` +
653
+ `lucide-react-native@${LUCIDE_VERSION}. The widget bundles cleanly but FAILS at ` +
654
+ `runtime load ("does not provide an export named '${imported}'") and never ` +
655
+ `renders — replace it with an icon name that exists in that version (lucide ` +
656
+ `adds and renames icons across releases, so a newer name may not exist here).`,
657
+ line,
658
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
659
+ });
660
+ }
661
+ }
662
+ return findings;
663
+ }
664
+
604
665
  export function lintSource(source, options) {
605
666
  if (typeof source !== "string") {
606
667
  return {
@@ -647,6 +708,7 @@ export function lintSource(source, options) {
647
708
  );
648
709
  // REQ-WSDK-PLATFORM §3.5: soft host-API URL warning (does not block).
649
710
  findings.push(..._hostApiUrlRules(source));
711
+ findings.push(..._lucideIconRules(source));
650
712
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
651
713
  // line-by-line scan so banned-identifier findings stay first in the
652
714
  // output.