@colixsystems/widget-sdk 0.64.0 → 0.66.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
@@ -53,7 +53,26 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
53
53
 
54
54
  ## Status
55
55
 
56
- `v0.64.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**.
56
+ `v0.66.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**.
57
+
58
+ ### What's new in 0.66.0
59
+
60
+ **New linter rule `image-percent-height`, and `appstudio-widget lint` finally prints warnings (sc-3493).**
61
+
62
+ - **`image-percent-height` (severity `warning`, non-blocking).** An `<Image>` / `<ImageBackground>` sized with a literal percentage `height` — `style={{ width: "100%", height: "47%" }}` — is flagged. React Native / Yoga resolves a percentage height against the **parent's** height, so under a content-sized parent it collapses to 0: the `uri` still fetches, but the image is invisible on both the web Player and the native Expo export, with nothing in the console to trace. Author fix: size it with `aspectRatio` (`{ width: "100%", aspectRatio: 1 }`) or a numeric pixel height. It is a **warning**, not an error, precisely because `height: "100%"` *is* correct inside a parent with a definite height (a fixed-height hero) and a text scan cannot tell the two apart — so the rule informs without rejecting a valid widget. Scope is the literal inline form only; a height threaded through a variable or a `StyleSheet` object is beyond an AST-free scan, and the guidance in the `useFilestoreFile` note below remains the primary guard. Comments are not scanned, so documenting the anti-pattern is safe.
63
+ - **The CLI no longer swallows warnings.** `runLint` reported `clean` and dropped every `severity: "warning"` finding whenever there were no errors, which made the existing `no-host-api-url` warning (and this new one) invisible to anyone using `appstudio-widget lint`. It now prints an `N error(s), M warning(s)` header and one line per finding tagged `error` / `warning`. **Exit codes are unchanged:** `0` when there are no error-severity findings (warnings included), `1` otherwise — so a warning still never blocks a build. `clean` is printed only when there are genuinely zero findings.
64
+
65
+ `CONTRACT` is unchanged (no new field), and no export changed signature.
66
+
67
+ ### What's new in 0.65.0
68
+
69
+ **Every file record carries `url`, and hand-built file URLs are linted (sc-3589 follow-up).** Two fixes for the same real-world failure: a `FILE` column's image silently not rendering.
70
+
71
+ - **`url` is now on every file record**, aliasing the absolutized `presigned_url`. The wire field is `presigned_url`, so the intuitive `file.url` read was `undefined` — and because the usual guard is `if (!file.url) return null`, the widget rendered *nothing*, with no error to trace. The alias is added in the filestore client's one shared normalizer, so it applies to `useFilestoreFile(id)` **and every row of `useFilestoreFiles`** — a gallery can render `files.map(f => f.url)` directly. `presigned_url` is unchanged and still present. Requires `@colixsystems/filestore-client` ≥ 0.7.0.
72
+ - **Prefer the top-level `url`:** `const { url } = useFilestoreFile(id)`. The returned `file` is `null` until the fetch resolves (and stays null for an empty `FILE` cell), so `const { file } = …; file.url` throws on the first render. `file.url` is correct only *after* you null-check `file`.
73
+ - **`no-host-api-url` now flags hand-built host paths.** The needles were only `/api/v1`, `/uploads/` and `Authorization: Bearer`, so `` `/api/files/${id}` `` — a route that does not exist — passed clean and shipped. The rule now matches `/api/files/` anywhere (so the origin-prefixed `` `${location.origin}/api/files/${id}` `` is caught too) plus a *quoted* relative `/api/` path for invented prefixes generally. Two deliberate carve-outs: **comments are not scanned**, so documenting the rule in a comment is safe; and an absolute third-party URL that merely contains `/api/` (`https://api.example.com/api/x`) never matches. If a third-party call genuinely needs a *relative* `/api/…` path against an axios `baseURL`, add `// appstudio-lint-ignore no-host-api-url` on that line or the line above.
74
+
75
+ **Never build a file URL from an id.** Filestore bytes are only reachable through a server-signed token URL, so a client-composed path can never work — always go through `useFilestoreFile`. `CONTRACT.version` → `1.44.0`. Both changes additive; no export changed signature.
57
76
 
58
77
  ### What's new in 0.64.0
59
78
 
@@ -234,8 +253,8 @@ Also: `useFileSignatures(fileIds)` is now **self-scoped** (the caller's own sign
234
253
  ### What's new in 0.30.0
235
254
 
236
255
  **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):
237
- - `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.
238
- - `useFilestoreFile(fileId)` → `{ file, url, loading, error, refetch }` — resolves ONE file id to a displayable `url` (its `presigned_url`, absolutized for web + native) via `ctx.filestore.files.get(id)`. A datastore `FILE` column holds — and reads back as — that **bare file-id string**; it is NOT hydrated into an object the way a `RELATION` (`{ id, label }`) or `USER` (`{ id, name }`) column is, so pass the value straight to the hook (no `{ id }` / `{ url }` unwrapping guard). Empty / deleted / not-found ids resolve to `url: null` so a display widget shows a fallback. Requires `files.read:*`.
256
+ - `useFilestoreFiles({ spaceType, folderId?, q?, type? })` → `{ files, loading, error, refetch }` — browses the end-user's Filestore space. The hook resolves `owner_id` from the host context (tenant for a project space, the app user for a personal space), so the widget only picks the space. Every row carries a ready-to-render `url` (its absolutized `presigned_url`), so a gallery renders `files.map(f => f.url)` directly — no per-row `useFilestoreFile` call.
257
+ - `useFilestoreFile(fileId)` → `{ file, url, loading, error, refetch }` — resolves ONE file id to a displayable `url` (its `presigned_url`, absolutized for web + native) via `ctx.filestore.files.get(id)`. **Read the top-level `url`** — the returned `file` is `null` until the fetch resolves (and for an empty id), so `file.url` throws on the first render; it is correct only after a null check. **Never compose a file URL yourself** — the bytes are served only from a server-signed token URL, so a hand-built path like `/api/files/<id>` can never resolve (the linter's `no-host-api-url` rule flags it, including the `${location.origin}/api/files/…` form). A datastore `FILE` column holds — and reads back as — that **bare file-id string**; it is NOT hydrated into an object the way a `RELATION` (`{ id, label }`) or `USER` (`{ id, name }`) column is, so pass the value straight to the hook (no `{ id }` / `{ url }` unwrapping guard). Empty / deleted / not-found ids resolve to `url: null` so a display widget shows a fallback. When you render `url` in an `<Image>` that fills its container, size it with `aspectRatio` (e.g. `{ width: "100%", aspectRatio: 1 }`) or pixels — never a percentage `height`, which React Native collapses to 0 against a content-sized parent, so the image loads but is invisible. Requires `files.read:*`.
239
258
  - `useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })` → `{ folders, loading, error, refetch }` — the folder-navigation companion to `useFilestoreFiles`; pass `enabled:false` to suspend fetching.
240
259
  - `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.
