@colixsystems/widget-sdk 0.198.0 → 0.200.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/dist/cli.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // — REQ-WSDK-DEVKIT local dev server
6
6
 
7
7
  import { readFileSync } from "node:fs";
8
- import { basename, resolve } from "node:path";
8
+ import { basename, dirname, resolve } from "node:path";
9
9
  import { pathToFileURL } from "node:url";
10
10
  import { argv, exit, stderr, stdout } from "node:process";
11
11
  import {
@@ -15,6 +15,10 @@ import {
15
15
  narrowManifestForFile,
16
16
  } from "./linter.js";
17
17
  import { startDevServer } from "./devserver.js";
18
+ import {
19
+ bundledAssetDirectoryOf,
20
+ loadBundledAssets,
21
+ } from "./bundled-assets-fs.js";
18
22
 
19
23
  function usage() {
20
24
  stderr.write(
@@ -22,6 +26,8 @@ function usage() {
22
26
  " appstudio-widget lint <file.jsx...> [--manifest <path>]\n" +
23
27
  " Pass every file the widget ships plus --manifest to also check " +
24
28
  "that each styleSchema field is actually read.\n" +
29
+ " An assets/ directory beside a file is checked too: every image " +
30
+ "and sound in it must be a type the package may bundle.\n" +
25
31
  " appstudio-widget dev <entry.jsx|widget-dir> [--port <n>] [--manifest <path>]\n" +
26
32
  " A directory containing widget.json runs in multi-file mode " +
27
33
  "(REQ-WSDK-DEVKIT v2): the dev server reads the canonical web entry, " +
@@ -57,6 +63,28 @@ function parseFlags(args) {
57
63
  return { flags, positionals };
58
64
  }
59
65
 
66
+ // One read per assets/ directory, however many linted files sit beside it.
67
+ function loadAssetsBesideFiles(filePaths) {
68
+ const byDirectory = new Map();
69
+ for (const filePath of filePaths) {
70
+ const assetsDir = bundledAssetDirectoryOf(dirname(filePath));
71
+ if (!byDirectory.has(assetsDir)) {
72
+ byDirectory.set(assetsDir, loadBundledAssets(assetsDir));
73
+ }
74
+ }
75
+ return byDirectory;
76
+ }
77
+
78
+ function assetFindings(assets) {
79
+ return assets.errors.map((error) => ({
80
+ rule: error.code,
81
+ severity: "error",
82
+ label: error.message,
83
+ line: 0,
84
+ snippet: "",
85
+ }));
86
+ }
87
+
60
88
  async function loadManifest(path) {
61
89
  const abs = resolve(path);
62
90
  if (abs.endsWith(".json")) return JSON.parse(readFileSync(abs, "utf8"));
@@ -68,9 +96,8 @@ function writeFindings(findings, stream) {
68
96
  for (const f of findings) {
69
97
  const severity = f.severity === "warning" ? "warning" : "error";
70
98
  const where = f.line ? ` line ${f.line}` : "";
71
- stream.write(
72
- ` ${severity} [${f.rule}]${where}: ${f.label}\n ${f.snippet}\n`,
73
- );
99
+ const snippet = f.snippet ? ` ${f.snippet}\n` : "";
100
+ stream.write(` ${severity} [${f.rule}]${where}: ${f.label}\n${snippet}`);
74
101
  }
75
102
  }
76
103
 
@@ -104,16 +131,20 @@ async function runLint(rest) {
104
131
  }
105
132
  }
106
133
  const label = positionals.join(", ");
134
+ const assetsByDirectory = loadAssetsBesideFiles(Object.keys(files));
135
+ const assetsBeside = (filePath) =>
136
+ assetsByDirectory.get(bundledAssetDirectoryOf(dirname(filePath)));
107
137
  // Narrow per split-impl file, as the submission gate does.
108
138
  const perFile = Object.entries(files).map(([filePath, source]) => [
109
139
  filePath,
110
- lintSource(
111
- source,
112
- manifest
140
+ lintSource(source, {
141
+ assetFiles: assetsBeside(filePath).fileNames,
142
+ ...(manifest
113
143
  ? { manifest: narrowManifestForFile(manifest, basename(filePath)) }
114
- : undefined,
115
- ),
144
+ : {}),
145
+ }),
116
146
  ]);
147
+ const bundled = [...assetsByDirectory.values()].flatMap(assetFindings);
117
148
  const wiring = manifest
118
149
  ? lintStyleWiring(manifest, files)
119
150
  : { ok: true, findings: [] };
@@ -123,6 +154,7 @@ async function runLint(rest) {
123
154
  : { ok: true, findings: [] };
124
155
  const all = perFile
125
156
  .flatMap(([, r]) => r.findings)
157
+ .concat(bundled)
126
158
  .concat(wiring.findings)
127
159
  .concat(editability.findings);
128
160
  if (all.length === 0) {
@@ -132,7 +164,11 @@ async function runLint(rest) {
132
164
  // sc-3493 — a warning-severity finding used to be swallowed: `ok` stays true
133
165
  // for warnings, so the CLI printed "clean" and dropped them. A warning nobody
134
166
  // sees is pointless. Report every finding; only errors change the exit code.
135
- const ok = perFile.every(([, r]) => r.ok) && wiring.ok && editability.ok;
167
+ const ok =
168
+ perFile.every(([, r]) => r.ok) &&
169
+ bundled.length === 0 &&
170
+ wiring.ok &&
171
+ editability.ok;
136
172
  const errors = all.filter((f) => f.severity !== "warning").length;
137
173
  const stream = ok ? stdout : stderr;
138
174
  stream.write(
@@ -143,6 +179,10 @@ async function runLint(rest) {
143
179
  if (perFile.length > 1) stream.write(`${filePath}:\n`);
144
180
  writeFindings(r.findings, stream);
145
181
  }
182
+ if (bundled.length > 0) {
183
+ stream.write("assets:\n");
184
+ writeFindings(bundled, stream);
185
+ }
146
186
  if (wiring.findings.length > 0) {
147
187
  stream.write(`manifest.styleSchema:\n`);
148
188
  writeFindings(wiring.findings, stream);
@@ -185,7 +225,8 @@ async function runDev(rest) {
185
225
  ` manifest: ${handle.url}/manifest.json\n` +
186
226
  ` reload: ${handle.url}/__dev/events (SSE)\n` +
187
227
  (handle.directoryMode
188
- ? ` files: ${handle.url}/file/<relpath>\n shims: ${handle.url}/shim/<slug>\n`
228
+ ? ` files: ${handle.url}/file/<relpath>\n shims: ${handle.url}/shim/<slug>\n` +
229
+ ` assets: ${handle.url}/assets/<name>\n`
189
230
  : "") +
190
231
  (handle.manifestId ? ` id: ${handle.manifestId}\n` : "") +
191
232
  `\nIn the Studio Builder (dev mode), open the "Dev widgets" panel and add:\n` +
package/dist/contract.cjs CHANGED
@@ -1171,6 +1171,20 @@ const HOOKS = [
1171
1171
  requiredContextSlice: ["assets.list"],
1172
1172
  scopes: null,
1173
1173
  },
1174
+ {
1175
+ name: "useBundledAsset",
1176
+ signature: "useBundledAsset(fileName)",
1177
+ description:
1178
+ "Returns the URL of an image or sound file the widget package ships in " +
1179
+ "its assets/ directory, or null when the package has no file with that " +
1180
+ "name. Only an uploaded package can bundle files; a widget written in " +
1181
+ "the Studio has none.",
1182
+ returnShape: {
1183
+ "(returns)": "string | null",
1184
+ },
1185
+ requiredContextSlice: ["widget.assets"],
1186
+ scopes: null,
1187
+ },
1174
1188
  {
1175
1189
  name: "useFilestoreFiles",
1176
1190
  signature: "useFilestoreFiles({ spaceType, recordId, folderId, q, type })",
@@ -2888,6 +2902,19 @@ const ACTION_SCRIPT_GLOBALS = [
2888
2902
  // Mirrors action.service.js SCRIPT_MAX_BYTES.
2889
2903
  const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
2890
2904
 
2905
+ // The one package size limit; the backend derives its upload caps from it.
2906
+ const PACKAGE_MAX_BYTES = 12 * 1024 * 1024;
2907
+
2908
+ const BUNDLED_ASSETS = {
2909
+ directory: "assets",
2910
+ maxFiles: 50,
2911
+ maxNameLength: 64,
2912
+ types: {
2913
+ image: { extensions: ["png", "jpg", "jpeg", "gif", "webp"] },
2914
+ audio: { extensions: ["mp3", "m4a", "wav"] },
2915
+ },
2916
+ };
2917
+
2891
2918
  /**
2892
2919
  * sc-4915 — the trigger set a manifest action declares, canonically ordered.
2893
2920
  *
@@ -3071,9 +3098,15 @@ const WIDGET_CONTEXT_SHAPE = {
3071
3098
  },
3072
3099
  widget: {
3073
3100
  description:
3074
- "Identity of the currently rendered widget instance ({ id, version }).",
3101
+ "Identity of the currently rendered widget instance ({ id, version }) " +
3102
+ "and the files its package bundles.",
3075
3103
  required: true,
3076
- fields: { id: "manifest.id", version: "manifest.version" },
3104
+ fields: {
3105
+ id: "manifest.id",
3106
+ version: "manifest.version",
3107
+ assets:
3108
+ "{ [fileName]: string } — bundled file name → absolute URL; {} when the package bundles nothing",
3109
+ },
3077
3110
  },
3078
3111
  // REQ-LAY-08 — optional host layout hint backing useFill(). True when the
3079
3112
  // host sized this widget to fill its layout slot's available height (a
@@ -5268,7 +5301,11 @@ const CONTRACT = deepFreeze({
5268
5301
  // asset carrying `durationMs`. New `useHardwareBack(handler)` walks a
5269
5302
  // widget's own pages on Android's back button (optional
5270
5303
  // `ctx.navigation.onHardwareBack`; unsupported on the web).
5271
- version: "1.158.0",
5304
+ // 1.159.0: additive (sc-10721) — a package may ship images and sounds in a
5305
+ // flat `assets/` directory (`bundledAssets`, within `packageMaxBytes`).
5306
+ // New `useBundledAsset(fileName)` resolves one through the new
5307
+ // `widget.assets` map; linter rule `bundled-asset-missing` (error).
5308
+ version: "1.159.0",
5272
5309
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5273
5310
  hooks: HOOKS,
5274
5311
  primitives: PRIMITIVES,
@@ -5278,6 +5315,8 @@ const CONTRACT = deepFreeze({
5278
5315
  actionTriggerTypes: ACTION_TRIGGER_TYPES,
5279
5316
  actionScriptGlobals: ACTION_SCRIPT_GLOBALS,
5280
5317
  actionScriptMaxBytes: ACTION_SCRIPT_MAX_BYTES,
5318
+ packageMaxBytes: PACKAGE_MAX_BYTES,
5319
+ bundledAssets: BUNDLED_ASSETS,
5281
5320
  themeTokens: DEFAULT_THEME_TOKENS,
5282
5321
  themeColorTokens: THEME_COLOR_TOKENS,
5283
5322
  themeComponents: THEME_COMPONENTS,
package/dist/contract.js CHANGED
@@ -1171,6 +1171,20 @@ const HOOKS = [
1171
1171
  requiredContextSlice: ["assets.list"],
1172
1172
  scopes: null,
1173
1173
  },
1174
+ {
1175
+ name: "useBundledAsset",
1176
+ signature: "useBundledAsset(fileName)",
1177
+ description:
1178
+ "Returns the URL of an image or sound file the widget package ships in " +
1179
+ "its assets/ directory, or null when the package has no file with that " +
1180
+ "name. Only an uploaded package can bundle files; a widget written in " +
1181
+ "the Studio has none.",
1182
+ returnShape: {
1183
+ "(returns)": "string | null",
1184
+ },
1185
+ requiredContextSlice: ["widget.assets"],
1186
+ scopes: null,
1187
+ },
1174
1188
  {
1175
1189
  name: "useFilestoreFiles",
1176
1190
  signature: "useFilestoreFiles({ spaceType, recordId, folderId, q, type })",
@@ -2884,6 +2898,19 @@ const ACTION_SCRIPT_GLOBALS = [
2884
2898
  // Mirrors action.service.js SCRIPT_MAX_BYTES.
2885
2899
  const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
2886
2900
 
2901
+ // The one package size limit; the backend derives its upload caps from it.
2902
+ const PACKAGE_MAX_BYTES = 12 * 1024 * 1024;
2903
+
2904
+ const BUNDLED_ASSETS = {
2905
+ directory: "assets",
2906
+ maxFiles: 50,
2907
+ maxNameLength: 64,
2908
+ types: {
2909
+ image: { extensions: ["png", "jpg", "jpeg", "gif", "webp"] },
2910
+ audio: { extensions: ["mp3", "m4a", "wav"] },
2911
+ },
2912
+ };
2913
+
2887
2914
  /**
2888
2915
  * sc-4915 — the trigger set a manifest action declares, canonically ordered.
2889
2916
  *
@@ -3067,9 +3094,15 @@ const WIDGET_CONTEXT_SHAPE = {
3067
3094
  },
3068
3095
  widget: {
3069
3096
  description:
3070
- "Identity of the currently rendered widget instance ({ id, version }).",
3097
+ "Identity of the currently rendered widget instance ({ id, version }) " +
3098
+ "and the files its package bundles.",
3071
3099
  required: true,
3072
- fields: { id: "manifest.id", version: "manifest.version" },
3100
+ fields: {
3101
+ id: "manifest.id",
3102
+ version: "manifest.version",
3103
+ assets:
3104
+ "{ [fileName]: string } — bundled file name → absolute URL; {} when the package bundles nothing",
3105
+ },
3073
3106
  },
3074
3107
  // REQ-LAY-08 — optional host layout hint backing useFill(). True when the
3075
3108
  // host sized this widget to fill its layout slot's available height (a
@@ -5264,7 +5297,11 @@ const CONTRACT = deepFreeze({
5264
5297
  // asset carrying `durationMs`. New `useHardwareBack(handler)` walks a
5265
5298
  // widget's own pages on Android's back button (optional
5266
5299
  // `ctx.navigation.onHardwareBack`; unsupported on the web).
5267
- version: "1.158.0",
5300
+ // 1.159.0: additive (sc-10721) — a package may ship images and sounds in a
5301
+ // flat `assets/` directory (`bundledAssets`, within `packageMaxBytes`).
5302
+ // New `useBundledAsset(fileName)` resolves one through the new
5303
+ // `widget.assets` map; linter rule `bundled-asset-missing` (error).
5304
+ version: "1.159.0",
5268
5305
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5269
5306
  hooks: HOOKS,
5270
5307
  primitives: PRIMITIVES,
@@ -5274,6 +5311,8 @@ const CONTRACT = deepFreeze({
5274
5311
  actionTriggerTypes: ACTION_TRIGGER_TYPES,
5275
5312
  actionScriptGlobals: ACTION_SCRIPT_GLOBALS,
5276
5313
  actionScriptMaxBytes: ACTION_SCRIPT_MAX_BYTES,
5314
+ packageMaxBytes: PACKAGE_MAX_BYTES,
5315
+ bundledAssets: BUNDLED_ASSETS,
5277
5316
  themeTokens: DEFAULT_THEME_TOKENS,
5278
5317
  themeColorTokens: THEME_COLOR_TOKENS,
5279
5318
  themeComponents: THEME_COMPONENTS,
package/dist/devserver.js CHANGED
@@ -24,8 +24,11 @@
24
24
  // `globalThis.__appstudioWidgetHost__` — the same global the Studio's
25
25
  // blob shims populate, so the dev path stays in lockstep with the
26
26
  // published path;
27
+ // • serves the widget's bundled images and sounds at `/assets/<name>` and
28
+ // lists them at `/assets.json`;
27
29
  // • watches the whole widget directory recursively for `.jsx`/`.json` and
28
- // broadcasts `reload` to every connected client.
30
+ // anything under `assets/`, and broadcasts `reload` to every connected
31
+ // client.
29
32
  //
30
33
  // single-file mode (legacy v1 — passed a bare `.jsx` entry)
31
34
  // • original REQ-WSDK-DEVKIT behaviour. Kept so existing tasks don't
@@ -60,6 +63,15 @@ import {
60
63
  } from "node:fs";
61
64
  import { resolve, dirname, join, sep, relative, posix } from "node:path";
62
65
  import { lintSource } from "./linter.js";
66
+ import {
67
+ BUNDLED_ASSET_DIRECTORY,
68
+ isValidBundledAssetName,
69
+ sniffBundledAssetType,
70
+ } from "./bundled-assets.js";
71
+ import {
72
+ bundledAssetDirectoryOf,
73
+ loadBundledAssets,
74
+ } from "./bundled-assets-fs.js";
63
75
  import {
64
76
  getShimSource,
65
77
  shimmableSpecifiersForSiblings,
@@ -414,6 +426,16 @@ function _isUnderReal(parentRealAbs, childAbs) {
414
426
  }
415
427
  }
416
428
 
429
+ const ASSET_ROUTE_PREFIX = `/${BUNDLED_ASSET_DIRECTORY}/`;
430
+ const ASSET_PATH_RE = new RegExp(`^${BUNDLED_ASSET_DIRECTORY}[\\\\/]`);
431
+
432
+ /** True when a change to `filename` (relative to the watch root) reloads. */
433
+ export function isReloadTrigger(filename) {
434
+ if (!filename) return true;
435
+ const path = String(filename);
436
+ return /\.(jsx?|json)$/.test(path) || ASSET_PATH_RE.test(path);
437
+ }
438
+
417
439
  function setCors(res) {
418
440
  res.setHeader("Access-Control-Allow-Origin", "*");
419
441
  res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
@@ -545,6 +567,35 @@ export function createDevServer({
545
567
  return { abs, rel: normalised };
546
568
  }
547
569
 
570
+ // Single-file mode has no widget directory, so it bundles nothing.
571
+ const assetsDir = directoryMode ? bundledAssetDirectoryOf(watchRoot) : null;
572
+ const bundledAssets = () => loadBundledAssets(assetsDir);
573
+
574
+ function serveAssetList(res) {
575
+ const names = bundledAssets().assets.map((asset) => asset.name);
576
+ res.writeHead(200, { "Content-Type": "application/json" });
577
+ res.end(JSON.stringify({ assets: names }));
578
+ }
579
+
580
+ function serveAsset(res, name) {
581
+ const resolved = isValidBundledAssetName(name)
582
+ ? resolveFileRel(`${BUNDLED_ASSET_DIRECTORY}/${name}`)
583
+ : null;
584
+ const bytes = resolved ? readFileSync(resolved.abs) : null;
585
+ const type = bytes ? sniffBundledAssetType(bytes) : null;
586
+ if (!type) {
587
+ res.writeHead(404, { "Content-Type": "text/plain" });
588
+ res.end("Bundled file not found");
589
+ return;
590
+ }
591
+ res.writeHead(200, {
592
+ "Content-Type": type.mimeType,
593
+ "Content-Length": bytes.length,
594
+ "Cache-Control": "no-store",
595
+ });
596
+ res.end(bytes);
597
+ }
598
+
548
599
  function transpileWithGuards(absPath) {
549
600
  const source = readFileSync(absPath, "utf8");
550
601
  try {
@@ -754,6 +805,10 @@ export function createDevServer({
754
805
  );
755
806
  return;
756
807
  }
808
+ if (url === "/assets.json") return serveAssetList(res);
809
+ if (url.startsWith(ASSET_ROUTE_PREFIX)) {
810
+ return serveAsset(res, url.slice(ASSET_ROUTE_PREFIX.length));
811
+ }
757
812
  if (directoryMode && url.startsWith("/file/")) {
758
813
  return serveFile(req, res, url.slice("/file/".length));
759
814
  }
@@ -782,15 +837,18 @@ export function createDevServer({
782
837
  } catch {
783
838
  return;
784
839
  }
785
- const { ok, findings } = lintSource(source);
786
- if (onLint) {
787
- if (ok) onLint("lint: clean");
788
- else {
789
- onLint(`lint: ${findings.length} finding(s)`);
790
- for (const f of findings)
791
- onLint(` [${f.rule}] line ${f.line}: ${f.label}`);
792
- }
840
+ const assets = bundledAssets();
841
+ const { ok, findings } = lintSource(source, {
842
+ assetFiles: assets.fileNames,
843
+ });
844
+ if (!onLint) return;
845
+ if (ok && assets.ok) {
846
+ onLint("lint: clean");
847
+ return;
793
848
  }
849
+ onLint(`lint: ${findings.length + assets.errors.length} finding(s)`);
850
+ for (const e of assets.errors) onLint(` [${e.code}] ${e.message}`);
851
+ for (const f of findings) onLint(` [${f.rule}] line ${f.line}: ${f.label}`);
794
852
  }
795
853
 
796
854
  return {
@@ -939,7 +997,7 @@ export async function startDevServer({
939
997
  dev.watchRoot,
940
998
  { recursive: directoryMode },
941
999
  (_event, filename) => {
942
- if (filename && !/\.(jsx?|json)$/.test(String(filename))) return;
1000
+ if (!isReloadTrigger(filename)) return;
943
1001
  if (timer) clearTimeout(timer);
944
1002
  timer = setTimeout(() => {
945
1003
  timer = null;
@@ -10,5 +10,6 @@
10
10
  import * as ReactNative from "react-native-web";
11
11
  import { makeEmojiPicker } from "./emoji-picker-view.js";
12
12
  import { Overlay } from "./overlay.js";
13
+ import { TextInput } from "./primitives.js";
13
14
 
14
- export const EmojiPicker = makeEmojiPicker({ ...ReactNative, Overlay });
15
+ export const EmojiPicker = makeEmojiPicker({ ...ReactNative, TextInput, Overlay });
package/dist/hooks.js CHANGED
@@ -4334,6 +4334,20 @@ export function useAsset(assetId) {
4334
4334
  return { url, asset, loading, error, refetch };
4335
4335
  }
4336
4336
 
4337
+ /**
4338
+ * URL of a file the widget package ships in its `assets/` directory, or
4339
+ * `null` when the package bundles no file with that name.
4340
+ */
4341
+ export function useBundledAsset(fileName) {
4342
+ const ctx = useWidgetContextOrThrow("useBundledAsset");
4343
+ // An older host publishes no `widget.assets`.
4344
+ const assets = ctx.widget && ctx.widget.assets;
4345
+ if (!fileName || !assets) return null;
4346
+ return Object.prototype.hasOwnProperty.call(assets, fileName)
4347
+ ? assets[fileName]
4348
+ : null;
4349
+ }
4350
+
4337
4351
  /**
4338
4352
  * List every tenant asset carrying a given tag. Returns
4339
4353
  * `{ assets, loading, error, refetch }`. The widget passes a single `tag`
package/dist/host.d.ts CHANGED
@@ -460,6 +460,12 @@ export interface LoginDesign {
460
460
  /** The sign-in screen's authored design, or null when nothing is configured. */
461
461
  export function normaliseLoginDesign(raw: unknown): LoginDesign | null;
462
462
 
463
+ /** sc-10845 — the sign-in card's logo: the author's own, else the app logo. */
464
+ export function resolveLoginLogoUrl(
465
+ login: unknown,
466
+ appLogoUrl: string | null | undefined,
467
+ ): string | null;
468
+
463
469
  /** sc-7800 — true when a stored value is a `theme:<path>` colour binding. */
464
470
  export function isThemeTokenRef(value: unknown): boolean;
465
471
 
package/dist/host.js CHANGED
@@ -128,6 +128,7 @@ export {
128
128
  LOGIN_TEXT_MAX_LENGTH,
129
129
  normaliseThemeGradient,
130
130
  normaliseLoginDesign,
131
+ resolveLoginLogoUrl,
131
132
  } from "./theme-background.js";
132
133
 
133
134
  // sc-8448: one DATETIME local-clock conversion for the picker and the Studio.
package/dist/index.d.ts CHANGED
@@ -698,7 +698,13 @@ export interface RecordMuteWire {
698
698
 
699
699
  export interface WidgetContext<TProps = unknown> {
700
700
  props: TProps;
701
- widget: { id: string; instanceId: string; version: string };
701
+ widget: {
702
+ id: string;
703
+ instanceId: string;
704
+ version: string;
705
+ /** Bundled file name → absolute URL; `{}` when the package bundles nothing. */
706
+ assets: Record<string, string>;
707
+ };
702
708
  /**
703
709
  * REQ-LAY-08 — optional host layout hint backing `useFill()`. `true` when
704
710
  * the host sized this widget to fill its layout slot's available height (a
@@ -1614,6 +1620,18 @@ export function useAsset(assetId: string | null | undefined): {
1614
1620
  refetch(): Promise<void>;
1615
1621
  };
1616
1622
 
1623
+ /**
1624
+ * URL of an image or sound file the widget package ships in its `assets/`
1625
+ * directory, or `null` when the package has no file with that name.
1626
+ *
1627
+ * ```jsx
1628
+ * const logo = useBundledAsset("logo.png");
1629
+ * const chime = useAudioPlayer(useBundledAsset("chime.mp3"));
1630
+ * return logo ? <Image source={{ uri: logo }} /> : null;
1631
+ * ```
1632
+ */
1633
+ export function useBundledAsset(fileName: string | null | undefined): string | null;
1634
+
1617
1635
  /**
1618
1636
  * List every tenant asset carrying a given tag. Reads `ctx.assets.list({
1619
1637
  * tag, type, limit })` and unwraps the `{ data, meta }` envelope.
@@ -3705,6 +3723,12 @@ export interface LintFinding {
3705
3723
  }
3706
3724
  export interface LintOptions {
3707
3725
  manifest?: { requestedScopes?: string[]; supportedPlatforms?: string[] };
3726
+ /**
3727
+ * File names in the package's `assets/` directory. When given, a
3728
+ * `useBundledAsset("name")` literal naming a file outside the list is a
3729
+ * `bundled-asset-missing` error; omit it to skip that rule.
3730
+ */
3731
+ assetFiles?: string[];
3708
3732
  }
3709
3733
  export function lintSource(
3710
3734
  source: string,
@@ -3714,6 +3738,40 @@ export function lintSource(
3714
3738
  findings: LintFinding[];
3715
3739
  };
3716
3740
 
3741
+ // Bundled assets — the files a package ships in its `assets/` directory.
3742
+ export type BundledAssetKind = "image" | "audio";
3743
+ export type BundledAssetErrorCode =
3744
+ | "ASSET_NAME_INVALID"
3745
+ | "ASSET_TYPE_NOT_ALLOWED"
3746
+ | "ASSET_EMPTY"
3747
+ | "ASSET_CONTENT_MISMATCH"
3748
+ | "ASSET_NAME_DUPLICATE"
3749
+ | "ASSET_TOO_MANY";
3750
+ export interface BundledAssetError {
3751
+ code: BundledAssetErrorCode;
3752
+ /** `null` for an error about the directory as a whole (`ASSET_TOO_MANY`). */
3753
+ name: string | null;
3754
+ message: string;
3755
+ }
3756
+ export interface BundledAsset {
3757
+ name: string;
3758
+ kind: BundledAssetKind;
3759
+ mimeType: string;
3760
+ sizeBytes: number;
3761
+ }
3762
+ /** The package directory bundled files live in (`"assets"`); from the `./bundled-assets` entry. */
3763
+ export const BUNDLED_ASSET_DIRECTORY: string;
3764
+ /** True when `name` may be used as a file name inside `assets/`. */
3765
+ export function isValidBundledAssetName(name: unknown): boolean;
3766
+ /** Identifies a bundled file from its leading bytes; `null` when it is not an allowed type. */
3767
+ export function sniffBundledAssetType(
3768
+ bytes: Uint8Array,
3769
+ ): { kind: BundledAssetKind; mimeType: string; extensions: string[] } | null;
3770
+ /** Validates the files of an `assets/` directory; `assets` is sorted by name. */
3771
+ export function validateBundledAssets(
3772
+ files: Array<{ name: string; bytes: Uint8Array }>,
3773
+ ): { ok: boolean; errors: BundledAssetError[]; assets: BundledAsset[] };
3774
+
3717
3775
  // --------------------------------------------------------------- CONTRACT
3718
3776
  //
3719
3777
  // Single-source-of-truth contract artefact. See docs/design/ai-widget-contract.md.
@@ -3768,6 +3826,17 @@ export interface AiWidgetContract {
3768
3826
  readonly manifestSchema: Readonly<Record<string, ContractManifestField>>;
3769
3827
  readonly manifestCategories: ReadonlyArray<string>;
3770
3828
  readonly manifestPlatforms: ReadonlyArray<string>;
3829
+ /** Largest package a widget may ship, bundled files included. */
3830
+ readonly packageMaxBytes: number;
3831
+ /** What a package may ship in its `assets/` directory. */
3832
+ readonly bundledAssets: {
3833
+ readonly directory: string;
3834
+ readonly maxFiles: number;
3835
+ readonly maxNameLength: number;
3836
+ readonly types: Readonly<
3837
+ Record<BundledAssetKind, { readonly extensions: ReadonlyArray<string> }>
3838
+ >;
3839
+ };
3771
3840
  readonly themeTokens: ThemeTokens;
3772
3841
  /** sc-7800 — the brand colour tokens a `type: "color"` field may bind to. */
3773
3842
  readonly themeColorTokens: ReadonlyArray<{ path: string; label: string }>;
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ export {
39
39
  useAgent,
40
40
  useAsset,
41
41
  useAssetsByTag,
42
+ useBundledAsset,
42
43
  useFilestoreFiles,
43
44
  useFilestoreFile,
44
45
  useFilestoreUpload,
@@ -169,6 +170,11 @@ export {
169
170
  Video,
170
171
  } from "./primitives.js";
171
172
  export { lintSource, bannedIdentifiers } from "./linter.js";
173
+ export {
174
+ validateBundledAssets,
175
+ sniffBundledAssetType,
176
+ isValidBundledAssetName,
177
+ } from "./bundled-assets.js";
172
178
  // sc-3696: the trailing six are the theme-derivation helpers both hosts resolve
173
179
  // `useTheme()` with (the Player through widgetTheme.js, the export through its
174
180
  // generated theme module), plus the gradient angle projection `<Gradient>`
@@ -39,6 +39,7 @@ export {
39
39
  useAgent,
40
40
  useAsset,
41
41
  useAssetsByTag,
42
+ useBundledAsset,
42
43
  useFilestoreFiles,
43
44
  useFilestoreFile,
44
45
  useFilestoreUpload,
@@ -162,6 +163,11 @@ export {
162
163
  Video,
163
164
  } from "./primitives.native.js";
164
165
  export { lintSource, bannedIdentifiers } from "./linter.js";
166
+ export {
167
+ validateBundledAssets,
168
+ sniffBundledAssetType,
169
+ isValidBundledAssetName,
170
+ } from "./bundled-assets.js";
165
171
  // sc-3696: the trailing six are the theme-derivation helpers both hosts resolve
166
172
  // `useTheme()` with (the Player through widgetTheme.js, the export through its
167
173
  // generated theme module), plus the gradient angle projection `<Gradient>`
package/dist/linter.cjs CHANGED
@@ -1082,6 +1082,33 @@ function _jsxOpenTagEnd(source, from) {
1082
1082
  return source.length;
1083
1083
  }
1084
1084
 
1085
+ // sc-10721 — a useBundledAsset literal must name a file the package ships.
1086
+ const BUNDLED_ASSET_CALL_RE =
1087
+ /\buseBundledAsset\s*\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|`([^`]*)`)\s*[,)]/g;
1088
+
1089
+ function _bundledAssetRules(source, assetFiles) {
1090
+ if (!Array.isArray(assetFiles)) return [];
1091
+ const findings = [];
1092
+ // Comments blanked, strings kept: the file name IS a string literal.
1093
+ const code = _stripNonCode(source, { keepStrings: true });
1094
+ const sourceLines = source.split(/\r?\n/);
1095
+ for (const match of code.matchAll(BUNDLED_ASSET_CALL_RE)) {
1096
+ const name = match[1] || match[2] || match[3] || "";
1097
+ if (name.includes("${") || assetFiles.includes(name)) continue;
1098
+ const line = code.slice(0, match.index).split(/\r?\n/).length;
1099
+ findings.push({
1100
+ rule: "bundled-asset-missing",
1101
+ severity: "error",
1102
+ label:
1103
+ `useBundledAsset("${name}") names a file that is not in the package's ` +
1104
+ "assets/ directory. Add the file there or correct the name.",
1105
+ line,
1106
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
1107
+ });
1108
+ }
1109
+ return findings;
1110
+ }
1111
+
1085
1112
  // sc-4649 / sc-4686 — see linter.js for the rationale comments. The two
1086
1113
  // files must stay in lockstep (a test asserts behaviour-equivalence).
1087
1114
  const REQUEST_PAYMENT_CALL = "requestPayment(";
@@ -2015,6 +2042,7 @@ function lintSource(source, options) {
2015
2042
  findings.push(..._translationApiRules(source));
2016
2043
  findings.push(..._handBuiltPageUrlRules(source));
2017
2044
  findings.push(..._lucideIconRules(source));
2045
+ findings.push(..._bundledAssetRules(source, options && options.assetFiles));
2018
2046
  findings.push(..._selfContainedReferenceRules(source));
2019
2047
  findings.push(..._imagePercentHeightRules(source));
2020
2048
  // sc-7274 — soft warning: a percentage flexBasis sizes the main axis, so in a