241
260
  - `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).
@@ -489,8 +508,9 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
489
508
  - **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
490
509
  - **Set the theme font on every `Text`.** React Native `Text` does not inherit `fontFamily` from a parent, so a text element that omits it falls back to the system font and ignores the workspace's configured font. Put `theme.typography.fontFamily` on every text style (a shared `StyleSheet` built from `theme` keeps it in one place) and size text with `theme.typography.sizes`.
491
510
  - **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.md` + a `colors.border` hairline or a subtle shadow. Use the status roles (`danger / success / warning / info`) for state.
511
+ - **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the native-safe way to go multi-column (widgets have no breakpoint hook, so never hard-code fixed columns). Keep wide fields (email, address, notes) full-width, cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only).
492
512
  - **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
493
- - **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme.
513
+ - **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
494
514
  - **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useAsset()`. Give every image a sized, `radii`-clipped container so it never renders as a raw rectangle, and never hardcode a credentialed image URL — expose an `image`-type property instead.
495
515
  - **Design the empty, loading, and error states.** A blank box on a fresh install reads as broken — show a short helper line when a list is empty, a calm loading line, and a single human sentence in `colors.danger` on error.
496
516
 
package/dist/cli.js CHANGED
@@ -60,15 +60,25 @@ function runLint(rest) {
60
60
  exit(1);
61
61
  }
62
62
  const { ok, findings } = lintSource(source);
63
- if (ok) {
63
+ if (findings.length === 0) {
64
64
  stdout.write(`${filePath}: clean\n`);
65
65
  exit(0);
66
66
  }
67
- stderr.write(`${filePath}: ${findings.length} finding(s)\n`);
67
+ // sc-3493 — a warning-severity finding used to be swallowed: `ok` stays true
68
+ // for warnings, so the CLI printed "clean" and dropped them. A warning nobody
69
+ // sees is pointless. Report every finding; only errors change the exit code.
70
+ const errors = findings.filter((f) => f.severity !== "warning").length;
71
+ const stream = ok ? stdout : stderr;
72
+ stream.write(
73
+ `${filePath}: ${errors} error(s), ${findings.length - errors} warning(s)\n`,
74
+ );
68
75
  for (const f of findings) {
69
- stderr.write(` [${f.rule}] line ${f.line}: ${f.label}\n ${f.snippet}\n`);
76
+ const severity = f.severity === "warning" ? "warning" : "error";
77
+ stream.write(
78
+ ` ${severity} [${f.rule}] line ${f.line}: ${f.label}\n ${f.snippet}\n`,
79
+ );
70
80
  }
71
- exit(1);
81
+ exit(ok ? 0 : 1);
72
82
  }
73
83
 
74
84
  async function runDev(rest) {
package/dist/contract.cjs CHANGED
@@ -211,10 +211,14 @@ const HOOKS = [
211
211
  name: "useFilestoreFiles",
212
212
  signature: "useFilestoreFiles({ spaceType, folderId?, q?, type? })",
213
213
  description:
214
- "Browse the end-user's Filestore files in a project or personal space. " +
215
- "The hook resolves owner_id from the host context (tenant for project, " +
216
- "app user for personal) — the widget only chooses the space. Reads " +
217
- "ctx.filestore.files.list and unwraps { data, meta } to the files array.",
214
+ "Browse the end-user's Filestore files in a project, personal, or public " +
215
+ "space. The hook resolves owner_id from the host context (tenant for " +
216
+ "project/public, app user for personal) — the widget only chooses the " +
217
+ "space. A public space is world-readable: its files render even for " +
218
+ "logged-out Player visitors. Reads ctx.filestore.files.list and unwraps " +
219
+ "{ data, meta } to the files array. Every row carries a ready-to-render " +
220
+ "`url` (its absolutized presigned_url, aliased by the filestore client) " +
221
+ "so a gallery renders `files.map(f => f.url)` directly.",
218
222
  returnShape: {
219
223
  files: "FilestoreFile[]",
220
224
  loading: "boolean",
@@ -236,7 +240,12 @@ const HOOKS = [
236
240
  "absolutized by the client so it loads on web AND native. An empty id " +
237
241
  "collapses to { file: null, url: null } with no round-trip; a deleted / " +
238
242
  "not-found id degrades the same way (url stays null, error carries the " +
239
- "wire error) so a display widget shows its fallback instead of crashing.",
243
+ "wire error) so a display widget shows its fallback instead of crashing. " +
244
+ "READ THE TOP-LEVEL `url`: `file` is null until the fetch resolves (and " +
245
+ "stays null for an empty id), so `file.url` throws on the first render. " +
246
+ "Every file record the client returns does carry the same `url` alias, " +
247
+ "so `file.url` is correct once you have null-checked `file`. " +
248
+ "NEVER build a file URL by hand from an id — no route serves one.",
240
249
  returnShape: {
241
250
  file: "FilestoreFile | null",
242
251
  url: "string | null",
@@ -295,10 +304,10 @@ const HOOKS = [
295
304
  name: "useFilestoreFolders",
296
305
  signature: "useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })",
297
306
  description:
298
- "Browse the end-user's Filestore folders in a project or personal space, " +
299
- "mirroring useFilestoreFiles for subfolder navigation. The hook resolves " +
300
- "owner_id from the host context; pass enabled:false to suspend fetching. " +
301
- "Reads ctx.filestore.folders.list and unwraps { data, meta } to the array.",
307
+ "Browse the end-user's Filestore folders in a project, personal, or public " +
308
+ "space, mirroring useFilestoreFiles for subfolder navigation. The hook " +
309
+ "resolves owner_id from the host context; pass enabled:false to suspend " +
310
+ "fetching. Reads ctx.filestore.folders.list and unwraps { data, meta } to the array.",
302
311
  returnShape: {
303
312
  folders: "FilestoreFolder[]",
304
313
  loading: "boolean",
@@ -1090,7 +1099,7 @@ const WIDGET_CONTEXT_SHAPE = {
1090
1099
  },
1091
1100
  filestore: {
1092
1101
  description:
1093
- "Injected @colixsystems/filestore-client instance — the end-user file archive (project / personal spaces) + BankID file signing. " +
1102
+ "Injected @colixsystems/filestore-client instance — the end-user file archive (project / personal / public spaces) + BankID file signing. " +
1094
1103
  "{ files: { list(query) -> Promise<{ data, meta }>, get(id), upload(formData), update(id, body), remove(id), preview(id) }, " +
1095
1104
  "folders: { list, create, update, remove }, shares: { ... }, trash: { ... }, " +
1096
1105
  "signatures: { initiate(fileId), status(id), cancel(id), verify(id) }, objectUrl(token), fetchObject(token) }. " +
@@ -1209,12 +1218,11 @@ const BUNDLE_EXPORT_CONTRACT = [
1209
1218
  // REQ-WSDK-PLATFORM (docs/design/req-widget-sdk-cross-platform-primitives.md
1210
1219
  // §3.5, §8): `fetch` and `XMLHttpRequest` are NOT banned. Widgets may call
1211
1220
  // third-party APIs directly. Same-origin requests to the host's own
1212
- // `/api/*` surface are rejected at runtime by the WidgetContextProvider's
1213
- // network gate (`no host-api access from widgets`) the JWT token is
1214
- // never shared with widget code, so the call would 401 anyway; the runtime
1215
- // gate makes the failure mode "blocked" instead of "401 noise". A soft
1216
- // linter warning (`no-host-api-url`) flags obvious host-URL substrings at
1217
- // submission so authors learn the rule statically.
1221
+ // `/api/*` surface just fail: widget code never receives the JWT, so they
1222
+ // 401 (and an invented route like `/api/files/<id>` 404s). There is no
1223
+ // runtime gate the soft linter warning (`no-host-api-url`) is the only
1224
+ // thing that catches it, which is why it flags relative `/api/` literals
1225
+ // too, not just the real `/api/v1` prefix.
1218
1226
  const BANNED_APIS = [
1219
1227
  { identifier: "eval", reason: "Arbitrary code evaluation." },
1220
1228
  {
@@ -1305,7 +1313,7 @@ const VETTED_IMPORTS = [
1305
1313
  platforms: ["web", "native"],
1306
1314
  category: "network",
1307
1315
  description:
1308
- "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.",
1316
+ "HTTP client for third-party APIs. Calls to the host's own /api/* surface do not worka widget is never given a JWT, so they 401 (and an invented path matches no route at all); use SDK hooks for workspace data.",
1309
1317
  },
1310
1318
  {
1311
1319
  specifier: "date-fns",
@@ -1513,6 +1521,18 @@ const HOST_API_URL_PATTERNS = [
1513
1521
  "/api/v1",
1514
1522
  "/uploads/",
1515
1523
  "Authorization: Bearer",
1524
+ // sc-3589 follow-up — the invented file route, matched ANYWHERE in a literal
1525
+ // so the origin-prefixed spelling (`${location.origin}/api/files/<id>`) is
1526
+ // caught as well as the bare relative one. No route serves this path.
1527
+ "/api/files/",
1528
+ // A QUOTED relative path into the host API — a broader net for invented
1529
+ // prefixes generally. Quote-anchored so an absolute third-party URL that
1530
+ // merely contains "/api/" (https://api.example.com/api/x) does not match.
1531
+ // Comments are blanked before matching, and a legitimate third-party
1532
+ // relative path can opt out with the `appstudio-lint-ignore` directive.
1533
+ '"/api/',
1534
+ "'/api/",
1535
+ "`/api/",
1516
1536
  ];
1517
1537
 
1518
1538
  function deepFreeze(value) {
@@ -1926,7 +1946,21 @@ const CONTRACT = deepFreeze({
1926
1946
  // Previously it hardcoded `color: inherit` / an unset scheme (web) and an
1927
1947
  // uncoloured trigger (native), rendering dark-on-dark. No prop changed —
1928
1948
  // behavioural fix, additive.
1929
- version: "1.43.0",
1949
+ // 1.44.0 (sc-3589 follow-up) — two FILE-display fixes. (a) The filestore
1950
+ // client's `withDisplayableUrl` normalizer now aliases the absolutized presigned_url as `url` on
1951
+ // EVERY file record, so `file.url` works for `useFilestoreFile` AND every
1952
+ // row of `useFilestoreFiles`. The wire field is `presigned_url`; reading
1953
+ // the wrong name returned undefined silently, rendering nothing with no
1954
+ // error. Aliased in the one shared normalizer, not per hook, so the
1955
+ // single-file and list shapes cannot diverge. `file` is still null while
1956
+ // loading — the top-level `url` remains the read to prefer.
1957
+ // (b) `hostApiUrlPatterns` gains `/api/files/` (matched anywhere, so the
1958
+ // origin-prefixed spelling is caught) plus quote-anchored `/api/` needles
1959
+ // for invented prefixes generally. The rule now blanks COMMENTS before
1960
+ // matching, so a comment quoting the bad path is not a finding, and a
1961
+ // legitimate third-party relative path can opt out with an
1962
+ // `appstudio-lint-ignore no-host-api-url` comment. Additive.
1963
+ version: "1.44.0",
1930
1964
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1931
1965
  hooks: HOOKS,
1932
1966
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -211,10 +211,14 @@ const HOOKS = [
211
211
  name: "useFilestoreFiles",
212
212
  signature: "useFilestoreFiles({ spaceType, folderId?, q?, type? })",
213
213
  description:
214
- "Browse the end-user's Filestore files in a project or personal space. " +
215
- "The hook resolves owner_id from the host context (tenant for project, " +
216
- "app user for personal) — the widget only chooses the space. Reads " +
217
- "ctx.filestore.files.list and unwraps { data, meta } to the files array.",
214
+ "Browse the end-user's Filestore files in a project, personal, or public " +
215
+ "space. The hook resolves owner_id from the host context (tenant for " +
216
+ "project/public, app user for personal) — the widget only chooses the " +
217
+ "space. A public space is world-readable: its files render even for " +
218
+ "logged-out Player visitors. Reads ctx.filestore.files.list and unwraps " +
219
+ "{ data, meta } to the files array. Every row carries a ready-to-render " +
220
+ "`url` (its absolutized presigned_url, aliased by the filestore client) " +
221
+ "so a gallery renders `files.map(f => f.url)` directly.",
218
222
  returnShape: {
219
223
  files: "FilestoreFile[]",
220
224
  loading: "boolean",
@@ -236,7 +240,12 @@ const HOOKS = [
236
240
  "absolutized by the client so it loads on web AND native. An empty id " +
237
241
  "collapses to { file: null, url: null } with no round-trip; a deleted / " +
238
242
  "not-found id degrades the same way (url stays null, error carries the " +
239
- "wire error) so a display widget shows its fallback instead of crashing.",
243
+ "wire error) so a display widget shows its fallback instead of crashing. " +
244
+ "READ THE TOP-LEVEL `url`: `file` is null until the fetch resolves (and " +
245
+ "stays null for an empty id), so `file.url` throws on the first render. " +
246
+ "Every file record the client returns does carry the same `url` alias, " +
247
+ "so `file.url` is correct once you have null-checked `file`. " +
248
+ "NEVER build a file URL by hand from an id — no route serves one.",
240
249
  returnShape: {
241
250
  file: "FilestoreFile | null",
242
251
  url: "string | null",
@@ -295,10 +304,10 @@ const HOOKS = [
295
304
  name: "useFilestoreFolders",
296
305
  signature: "useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })",
297
306
  description:
298
- "Browse the end-user's Filestore folders in a project or personal space, " +
299
- "mirroring useFilestoreFiles for subfolder navigation. The hook resolves " +
300
- "owner_id from the host context; pass enabled:false to suspend fetching. " +
301
- "Reads ctx.filestore.folders.list and unwraps { data, meta } to the array.",
307
+ "Browse the end-user's Filestore folders in a project, personal, or public " +
308
+ "space, mirroring useFilestoreFiles for subfolder navigation. The hook " +
309
+ "resolves owner_id from the host context; pass enabled:false to suspend " +
310
+ "fetching. Reads ctx.filestore.folders.list and unwraps { data, meta } to the array.",
302
311
  returnShape: {
303
312
  folders: "FilestoreFolder[]",
304
313
  loading: "boolean",
@@ -1090,7 +1099,7 @@ const WIDGET_CONTEXT_SHAPE = {
1090
1099
  },
1091
1100
  filestore: {
1092
1101
  description:
1093
- "Injected @colixsystems/filestore-client instance — the end-user file archive (project / personal spaces) + BankID file signing. " +
1102
+ "Injected @colixsystems/filestore-client instance — the end-user file archive (project / personal / public spaces) + BankID file signing. " +
1094
1103
  "{ files: { list(query) -> Promise<{ data, meta }>, get(id), upload(formData), update(id, body), remove(id), preview(id) }, " +
1095
1104
  "folders: { list, create, update, remove }, shares: { ... }, trash: { ... }, " +
1096
1105
  "signatures: { initiate(fileId), status(id), cancel(id), verify(id) }, objectUrl(token), fetchObject(token) }. " +
@@ -1209,12 +1218,11 @@ const BUNDLE_EXPORT_CONTRACT = [
1209
1218
  // REQ-WSDK-PLATFORM (docs/design/req-widget-sdk-cross-platform-primitives.md
1210
1219
  // §3.5, §8): `fetch` and `XMLHttpRequest` are NOT banned. Widgets may call
1211
1220
  // third-party APIs directly. Same-origin requests to the host's own
1212
- // `/api/*` surface are rejected at runtime by the WidgetContextProvider's
1213
- // network gate (`no host-api access from widgets`) the JWT token is
1214
- // never shared with widget code, so the call would 401 anyway; the runtime
1215
- // gate makes the failure mode "blocked" instead of "401 noise". A soft
1216
- // linter warning (`no-host-api-url`) flags obvious host-URL substrings at
1217
- // submission so authors learn the rule statically.
1221
+ // `/api/*` surface just fail: widget code never receives the JWT, so they
1222
+ // 401 (and an invented route like `/api/files/<id>` 404s). There is no
1223
+ // runtime gate the soft linter warning (`no-host-api-url`) is the only
1224
+ // thing that catches it, which is why it flags relative `/api/` literals
1225
+ // too, not just the real `/api/v1` prefix.
1218
1226
  const BANNED_APIS = [
1219
1227
  { identifier: "eval", reason: "Arbitrary code evaluation." },
1220
1228
  {
@@ -1305,7 +1313,7 @@ const VETTED_IMPORTS = [
1305
1313
  platforms: ["web", "native"],
1306
1314
  category: "network",
1307
1315
  description:
1308
- "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.",
1316
+ "HTTP client for third-party APIs. Calls to the host's own /api/* surface do not worka widget is never given a JWT, so they 401 (and an invented path matches no route at all); use SDK hooks for workspace data.",
1309
1317
  },
1310
1318
  {
1311
1319
  specifier: "date-fns",
@@ -1513,6 +1521,18 @@ const HOST_API_URL_PATTERNS = [
1513
1521
  "/api/v1",
1514
1522
  "/uploads/",
1515
1523
  "Authorization: Bearer",
1524
+ // sc-3589 follow-up — the invented file route, matched ANYWHERE in a literal
1525
+ // so the origin-prefixed spelling (`${location.origin}/api/files/<id>`) is
1526
+ // caught as well as the bare relative one. No route serves this path.
1527
+ "/api/files/",
1528
+ // A QUOTED relative path into the host API — a broader net for invented
1529
+ // prefixes generally. Quote-anchored so an absolute third-party URL that
1530
+ // merely contains "/api/" (https://api.example.com/api/x) does not match.
1531
+ // Comments are blanked before matching, and a legitimate third-party
1532
+ // relative path can opt out with the `appstudio-lint-ignore` directive.
1533
+ '"/api/',
1534
+ "'/api/",
1535
+ "`/api/",
1516
1536
  ];
1517
1537
 
1518
1538
  function deepFreeze(value) {
@@ -1926,7 +1946,21 @@ const CONTRACT = deepFreeze({
1926
1946
  // Previously it hardcoded `color: inherit` / an unset scheme (web) and an
1927
1947
  // uncoloured trigger (native), rendering dark-on-dark. No prop changed —
1928
1948
  // behavioural fix, additive.
1929
- version: "1.43.0",
1949
+ // 1.44.0 (sc-3589 follow-up) — two FILE-display fixes. (a) The filestore
1950
+ // client's `withDisplayableUrl` normalizer now aliases the absolutized presigned_url as `url` on
1951
+ // EVERY file record, so `file.url` works for `useFilestoreFile` AND every
1952
+ // row of `useFilestoreFiles`. The wire field is `presigned_url`; reading
1953
+ // the wrong name returned undefined silently, rendering nothing with no
1954
+ // error. Aliased in the one shared normalizer, not per hook, so the
1955
+ // single-file and list shapes cannot diverge. `file` is still null while
1956
+ // loading — the top-level `url` remains the read to prefer.
1957
+ // (b) `hostApiUrlPatterns` gains `/api/files/` (matched anywhere, so the
1958
+ // origin-prefixed spelling is caught) plus quote-anchored `/api/` needles
1959
+ // for invented prefixes generally. The rule now blanks COMMENTS before
1960
+ // matching, so a comment quoting the bad path is not a finding, and a
1961
+ // legitimate third-party relative path can opt out with an
1962
+ // `appstudio-lint-ignore no-host-api-url` comment. Additive.
1963
+ version: "1.44.0",
1930
1964
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1931
1965
  hooks: HOOKS,
1932
1966
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -1449,8 +1449,10 @@ export function useAssetsByTag(tag, options) {
1449
1449
  * ==========================================================================*/
1450
1450
 
1451
1451
  // Resolve the owner_id a filestore query needs from the host context: a PROJECT
1452
- // space is owned by the tenant (ctx.workspace.id); a PERSONAL space is owned by
1453
- // the signed-in app user (ctx.user.id). The widget only chooses the space.
1452
+ // or PUBLIC space is owned by the tenant (ctx.workspace.id); a PERSONAL space is
1453
+ // owned by the signed-in app user (ctx.user.id). The widget only chooses the
1454
+ // space. PUBLIC is world-readable, so its files resolve even when there is no
1455
+ // signed-in user (ctx.workspace.id is set for a logged-out Player visitor too).
1454
1456
  function _filestoreOwnerId(ctx, spaceType) {
1455
1457
  const space = String(spaceType || "project").toUpperCase();
1456
1458
  if (space === "PERSONAL") return (ctx.user && ctx.user.id) || null;
@@ -1546,7 +1548,8 @@ export function useFilestoreFiles(options) {
1546
1548
  * An empty / null id collapses to `{ file: null, url: null }` with no network
1547
1549
  * round-trip; a deleted or not-found id degrades the same way (`url` stays
1548
1550
  * null and `error` carries the wire error) so a display widget shows its
1549
- * fallback instead of crashing.
1551
+ * fallback instead of crashing. Read the top-level `url` — `file` is null
1552
+ * until the fetch resolves, so `file.url` throws on the first render.
1550
1553
  */
1551
1554
  export function useFilestoreFile(fileId) {
1552
1555
  const ctx = useWidgetContextOrThrow("useFilestoreFile");
package/dist/linter.cjs CHANGED
@@ -69,7 +69,7 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
69
69
  // left intact: real code lives there and must still be scanned (`${window}`
70
70
  // is a genuine escape). Backslash escapes inside strings/templates are
71
71
  // consumed so an escaped quote (`"\""`) doesn't end the literal early.
72
- function _stripNonCode(source) {
72
+ function _stripNonCode(source, { keepStrings = false } = {}) {
73
73
  let out = "";
74
74
  const n = source.length;
75
75
  let mode = "code"; // code | line | block | sq | dq | tmpl
@@ -84,6 +84,10 @@ function _stripNonCode(source) {
84
84
  const blank = (ch) => {
85
85
  out += ch === "\n" || ch === "\r" ? ch : " ";
86
86
  };
87
+ // String / template CONTENT: blanked for the banned-identifier scan
88
+ // (prose must not trip `no-window`), kept for the host-API-URL scan,
89
+ // whose whole job is to find a URL literal.
90
+ const str = keepStrings ? keep : blank;
87
91
  let i = 0;
88
92
  while (i < n) {
89
93
  const ch = source[i];
@@ -101,15 +105,15 @@ function _stripNonCode(source) {
101
105
  i += 2;
102
106
  } else if (ch === "'") {
103
107
  mode = "sq";
104
- blank(ch);
108
+ str(ch);
105
109
  i += 1;
106
110
  } else if (ch === '"') {
107
111
  mode = "dq";
108
- blank(ch);
112
+ str(ch);
109
113
  i += 1;
110
114
  } else if (ch === "`") {
111
115
  mode = "tmpl";
112
- blank(ch);
116
+ str(ch);
113
117
  i += 1;
114
118
  } else if (ch === "{") {
115
119
  braceDepth += 1;
@@ -123,7 +127,7 @@ function _stripNonCode(source) {
123
127
  ) {
124
128
  tmplStack.pop();
125
129
  mode = "tmpl";
126
- blank(ch);
130
+ str(ch);
127
131
  } else {
128
132
  keep(ch);
129
133
  }
@@ -153,12 +157,12 @@ function _stripNonCode(source) {
153
157
  } else if (mode === "sq" || mode === "dq") {
154
158
  const quote = mode === "sq" ? "'" : '"';
155
159
  if (ch === "\\") {
156
- blank(ch);
157
- if (i + 1 < n) blank(nx);
160
+ str(ch);
161
+ if (i + 1 < n) str(nx);
158
162
  i += 2;
159
163
  } else if (ch === quote) {
160
164
  mode = "code";
161
- blank(ch);
165
+ str(ch);
162
166
  i += 1;
163
167
  } else if (ch === "\n") {
164
168
  // A bare newline terminates an unterminated string in JS; bail back
@@ -167,18 +171,18 @@ function _stripNonCode(source) {
167
171
  keep(ch);
168
172
  i += 1;
169
173
  } else {
170
- blank(ch);
174
+ str(ch);
171
175
  i += 1;
172
176
  }
173
177
  } else {
174
178
  // mode === "tmpl"
175
179
  if (ch === "\\") {
176
- blank(ch);
177
- if (i + 1 < n) blank(nx);
180
+ str(ch);
181
+ if (i + 1 < n) str(nx);
178
182
  i += 2;
179
183
  } else if (ch === "`") {
180
184
  mode = "code";
181
- blank(ch);
185
+ str(ch);
182
186
  i += 1;
183
187
  } else if (ch === "$" && nx === "{") {
184
188
  // Enter an expression hole. Remember the brace depth the template
@@ -190,7 +194,7 @@ function _stripNonCode(source) {
190
194
  keep(nx);
191
195
  i += 2;
192
196
  } else {
193
- blank(ch);
197
+ str(ch);
194
198
  i += 1;
195
199
  }
196
200
  }
@@ -332,11 +336,37 @@ function _importRules(source, manifest) {
332
336
  return findings;
333
337
  }
334
338
 
339
+ // Explicit opt-out. A widget legitimately calling a THIRD-PARTY API with a
340
+ // relative path against an `axios` baseURL cannot avoid the substring match,
341
+ // and post-sc-3589 an unavoidable finding burns AI repair turns on correct
342
+ // code. The directive may sit on the offending line or the line above it.
343
+ const HOST_API_URL_IGNORE = "appstudio-lint-ignore no-host-api-url";
344
+
345
+ function _hostApiUrlIgnoredLines(source) {
346
+ const ignored = new Set();
347
+ const lines = source.split(/\r?\n/);
348
+ for (let i = 0; i < lines.length; i += 1) {
349
+ const line = lines[i];
350
+ if (!line.includes(HOST_API_URL_IGNORE)) continue;
351
+ ignored.add(i + 1);
352
+ // A directive on its OWN line also exempts the line below it. A TRAILING
353
+ // comment exempts only its own line — otherwise it would silently cover
354
+ // the next statement, which may be a genuine hand-built host URL.
355
+ if (/^\s*(\/\/|\/\*|\*)/.test(line)) ignored.add(i + 2);
356
+ }
357
+ return ignored;
358
+ }
359
+
335
360
  function _hostApiUrlRules(source) {
336
361
  const findings = [];
337
362
  const patterns = CONTRACT.hostApiUrlPatterns || [];
338
- const lines = source.split(/\r?\n/);
363
+ const ignored = _hostApiUrlIgnoredLines(source);
364
+ // Comments are blanked first: a comment or JSDoc line quoting
365
+ // `/api/files/<id>` — exactly the text the docs tell authors NOT to write —
366
+ // must not become a blocking publish check.
367
+ const lines = _stripNonCode(source, { keepStrings: true }).split(/\r?\n/);
339
368
  for (let i = 0; i < lines.length; i += 1) {
369
+ if (ignored.has(i + 1)) continue;
340
370
  const line = lines[i];
341
371
  for (const needle of patterns) {
342
372
  if (line.includes(needle)) {
@@ -344,9 +374,14 @@ function _hostApiUrlRules(source) {
344
374
  rule: "no-host-api-url",
345
375
  severity: "warning",
346
376
  label:
347
- `source contains "${needle}" — calls to the AppStudio host API ` +
348
- `are blocked at runtime (widgets get no JWT token). Use SDK ` +
349
- `hooks for workspace data; \`axios\`/\`fetch\` for third-party APIs.`,
377
+ `source contains "${needle}" — a widget cannot reach the ` +
378
+ `AppStudio host API: it is never given a JWT, so the call 401s, ` +
379
+ `and an invented path like /api/files/<id> matches no route at ` +
380
+ `all. Read workspace data through SDK hooks (a stored file via ` +
381
+ `useFilestoreFile(id).url — never a URL you build yourself). ` +
382
+ `\`axios\`/\`fetch\` remain fine for THIRD-PARTY APIs; if one ` +
383
+ `genuinely needs a relative /api path, add the comment ` +
384
+ `"${HOST_API_URL_IGNORE}" on or above the line.`,
350
385
  line: i + 1,
351
386
  snippet: line.trim().slice(0, 200),
352
387
  });
@@ -634,6 +669,64 @@ function _reactInScopeRules(source) {
634
669
  return findings;
635
670
  }
636
671
 
672
+ // sc-3466 / sc-3493 — percentage height on an <Image> collapses to 0 against a
673
+ // content-sized parent, so the image loads but renders invisible on both hosts.
674
+ // `severity: "warning"`: the same value is correct under a definite-height
675
+ // parent, which this AST-free scan cannot see. Mirror of linter.js.
676
+ const _IMAGE_TAG_RE = /<(Image|ImageBackground)\b/g;
677
+ const _PERCENT_HEIGHT_RE =
678
+ /(^|[{,;\s])height\s*:\s*(["'])\s*\d+(?:\.\d+)?\s*%\s*\2/g;
679
+
680
+ function _jsxOpenTagEnd(source, from) {
681
+ let depth = 0;
682
+ let quote = "";
683
+ for (let i = from; i < source.length; i += 1) {
684
+ const ch = source[i];
685
+ if (quote) {
686
+ if (ch === "\\") i += 1;
687
+ else if (ch === quote) quote = "";
688
+ continue;
689
+ }
690
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
691
+ else if (ch === "{") depth += 1;
692
+ else if (ch === "}") depth -= 1;
693
+ else if (ch === ">" && depth <= 0) return i;
694
+ }
695
+ return source.length;
696
+ }
697
+
698
+ function _imagePercentHeightRules(source) {
699
+ const findings = [];
700
+ const code = _stripNonCode(source, { keepStrings: true });
701
+ const sourceLines = source.split(/\r?\n/);
702
+ _IMAGE_TAG_RE.lastIndex = 0;
703
+ let tag;
704
+ while ((tag = _IMAGE_TAG_RE.exec(code))) {
705
+ const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
706
+ const attrs = code.slice(tag.index, end);
707
+ _PERCENT_HEIGHT_RE.lastIndex = 0;
708
+ const hit = _PERCENT_HEIGHT_RE.exec(attrs);
709
+ if (!hit) continue;
710
+ const line = code.slice(0, tag.index + hit.index).split(/\r?\n/).length;
711
+ findings.push({
712
+ rule: "image-percent-height",
713
+ severity: "warning",
714
+ label:
715
+ `<${tag[1]}> sizes its height with a percentage — React Native ` +
716
+ `resolves that against the PARENT's height, and a content-sized ` +
717
+ `parent has none, so it collapses to 0: the image loads but is ` +
718
+ `invisible on BOTH the web Player and the native Expo export. Size ` +
719
+ `it with aspectRatio (e.g. { width: "100%", aspectRatio: 1 }) or a ` +
720
+ `numeric pixel height. Warning only — a percentage height is correct ` +
721
+ `when the parent has a definite height (e.g. a fixed-height hero).`,
722
+ line,
723
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
724
+ });
725
+ _IMAGE_TAG_RE.lastIndex = end;
726
+ }
727
+ return findings;
728
+ }
729
+
637
730
  // Narrow a split-impl widget's manifest to the platform a single bundle file
638
731
  // ships to, so `import-platform-mismatch` lints each file against what it
639
732
  // actually targets. Mirror of linter.js.
@@ -694,6 +787,7 @@ function lintSource(source, options) {
694
787
  findings.push(..._hostApiUrlRules(source));
695
788
  findings.push(..._lucideIconRules(source));
696
789
  findings.push(..._reactInScopeRules(source));
790
+ findings.push(..._imagePercentHeightRules(source));
697
791
  findings.push(
698
792
  ..._scopeRules(source, options && options.manifest).map((f) => ({
699
793
  ...f,
package/dist/linter.js CHANGED
@@ -70,7 +70,7 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
70
70
  // left intact: real code lives there and must still be scanned (`${window}`
71
71
  // is a genuine escape). Backslash escapes inside strings/templates are
72
72
  // consumed so an escaped quote (`"\""`) doesn't end the literal early.
73
- function _stripNonCode(source) {
73
+ function _stripNonCode(source, { keepStrings = false } = {}) {
74
74
  let out = "";
75
75
  const n = source.length;
76
76
  let mode = "code"; // code | line | block | sq | dq | tmpl
@@ -85,6 +85,10 @@ function _stripNonCode(source) {
85
85
  const blank = (ch) => {
86
86
  out += ch === "\n" || ch === "\r" ? ch : " ";
87
87
  };
88
+ // String / template CONTENT: blanked for the banned-identifier scan
89
+ // (prose must not trip `no-window`), kept for the host-API-URL scan,
90
+ // whose whole job is to find a URL literal.
91
+ const str = keepStrings ? keep : blank;
88
92
  let i = 0;
89
93
  while (i < n) {
90
94
  const ch = source[i];
@@ -102,15 +106,15 @@ function _stripNonCode(source) {
102
106
  i += 2;
103
107
  } else if (ch === "'") {
104
108
  mode = "sq";
105
- blank(ch);
109
+ str(ch);
106
110
  i += 1;
107
111
  } else if (ch === '"') {
108
112
  mode = "dq";
109
- blank(ch);
113
+ str(ch);
110
114
  i += 1;
111
115
  } else if (ch === "`") {
112
116
  mode = "tmpl";
113
- blank(ch);
117
+ str(ch);
114
118
  i += 1;
115
119
  } else if (ch === "{") {
116
120
  braceDepth += 1;
@@ -124,7 +128,7 @@ function _stripNonCode(source) {
124
128
  ) {
125
129
  tmplStack.pop();
126
130
  mode = "tmpl";
127
- blank(ch);
131
+ str(ch);
128
132
  } else {
129
133
  keep(ch);
130
134
  }
@@ -154,12 +158,12 @@ function _stripNonCode(source) {
154
158
  } else if (mode === "sq" || mode === "dq") {
155
159
  const quote = mode === "sq" ? "'" : '"';
156
160
  if (ch === "\\") {
157
- blank(ch);
158
- if (i + 1 < n) blank(nx);
161
+ str(ch);
162
+ if (i + 1 < n) str(nx);
159
163
  i += 2;
160
164
  } else if (ch === quote) {
161
165
  mode = "code";
162
- blank(ch);
166
+ str(ch);
163
167
  i += 1;
164
168
  } else if (ch === "\n") {
165
169
  // A bare newline terminates an unterminated string in JS; bail back
@@ -168,18 +172,18 @@ function _stripNonCode(source) {
168
172
  keep(ch);
169
173
  i += 1;
170
174
  } else {
171
- blank(ch);
175
+ str(ch);
172
176
  i += 1;
173
177
  }
174
178
  } else {
175
179
  // mode === "tmpl"
176
180
  if (ch === "\\") {
177
- blank(ch);
178
- if (i + 1 < n) blank(nx);
181
+ str(ch);
182
+ if (i + 1 < n) str(nx);
179
183
  i += 2;
180
184
  } else if (ch === "`") {
181
185
  mode = "code";
182
- blank(ch);
186
+ str(ch);
183
187
  i += 1;
184
188
  } else if (ch === "$" && nx === "{") {
185
189
  // Enter an expression hole. Remember the brace depth the template
@@ -191,7 +195,7 @@ function _stripNonCode(source) {
191
195
  keep(nx);
192
196
  i += 2;
193
197
  } else {
194
- blank(ch);
198
+ str(ch);
195
199
  i += 1;
196
200
  }
197
201
  }
@@ -204,10 +208,10 @@ function _stripNonCode(source) {
204
208
  //
205
209
  // REQ-WSDK-PLATFORM: `no-axios-import` is GONE. axios is on the vetted
206
210
  // import list now (`CONTRACT.vettedImports`) — widgets may call third-party
207
- // APIs directly. Calls to the host's own /api/* surface are blocked at
208
- // runtime by the WidgetContextProvider's network gate; the soft
209
- // `no-host-api-url` rule below flags obvious host-URL substrings so
210
- // authors learn the rule statically.
211
+ // APIs directly. Calls to the host's own /api/* surface are not gated at
212
+ // runtime they simply 401 (widget code never gets the JWT), so the soft
213
+ // `no-host-api-url` rule below is the only thing that catches them, and it
214
+ // flags relative `/api/` literals as well as the real `/api/v1` prefix.
211
215
  const EXTRA_RULES = [
212
216
  {
213
217
  id: "no-auth-store-import",
@@ -369,14 +373,43 @@ function _importRules(source, manifest) {
369
373
  }
370
374
 
371
375
  // REQ-WSDK-PLATFORM §3.5: soft warning when source contains host-API URL
372
- // substrings. NOT a hard block false positives are possible (a widget
373
- // that happens to call a third-party API also located at `/api`). The
374
- // marketplace review queue surfaces the warning for a human pass.
376
+ // substrings. `severity: "warning"` so a HUMAN-authored submission still
377
+ // publishes and the marketplace review queue flags it for a human pass.
378
+ // sc-3589 an AI-agent widget has no such review queue, so its publish loop
379
+ // treats every finding (this one included) as a blocking check that drives a
380
+ // repair turn; a lone unrepaired one still publishes with a warning.
381
+ // False positives are possible: the needles are plain substring matches.
382
+ // Explicit opt-out. A widget legitimately calling a THIRD-PARTY API with a
383
+ // relative path against an `axios` baseURL cannot avoid the substring match,
384
+ // and post-sc-3589 an unavoidable finding burns AI repair turns on correct
385
+ // code. The directive may sit on the offending line or the line above it.
386
+ const HOST_API_URL_IGNORE = "appstudio-lint-ignore no-host-api-url";
387
+
388
+ function _hostApiUrlIgnoredLines(source) {
389
+ const ignored = new Set();
390
+ const lines = source.split(/\r?\n/);
391
+ for (let i = 0; i < lines.length; i += 1) {
392
+ const line = lines[i];
393
+ if (!line.includes(HOST_API_URL_IGNORE)) continue;
394
+ ignored.add(i + 1);
395
+ // A directive on its OWN line also exempts the line below it. A TRAILING
396
+ // comment exempts only its own line — otherwise it would silently cover
397
+ // the next statement, which may be a genuine hand-built host URL.
398
+ if (/^\s*(\/\/|\/\*|\*)/.test(line)) ignored.add(i + 2);
399
+ }
400
+ return ignored;
401
+ }
402
+
375
403
  function _hostApiUrlRules(source) {
376
404
  const findings = [];
377
405
  const patterns = CONTRACT.hostApiUrlPatterns || [];
378
- const lines = source.split(/\r?\n/);
406
+ const ignored = _hostApiUrlIgnoredLines(source);
407
+ // Comments are blanked first: a comment or JSDoc line quoting
408
+ // `/api/files/<id>` — exactly the text the docs tell authors NOT to write —
409
+ // must not become a blocking publish check.
410
+ const lines = _stripNonCode(source, { keepStrings: true }).split(/\r?\n/);
379
411
  for (let i = 0; i < lines.length; i += 1) {
412
+ if (ignored.has(i + 1)) continue;
380
413
  const line = lines[i];
381
414
  for (const needle of patterns) {
382
415
  if (line.includes(needle)) {
@@ -384,9 +417,14 @@ function _hostApiUrlRules(source) {
384
417
  rule: "no-host-api-url",
385
418
  severity: "warning",
386
419
  label:
387
- `source contains "${needle}" — calls to the AppStudio host API ` +
388
- `are blocked at runtime (widgets get no JWT token). Use SDK ` +
389
- `hooks for workspace data; \`axios\`/\`fetch\` for third-party APIs.`,
420
+ `source contains "${needle}" — a widget cannot reach the ` +
421
+ `AppStudio host API: it is never given a JWT, so the call 401s, ` +
422
+ `and an invented path like /api/files/<id> matches no route at ` +
423
+ `all. Read workspace data through SDK hooks (a stored file via ` +
424
+ `useFilestoreFile(id).url — never a URL you build yourself). ` +
425
+ `\`axios\`/\`fetch\` remain fine for THIRD-PARTY APIs; if one ` +
426
+ `genuinely needs a relative /api path, add the comment ` +
427
+ `"${HOST_API_URL_IGNORE}" on or above the line.`,
390
428
  line: i + 1,
391
429
  snippet: line.trim().slice(0, 200),
392
430
  });
@@ -723,6 +761,82 @@ function _reactInScopeRules(source) {
723
761
  return findings;
724
762
  }
725
763
 
764
+ // sc-3466 / sc-3493 — percentage height on an <Image> collapses to 0.
765
+ // React Native / Yoga resolves a percentage `height` against the PARENT's
766
+ // height; a content-sized parent has none, so the value resolves to 0 and the
767
+ // image fetches its uri but renders invisible on BOTH the web Player and the
768
+ // native Expo export. sc-3466 taught the rule to the AI widget agent's
769
+ // DEFAULT_SYSTEM_PROMPT, which remains the primary guard — this is the
770
+ // mechanical belt-and-braces catch for a model (or a human author) that
771
+ // ignores it.
772
+ //
773
+ // `severity: "warning"` deliberately: `height: "100%"` IS correct inside a
774
+ // parent with a definite height (a fixed-height hero), which the AST-free scan
775
+ // cannot see, so a blocking rule would reject valid widgets.
776
+ //
777
+ // Scope is the literal inline form only. A height threaded through a variable
778
+ // or a StyleSheet object stays out of reach of a text scan.
779
+ const _IMAGE_TAG_RE = /<(Image|ImageBackground)\b/g;
780
+ const _PERCENT_HEIGHT_RE =
781
+ /(^|[{,;\s])height\s*:\s*(["'])\s*\d+(?:\.\d+)?\s*%\s*\2/g;
782
+
783
+ // Index of the `>` closing the JSX opening tag that starts at `from`. Braces
784
+ // and string literals are skipped so a `>` inside `onPress={() => …}` or an
785
+ // attribute string can't end the tag early.
786
+ function _jsxOpenTagEnd(source, from) {
787
+ let depth = 0;
788
+ let quote = "";
789
+ for (let i = from; i < source.length; i += 1) {
790
+ const ch = source[i];
791
+ if (quote) {
792
+ if (ch === "\\") i += 1;
793
+ else if (ch === quote) quote = "";
794
+ continue;
795
+ }
796
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
797
+ else if (ch === "{") depth += 1;
798
+ else if (ch === "}") depth -= 1;
799
+ else if (ch === ">" && depth <= 0) return i;
800
+ }
801
+ return source.length;
802
+ }
803
+
804
+ function _imagePercentHeightRules(source) {
805
+ const findings = [];
806
+ // Comments are blanked (string contents kept) so a commented-out example —
807
+ // including the one in this rule's own docs — is never flagged.
808
+ const code = _stripNonCode(source, { keepStrings: true });
809
+ const sourceLines = source.split(/\r?\n/);
810
+ _IMAGE_TAG_RE.lastIndex = 0;
811
+ let tag;
812
+ while ((tag = _IMAGE_TAG_RE.exec(code))) {
813
+ const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
814
+ const attrs = code.slice(tag.index, end);
815
+ _PERCENT_HEIGHT_RE.lastIndex = 0;
816
+ const hit = _PERCENT_HEIGHT_RE.exec(attrs);
817
+ if (!hit) continue;
818
+ const line = code.slice(0, tag.index + hit.index).split(/\r?\n/).length;
819
+ findings.push({
820
+ rule: "image-percent-height",
821
+ severity: "warning",
822
+ label:
823
+ `<${tag[1]}> sizes its height with a percentage — React Native ` +
824
+ `resolves that against the PARENT's height, and a content-sized ` +
825
+ `parent has none, so it collapses to 0: the image loads but is ` +
826
+ `invisible on BOTH the web Player and the native Expo export. Size ` +
827
+ `it with aspectRatio (e.g. { width: "100%", aspectRatio: 1 }) or a ` +
828
+ `numeric pixel height. Warning only — a percentage height is correct ` +
829
+ `when the parent has a definite height (e.g. a fixed-height hero).`,
830
+ line,
831
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
832
+ });
833
+ // One finding per <Image>; a second percentage height on the same tag is
834
+ // the same defect.
835
+ _IMAGE_TAG_RE.lastIndex = end;
836
+ }
837
+ return findings;
838
+ }
839
+
726
840
  /**
727
841
  * Narrow a split-impl widget's manifest to the platform a single bundle file
728
842
  * ships to, so `import-platform-mismatch` lints each file against what it
@@ -790,6 +904,8 @@ export function lintSource(source, options) {
790
904
  findings.push(..._lucideIconRules(source));
791
905
  // sc-2353 — widget source must be self-contained (reference React ⇒ import it).
792
906
  findings.push(..._reactInScopeRules(source));
907
+ // sc-3493 — soft warning: percentage height on an <Image> collapses to 0.
908
+ findings.push(..._imagePercentHeightRules(source));
793
909
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
794
910
  // line-by-line scan so banned-identifier findings stay first in the
795
911
  // output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.64.0",
3
+ "version": "0.66.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "node scripts/build.js",
51
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-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-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js"
51
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"