@colixsystems/widget-sdk 0.186.0 → 0.188.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
@@ -20,7 +20,8 @@ The data layer lives in **four separate domain-client packages**, each instantia
20
20
  | **CORE** | `useTheme()` | `{ colors, elevation, interaction, spacing, spacingScale, radii, typography, components, widgetStyles }` | `ctx.workspace.theme` — no scope. `elevation` is the shared depth scale (`none / sm / md / lg / xl`) you spread into a style; `colors` includes the accent's quiet tiers (`primarySoft` / `onPrimarySoft` / `primaryStrong`) and `loader`, the spinner colour for your own loading state. `components` is HOST-OWNED (the theme's per-component style tokens); the host has already folded it into your `props.style`, so read `useWidgetStyle()` and ignore this slice. |
21
21
  | **CORE** | `useWorkspaceCurrency()` | `{ currency, formatMoney }` | `ctx.workspace.currency` — no scope. The currency this workspace charges its app users in, resolved at RENDER time. Render every price as `formatMoney(minorUnits)` and never write a currency symbol or code into a widget: the owner can change it after the widget ships, and a baked label then contradicts the charge. |
22
22
  | **CORE** | `useWidgetStyle()` | `{ [styleField]: value }` | `ctx.props.style` — no scope. The author-set per-widget style values declared in `manifest.styleSchema`; apply each onto whatever element you choose. |
23
- | **CORE** | `useUser()` | `{ id, email, displayName, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope |
23
+ | **CORE** | `useUser()` | `{ id, email, displayName, photoUrl, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope. `photoUrl` is the user's own profile photo as an absolute URL, or `null`. |
24
+ | **CORE** | `useUserPhoto()` | `{ url, canEdit, upload, remove, uploading, error }` | `ctx.user` + the optional `ctx.account` — no scope. Changes the signed-in user's OWN profile photo. `canEdit` is false when nobody is signed in or the host cannot change it; `upload` / `remove` then reject. |
24
25
  | **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute, openLink }` | `ctx.navigation` — no scope. `goTo(pageIdOrSlug, params?)` accepts a page UUID (a `pageRef` prop) **or its slug** (a row's page-key field) — both hosts resolve either (`openLink` for a link of unknown shape; a known external URL can also use the `Linking` primitive) |
25
26
  | **CORE** | `useRouteParams()` | `{ [paramKey]: value }` | `ctx.navigation.currentRoute.params` — no scope. The nav params the previous page passed via `goTo(pageId, params)`; the flat accessor for master→detail (read `recordId` on a detail page). Empty object when none. |
26
27
  | **CORE** | `usePageContext()` | `{ params, records }` | `ctx.pageContext` — no scope. The page's DECLARED parameters, resolved once by the host: `params` are coerced to their declared types, `records` holds the row already fetched for each `record` param (read it instead of fetching again). Both empty when the page declares none. |
@@ -74,8 +75,75 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
74
75
 
75
76
  ## Status
76
77
 
77
- `v0.186.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**.
78
+ `v0.188.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**.
78
79
 
80
+ ### What's new in 0.188.0 (contract 1.149.0)
81
+
82
+ **New linter rules for Leaflet maps: `leaflet-css-missing` and `leaflet-default-marker-icon`.** A widget's web entry is bundled with its own copy of Leaflet, and nothing else loads Leaflet's stylesheet. A map that skipped `import "leaflet/dist/leaflet.css"` drew a small patch of tiles inside an otherwise empty box, with its panes out of place and its markers invisible. Even with the stylesheet, Leaflet looks up its default marker image relative to the page, which 404s inside a widget, so a `<Marker>` with no `icon` showed no pin.
83
+
84
+ - **`leaflet-css-missing` (severity `error`).** A file that imports `leaflet` or `react-leaflet` code (an image-only import does not count) must also import `"leaflet/dist/leaflet.css"`. The bundler turns that import into an injected `<style>`. Import it in every file that uses Leaflet; the bundler includes it once.
85
+ - **`leaflet-default-marker-icon` (severity `warning`).** A react-leaflet `<Marker>` with no `icon` prop. Import the marker PNGs (the bundler inlines them as data URLs), build the icon once at module scope and pass it in:
86
+
87
+ ```jsx
88
+ import L from "leaflet";
89
+ import "leaflet/dist/leaflet.css";
90
+ import markerIcon from "leaflet/dist/images/marker-icon.png";
91
+ import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
92
+ import markerShadow from "leaflet/dist/images/marker-shadow.png";
93
+
94
+ const pin = L.icon({
95
+ iconUrl: markerIcon, iconRetinaUrl: markerIcon2x, shadowUrl: markerShadow,
96
+ iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41],
97
+ });
98
+ // <Marker position={[lat, lng]} icon={pin} />
99
+ ```
100
+
101
+ It is a warning because a widget that configures `L.Icon.Default` itself is fine, and is not flagged. `<CircleMarker>` needs no icon.
102
+
103
+ The `leaflet` and `react-leaflet` entries in `CONTRACT.vettedImports` now say the same. Comments are not scanned, so documenting the pattern is safe.
104
+
105
+ ### What's new in 0.187.0 (contract 1.148.0)
106
+
107
+ **App users have a profile photo, and a widget can show and change it.** `useUser()` now returns `photoUrl`, and the new `useUserPhoto()` hook lets the signed-in person replace or remove their own picture:
108
+
109
+ ```jsx
110
+ import { useUser, useUserPhoto, Avatar, FilePicker, Pressable, Text, View } from "@colixsystems/widget-sdk";
111
+
112
+ function ProfileHeader() {
113
+ const user = useUser();
114
+ const { canEdit, upload, remove, uploading, error } = useUserPhoto();
115
+
116
+ return (
117
+ <View>
118
+ <Avatar name={user.displayName} url={user.photoUrl} size="xl" />
119
+ {canEdit ? (
120
+ <FilePicker
121
+ accept="image/png,image/jpeg,image/webp"
122
+ disabled={uploading}
123
+ onPick={(file) => upload(file).catch(() => {})}
124
+ >
125
+ <Text>{uploading ? "Saving…" : "Change photo"}</Text>
126
+ </FilePicker>
127
+ ) : null}
128
+ {canEdit && user.photoUrl ? (
129
+ <Pressable disabled={uploading} onPress={() => remove().catch(() => {})}>
130
+ <Text>Remove photo</Text>
131
+ </Pressable>
132
+ ) : null}
133
+ {error ? <Text>{error.status === 413 ? "That image is too large." : "Could not save the photo."}</Text> : null}
134
+ </View>
135
+ );
136
+ }
137
+ ```
138
+
139
+ - **`useUser().photoUrl`** is an absolute URL you pass straight to `<Avatar url>` or `<Image source={{ uri }}>`. It is `null` when no photo is set, for anonymous visitors, and on a host that does not provide one. `<Avatar>` falls back to the name's initials, so the slot is never blank.
140
+ - **`useUserPhoto()`** returns `{ url, canEdit, upload, remove, uploading, error }`. `url` is the same value as `useUser().photoUrl`. `upload(file)` takes the file `<FilePicker>` gives you, or `asset.file` from `useCamera()` / `useImageEditor()` — never the asset itself (a browser `File` on web, expo-file-system's `File` in the exported app) and resolves `{ photoUrl }`; `remove()` resolves `{ photoUrl: null }`. Once either resolves, every widget on the page reads the new `useUser().photoUrl`.
141
+ - **The server crops the image to a square and resizes it.** PNG, JPEG or WebP, 10 MB at most. A refusal carries `err.status`: `413` too large, `415` unsupported type, `400` unreadable image.
142
+ - **Check `canEdit` before offering the control.** It is false when nobody is signed in and on a host that cannot change the photo, such as the Builder canvas preview. `upload()` and `remove()` then reject (`"Sign in to change your photo"` / `"Changing your photo is not available here"`) and set `error`; the hook itself never throws at render.
143
+ - **Only the signed-in user's own photo.** There is no way to read another user's photo: `useUsers()` and `useDirectory()` rows do not carry one.
144
+ - **No new scope.** The hook only ever touches the caller's own account.
145
+
146
+ A host backs the hook with the new optional `WidgetContext.account` slice, `{ setPhoto(file), removePhoto() }`. A host without it needs no change: `photoUrl` reads `null` and `canEdit` is false.
79
147
  ### What's new in 0.186.0 (contract 1.147.0)
80
148
 
81
149
  **A page's top navigation bar puts its tabs on the left or the right; the centred option is gone.** `resolveTopBarTokens().tabAlignment` is now `"left"` | `"right"`. A `theme_config.topBar.tabAlignment` of `"center"` resolves to `"left"`, like any other unknown value, and the Studio drops it the next time the theme is saved. Host-integration surface only — no author-facing hook, prop, primitive, or manifest field changed.
package/dist/contract.cjs CHANGED
@@ -808,12 +808,39 @@ const HOOKS = [
808
808
  id: "string | null",
809
809
  email: "string | null",
810
810
  displayName: "string | null",
811
+ photoUrl:
812
+ "string | null // the signed-in user's own profile photo, an absolute URL ready for <Avatar url> or <Image source={{ uri }}>; null when none is set, for anonymous visitors, and on a host that does not provide it",
811
813
  roles: "string[]",
812
814
  groupIds: "string[]",
813
815
  },
814
816
  requiredContextSlice: ["user"],
815
817
  scopes: null,
816
818
  },
819
+ {
820
+ name: "useUserPhoto",
821
+ signature: "useUserPhoto()",
822
+ description:
823
+ "Read and change the signed-in user's OWN profile photo. url mirrors useUser().photoUrl. upload(file) takes a browser " +
824
+ "File on web and expo-file-system's File on native: the file <FilePicker> gives you, or asset.file from useCamera() / " +
825
+ "useImageEditor() — NEVER the asset itself — and the host sends it; never build a FormData or call an endpoint yourself. The server crops " +
826
+ "the image to a square and resizes it. PNG, JPEG or WebP, 10 MB at most; a refusal carries err.status: 413 too large, " +
827
+ "415 unsupported type, 400 unreadable image. remove() clears the photo. After either resolves, useUser().photoUrl and " +
828
+ "url update in every widget on the page. canEdit is false when nobody is signed in and on a host that cannot change " +
829
+ "the photo (the Builder canvas preview): hide or disable the control then — upload() / remove() reject with an Error and " +
830
+ "set error rather than the hook throwing at render, as does upload() with no file. uploading is true while any call is in flight; error holds the last " +
831
+ "failure and clears on the next attempt. Needs no scope: it only ever touches the caller's own account. There is NO way " +
832
+ "to read another user's photo — useUsers() and useDirectory() rows carry none. Identical on web (Player) and the Expo export.",
833
+ returnShape: {
834
+ url: "string | null // same value as useUser().photoUrl",
835
+ canEdit: "boolean // signed in AND the host can change the photo",
836
+ upload: "(file: File) => Promise<{ photoUrl: string | null }>",
837
+ remove: "() => Promise<{ photoUrl: null }>",
838
+ uploading: "boolean",
839
+ error: "Error | null",
840
+ },
841
+ requiredContextSlice: ["user", "account.setPhoto", "account.removePhoto"],
842
+ scopes: null,
843
+ },
817
844
  {
818
845
  name: "useFill",
819
846
  signature: "useFill()",
@@ -2792,9 +2819,26 @@ const WIDGET_CONTEXT_SHAPE = {
2792
2819
  },
2793
2820
  user: {
2794
2821
  description:
2795
- "Signed-in user, host-built context object (camelCase: { id, email, displayName, roles, groupIds }) — NOT a wire payload, so it is the one camelCase island among the hooks. Not a data-client.",
2822
+ "Signed-in user, host-built context object (camelCase: { id, email, displayName, photoUrl, roles, groupIds }) — NOT a wire payload, so it is the one camelCase island among the hooks. photoUrl is the user's own profile photo as an absolute URL, or null. Not a data-client.",
2796
2823
  required: true,
2797
- fields: { id: "string", email: "string", displayName: "string" },
2824
+ fields: {
2825
+ id: "string",
2826
+ email: "string",
2827
+ displayName: "string",
2828
+ photoUrl: "string | null",
2829
+ },
2830
+ },
2831
+ // Optional so a host that cannot change the photo needs no stub:
2832
+ // useUserPhoto() reports canEdit false when it is absent.
2833
+ account: {
2834
+ description:
2835
+ "Optional self-service account actions for the signed-in user: " +
2836
+ "{ setPhoto(file) -> Promise<{ photoUrl }>, removePhoto() -> Promise<{ photoUrl: null }> }. " +
2837
+ "Backs useUserPhoto(). The host performs the request AND updates its own session, so user.photoUrl changes for every " +
2838
+ "widget. setPhoto takes a browser File on web and expo-file-system's File on native. Absent on a host that cannot " +
2839
+ "change the photo (the Builder canvas preview), where useUserPhoto() degrades to canEdit: false rather than throwing.",
2840
+ required: false,
2841
+ fields: { setPhoto: "function", removePhoto: "function" },
2798
2842
  },
2799
2843
  workspace: {
2800
2844
  description:
@@ -3252,13 +3296,16 @@ const VETTED_IMPORTS = [
3252
3296
  platforms: ["web"],
3253
3297
  category: "geo",
3254
3298
  description:
3255
- "Web-only mapping library. Use alongside react-leaflet in widget.web.jsx as the web counterpart to react-native-maps.",
3299
+ "Web-only mapping library. Use alongside react-leaflet in widget.web.jsx as the web counterpart to react-native-maps. " +
3300
+ 'Every file that imports it must also `import "leaflet/dist/leaflet.css"` (the linter enforces it), and a marker needs an ' +
3301
+ "explicit `L.icon` built from the imported `leaflet/dist/images/*.png`: Leaflet's default icon cannot resolve its image inside a bundle.",
3256
3302
  },
3257
3303
  {
3258
3304
  specifier: "react-leaflet",
3259
3305
  platforms: ["web"],
3260
3306
  category: "geo",
3261
- description: "React bindings for leaflet. Web-only.",
3307
+ description:
3308
+ 'React bindings for leaflet. Web-only. Import "leaflet/dist/leaflet.css" alongside it and pass `icon` to every <Marker>.',
3262
3309
  },
3263
3310
  {
3264
3311
  specifier: "expo-audio",
@@ -4815,7 +4862,16 @@ const CONTRACT = deepFreeze({
4815
4862
  // icon, figure and delta wrap in a tile too narrow to hold them.
4816
4863
  // 1.147.0: narrowing (sc-10123) — `resolveTopBarTokens().tabAlignment` is
4817
4864
  // "left" | "right". A stored "center" resolves to "left".
4818
- version: "1.147.0",
4865
+ // 1.148.0: additive (sc-10142) — `useUser()` returns `photoUrl`, the
4866
+ // signed-in user's own profile photo (null when unset or not provided).
4867
+ // New `useUserPhoto()` changes it through the new OPTIONAL context
4868
+ // slice `account` ({ setPhoto, removePhoto }); a host without it reads
4869
+ // `canEdit: false`.
4870
+ // 1.149.0: narrowing (sc-10466) — new linter rule `leaflet-css-missing`
4871
+ // (error): a file importing leaflet / react-leaflet must import
4872
+ // "leaflet/dist/leaflet.css". `leaflet-default-marker-icon` (warning)
4873
+ // flags a react-leaflet <Marker> with no `icon`.
4874
+ version: "1.149.0",
4819
4875
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4820
4876
  hooks: HOOKS,
4821
4877
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -808,12 +808,39 @@ const HOOKS = [
808
808
  id: "string | null",
809
809
  email: "string | null",
810
810
  displayName: "string | null",
811
+ photoUrl:
812
+ "string | null // the signed-in user's own profile photo, an absolute URL ready for <Avatar url> or <Image source={{ uri }}>; null when none is set, for anonymous visitors, and on a host that does not provide it",
811
813
  roles: "string[]",
812
814
  groupIds: "string[]",
813
815
  },
814
816
  requiredContextSlice: ["user"],
815
817
  scopes: null,
816
818
  },
819
+ {
820
+ name: "useUserPhoto",
821
+ signature: "useUserPhoto()",
822
+ description:
823
+ "Read and change the signed-in user's OWN profile photo. url mirrors useUser().photoUrl. upload(file) takes a browser " +
824
+ "File on web and expo-file-system's File on native: the file <FilePicker> gives you, or asset.file from useCamera() / " +
825
+ "useImageEditor() — NEVER the asset itself — and the host sends it; never build a FormData or call an endpoint yourself. The server crops " +
826
+ "the image to a square and resizes it. PNG, JPEG or WebP, 10 MB at most; a refusal carries err.status: 413 too large, " +
827
+ "415 unsupported type, 400 unreadable image. remove() clears the photo. After either resolves, useUser().photoUrl and " +
828
+ "url update in every widget on the page. canEdit is false when nobody is signed in and on a host that cannot change " +
829
+ "the photo (the Builder canvas preview): hide or disable the control then — upload() / remove() reject with an Error and " +
830
+ "set error rather than the hook throwing at render, as does upload() with no file. uploading is true while any call is in flight; error holds the last " +
831
+ "failure and clears on the next attempt. Needs no scope: it only ever touches the caller's own account. There is NO way " +
832
+ "to read another user's photo — useUsers() and useDirectory() rows carry none. Identical on web (Player) and the Expo export.",
833
+ returnShape: {
834
+ url: "string | null // same value as useUser().photoUrl",
835
+ canEdit: "boolean // signed in AND the host can change the photo",
836
+ upload: "(file: File) => Promise<{ photoUrl: string | null }>",
837
+ remove: "() => Promise<{ photoUrl: null }>",
838
+ uploading: "boolean",
839
+ error: "Error | null",
840
+ },
841
+ requiredContextSlice: ["user", "account.setPhoto", "account.removePhoto"],
842
+ scopes: null,
843
+ },
817
844
  {
818
845
  name: "useFill",
819
846
  signature: "useFill()",
@@ -2788,9 +2815,26 @@ const WIDGET_CONTEXT_SHAPE = {
2788
2815
  },
2789
2816
  user: {
2790
2817
  description:
2791
- "Signed-in user, host-built context object (camelCase: { id, email, displayName, roles, groupIds }) — NOT a wire payload, so it is the one camelCase island among the hooks. Not a data-client.",
2818
+ "Signed-in user, host-built context object (camelCase: { id, email, displayName, photoUrl, roles, groupIds }) — NOT a wire payload, so it is the one camelCase island among the hooks. photoUrl is the user's own profile photo as an absolute URL, or null. Not a data-client.",
2792
2819
  required: true,
2793
- fields: { id: "string", email: "string", displayName: "string" },
2820
+ fields: {
2821
+ id: "string",
2822
+ email: "string",
2823
+ displayName: "string",
2824
+ photoUrl: "string | null",
2825
+ },
2826
+ },
2827
+ // Optional so a host that cannot change the photo needs no stub:
2828
+ // useUserPhoto() reports canEdit false when it is absent.
2829
+ account: {
2830
+ description:
2831
+ "Optional self-service account actions for the signed-in user: " +
2832
+ "{ setPhoto(file) -> Promise<{ photoUrl }>, removePhoto() -> Promise<{ photoUrl: null }> }. " +
2833
+ "Backs useUserPhoto(). The host performs the request AND updates its own session, so user.photoUrl changes for every " +
2834
+ "widget. setPhoto takes a browser File on web and expo-file-system's File on native. Absent on a host that cannot " +
2835
+ "change the photo (the Builder canvas preview), where useUserPhoto() degrades to canEdit: false rather than throwing.",
2836
+ required: false,
2837
+ fields: { setPhoto: "function", removePhoto: "function" },
2794
2838
  },
2795
2839
  workspace: {
2796
2840
  description:
@@ -3248,13 +3292,16 @@ const VETTED_IMPORTS = [
3248
3292
  platforms: ["web"],
3249
3293
  category: "geo",
3250
3294
  description:
3251
- "Web-only mapping library. Use alongside react-leaflet in widget.web.jsx as the web counterpart to react-native-maps.",
3295
+ "Web-only mapping library. Use alongside react-leaflet in widget.web.jsx as the web counterpart to react-native-maps. " +
3296
+ 'Every file that imports it must also `import "leaflet/dist/leaflet.css"` (the linter enforces it), and a marker needs an ' +
3297
+ "explicit `L.icon` built from the imported `leaflet/dist/images/*.png`: Leaflet's default icon cannot resolve its image inside a bundle.",
3252
3298
  },
3253
3299
  {
3254
3300
  specifier: "react-leaflet",
3255
3301
  platforms: ["web"],
3256
3302
  category: "geo",
3257
- description: "React bindings for leaflet. Web-only.",
3303
+ description:
3304
+ 'React bindings for leaflet. Web-only. Import "leaflet/dist/leaflet.css" alongside it and pass `icon` to every <Marker>.',
3258
3305
  },
3259
3306
  {
3260
3307
  specifier: "expo-audio",
@@ -4811,7 +4858,16 @@ const CONTRACT = deepFreeze({
4811
4858
  // icon, figure and delta wrap in a tile too narrow to hold them.
4812
4859
  // 1.147.0: narrowing (sc-10123) — `resolveTopBarTokens().tabAlignment` is
4813
4860
  // "left" | "right". A stored "center" resolves to "left".
4814
- version: "1.147.0",
4861
+ // 1.148.0: additive (sc-10142) — `useUser()` returns `photoUrl`, the
4862
+ // signed-in user's own profile photo (null when unset or not provided).
4863
+ // New `useUserPhoto()` changes it through the new OPTIONAL context
4864
+ // slice `account` ({ setPhoto, removePhoto }); a host without it reads
4865
+ // `canEdit: false`.
4866
+ // 1.149.0: narrowing (sc-10466) — new linter rule `leaflet-css-missing`
4867
+ // (error): a file importing leaflet / react-leaflet must import
4868
+ // "leaflet/dist/leaflet.css". `leaflet-default-marker-icon` (warning)
4869
+ // flags a react-leaflet <Marker> with no `icon`.
4870
+ version: "1.149.0",
4815
4871
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4816
4872
  hooks: HOOKS,
4817
4873
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -242,8 +242,10 @@ export function useWidgetStyle() {
242
242
  }
243
243
 
244
244
  /**
245
- * Returns the active end-user identity VERBATIM from the host, e.g.
246
- * `{ id, email, displayName, roles, groupIds }` (camelCase — host-built context, not a wire payload).
245
+ * Returns the active end-user identity the host provides, e.g.
246
+ * `{ id, email, displayName, photoUrl, roles, groupIds }` (camelCase — host-built context, not a wire payload).
247
+ * It is the host's own object, or a memoised copy carrying `photoUrl: null`
248
+ * when the host omits that field.
247
249
  *
248
250
  * `id` is `null` for anonymous visitors (and on the Studio canvas preview,
249
251
  * which renders widgets as if signed-out so the public branch shows). The
@@ -251,12 +253,112 @@ export function useWidgetStyle() {
251
253
  * fields; widgets read them directly. The current principal is host-provided
252
254
  * — this hook does NOT read a data-client.
253
255
  *
254
- * Use this to render the signed-in user's name in a header, branch on
255
- * roles, or stamp a created-by field.
256
+ * `photoUrl` is the user's own profile photo as an absolute URL, ready for
257
+ * `<Avatar url>` or `<Image source={{ uri }}>`; `null` when none is set, for
258
+ * anonymous visitors, and on a host that does not provide it.
259
+ *
260
+ * Use this to render the signed-in user's name and photo in a header, branch
261
+ * on roles, or stamp a created-by field.
256
262
  */
257
263
  export function useUser() {
258
264
  const ctx = useWidgetContextOrThrow("useUser");
259
- return ctx.user;
265
+ const user = ctx.user;
266
+ // A host predating photoUrl omits it; the type promises null.
267
+ return useMemo(
268
+ () => (user && user.photoUrl === undefined ? { ...user, photoUrl: null } : user),
269
+ [user],
270
+ );
271
+ }
272
+
273
+ const USER_PHOTO_UNAVAILABLE = "Changing your photo is not available here";
274
+ const USER_PHOTO_SIGNED_OUT = "Sign in to change your photo";
275
+ const USER_PHOTO_NO_FILE = "useUserPhoto: file is required";
276
+
277
+ /**
278
+ * Read and change the signed-in user's OWN profile photo. Returns
279
+ * `{ url, canEdit, upload, remove, uploading, error }`.
280
+ *
281
+ * `url` mirrors `useUser().photoUrl`. `upload(file)` takes a browser `File`
282
+ * on the web Player and expo-file-system's `File` on the Expo export: the
283
+ * file `<FilePicker>` gives you, or `asset.file` from `useCamera()` /
284
+ * `useImageEditor()` — never the asset itself. The server crops the image to
285
+ * a square and resizes it; PNG, JPEG or WebP, 10 MB at most. A refusal
286
+ * carries `err.status`: 413 too large, 415 unsupported type, 400 unreadable
287
+ * image. `uploading` is true while any `upload` / `remove` is in flight.
288
+ *
289
+ * The host owns the request and its own session (`ctx.account`), so a change
290
+ * reaches every widget's `useUser().photoUrl` at once. `ctx.account` is
291
+ * optional: where it is absent (the Builder canvas preview) or nobody is
292
+ * signed in, `canEdit` is false and `upload` / `remove` reject instead of the
293
+ * hook throwing at render.
294
+ *
295
+ * There is no way to read ANOTHER user's photo: `useUsers()` and
296
+ * `useDirectory()` rows carry none.
297
+ */
298
+ export function useUserPhoto() {
299
+ const ctx = useWidgetContextOrThrow("useUserPhoto");
300
+ const user = ctx.user;
301
+ const account = ctx.account;
302
+ const hostCanEdit =
303
+ Boolean(account) &&
304
+ typeof account.setPhoto === "function" &&
305
+ typeof account.removePhoto === "function";
306
+ const signedIn = Boolean(user && user.id);
307
+ const canEdit = hostCanEdit && signedIn;
308
+ const url =
309
+ user && typeof user.photoUrl === "string" && user.photoUrl
310
+ ? user.photoUrl
311
+ : null;
312
+
313
+ const accountRef = useRef(null);
314
+ accountRef.current = hostCanEdit ? account : null;
315
+ const refusalRef = useRef(null);
316
+ refusalRef.current = !hostCanEdit
317
+ ? USER_PHOTO_UNAVAILABLE
318
+ : !signedIn
319
+ ? USER_PHOTO_SIGNED_OUT
320
+ : null;
321
+
322
+ const inFlightRef = useRef(0);
323
+ const [uploading, setUploading] = useState(false);
324
+ const [error, setError] = useState(null);
325
+
326
+ const change = useCallback(async (perform, invalidCall) => {
327
+ const refusal = refusalRef.current || invalidCall;
328
+ if (refusal) {
329
+ const err = new Error(refusal);
330
+ setError(err);
331
+ throw err;
332
+ }
333
+ inFlightRef.current += 1;
334
+ setUploading(true);
335
+ setError(null);
336
+ try {
337
+ return await perform(accountRef.current);
338
+ } catch (err) {
339
+ setError(err);
340
+ throw err;
341
+ } finally {
342
+ // upload() and remove() may overlap; the last one clears it.
343
+ inFlightRef.current -= 1;
344
+ setUploading(inFlightRef.current > 0);
345
+ }
346
+ }, []);
347
+
348
+ const upload = useCallback(
349
+ (file) =>
350
+ change(
351
+ (host) => host.setPhoto(file),
352
+ file ? null : USER_PHOTO_NO_FILE,
353
+ ),
354
+ [change],
355
+ );
356
+ const remove = useCallback(
357
+ () => change((host) => host.removePhoto()),
358
+ [change],
359
+ );
360
+
361
+ return { url, canEdit, upload, remove, uploading, error };
260
362
  }
261
363
 
262
364
  /**
@@ -3030,11 +3132,11 @@ export function useAgent(agentRef) {
3030
3132
  cancelled ||
3031
3133
  agentIdRef.current !== agentId ||
3032
3134
  resumeGenerationRef.current !== generation ||
3033
- suppressResumeRef.current ||
3034
- // A `send` that started during the resume already latched a thread.
3035
- // Overwriting it would orphan that PAID turn in a conversation
3036
- // nothing reads again, and point the next send somewhere else.
3037
- conversationRef.current
3135
+ suppressResumeRef.current ||
3136
+ // A `send` that started during the resume already latched a thread.
3137
+ // Overwriting it would orphan that PAID turn in a conversation
3138
+ // nothing reads again, and point the next send somewhere else.
3139
+ conversationRef.current
3038
3140
  ) {
3039
3141
  return;
3040
3142
  }
package/dist/index.d.ts CHANGED
@@ -646,9 +646,21 @@ export interface WidgetContext<TProps = unknown> {
646
646
  id: string | null;
647
647
  email: string | null;
648
648
  displayName: string | null;
649
+ /** The user's own profile photo as an absolute URL; null when none is set. */
650
+ photoUrl: string | null;
649
651
  roles: string[];
650
652
  groupIds: string[];
651
653
  };
654
+ /**
655
+ * Optional self-service account actions backing `useUserPhoto()`. The host
656
+ * performs the request and updates its own session, so `user.photoUrl`
657
+ * changes for every widget. Absent where the photo cannot be changed (the
658
+ * Builder canvas preview); `useUserPhoto().canEdit` is then false.
659
+ */
660
+ account?: {
661
+ setPhoto(file: UserPhotoFile): Promise<{ photoUrl: string | null }>;
662
+ removePhoto(): Promise<{ photoUrl: null }>;
663
+ };
652
664
  workspace: {
653
665
  id: string;
654
666
  slug: string;
@@ -1510,10 +1522,54 @@ export function useUser(): {
1510
1522
  id: string | null;
1511
1523
  email: string | null;
1512
1524
  displayName: string | null;
1525
+ /**
1526
+ * The user's own profile photo as an absolute URL, ready for `<Avatar url>`
1527
+ * or `<Image source={{ uri }}>`. Null when none is set, for anonymous
1528
+ * visitors, and on a host that does not provide it.
1529
+ */
1530
+ photoUrl: string | null;
1513
1531
  roles: string[];
1514
1532
  groupIds: string[];
1515
1533
  };
1516
1534
 
1535
+ /**
1536
+ * The file `useUserPhoto().upload` takes: a browser `File` on the web Player
1537
+ * and expo-file-system's `File` on the Expo export. It is the file
1538
+ * `<FilePicker>` gives you, or `asset.file` from `useCamera()` /
1539
+ * `useImageEditor()` — never the asset itself.
1540
+ */
1541
+ export type UserPhotoFile = Blob & { name?: string };
1542
+
1543
+ export interface UserPhotoApi {
1544
+ /** Same value as `useUser().photoUrl`. */
1545
+ url: string | null;
1546
+ /** True when a user is signed in and the host can change the photo. */
1547
+ canEdit: boolean;
1548
+ /**
1549
+ * Replace the photo. The server crops the image to a square and resizes it.
1550
+ * PNG, JPEG or WebP, 10 MB at most. A refusal carries `status`: 413 too
1551
+ * large, 415 unsupported type, 400 unreadable image. Rejects, and sets
1552
+ * `error`, when `canEdit` is false or no file is passed.
1553
+ */
1554
+ upload(file: UserPhotoFile): Promise<{ photoUrl: string | null }>;
1555
+ /** Clear the photo. Rejects when `canEdit` is false. */
1556
+ remove(): Promise<{ photoUrl: null }>;
1557
+ /** True while any `upload` or `remove` call is in flight. */
1558
+ uploading: boolean;
1559
+ /** The last failure; cleared by the next attempt. */
1560
+ error: (Error & { status?: number }) | null;
1561
+ }
1562
+
1563
+ /**
1564
+ * Read and change the signed-in user's OWN profile photo. Never throws at
1565
+ * render: where nobody is signed in, or the host cannot change the photo,
1566
+ * `canEdit` is false and `upload` / `remove` reject. Pass `upload` the
1567
+ * file `<FilePicker>` gives you, or `asset.file` from `useCamera()` /
1568
+ * `useImageEditor()`. There is no way to read another user's photo;
1569
+ * `useUsers()` and `useDirectory()` rows carry none.
1570
+ */
1571
+ export function useUserPhoto(): UserPhotoApi;
1572
+
1517
1573
  /**
1518
1574
  * REQ-LAY-08 — returns `true` when the host has sized this widget to fill its
1519
1575
  * layout slot's available height (a page-grid tile set to "Fill tile height",
package/dist/index.js CHANGED
@@ -71,6 +71,7 @@ export {
71
71
  useTranslate,
72
72
  TranslateError,
73
73
  useUser,
74
+ useUserPhoto,
74
75
  useFill,
75
76
  useNavigation,
76
77
  useRouteParams,
@@ -71,6 +71,7 @@ export {
71
71
  useTranslate,
72
72
  TranslateError,
73
73
  useUser,
74
+ useUserPhoto,
74
75
  useFill,
75
76
  useNavigation,
76
77
  useRouteParams,
package/dist/linter.cjs CHANGED
@@ -252,7 +252,9 @@ function _packageRoot(spec) {
252
252
  const parts = spec.split("/");
253
253
  // A traversal / empty segment is a non-canonical specifier — never resolve it
254
254
  // to a vetted root, or "leaflet/../evil" would pass the gate as "leaflet".
255
- if (parts.some((p) => p === "" || p === "." || p === "..")) return null;
255
+ if (parts.some((p) => p === "" || p === "." || p === ".." || p.includes("\\"))) {
256
+ return null;
257
+ }
256
258
  return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
257
259
  }
258
260
 
@@ -336,6 +338,87 @@ function _importRules(source, manifest) {
336
338
  return findings;
337
339
  }
338
340
 
341
+ // sc-10466 — Leaflet is bundled per widget; no host supplies its stylesheet.
342
+ // Without `leaflet/dist/leaflet.css` the tiles, panes and markers are
343
+ // unpositioned: a patch of map in an empty box, and no visible pin. And once
344
+ // the CSS is injected inline its default marker image resolves against the
345
+ // page URL and 404s, so a `<Marker>` needs an explicit `icon`.
346
+ const _LEAFLET_ROOTS = new Set(["leaflet", "react-leaflet"]);
347
+ const _LEAFLET_CSS = "leaflet/dist/leaflet.css";
348
+ const _MARKER_TAG_RE = /<Marker\b/g;
349
+
350
+ const _ASSET_SPECIFIER_RE = /\.(css|png|jpe?g|gif)$/i;
351
+ const _ICON_DEFAULT_CONFIGURED_RE =
352
+ /\bIcon\s*\.\s*Default\s*(\.\s*mergeOptions\s*\(|\.\s*imagePath\s*=)/;
353
+
354
+ function _lineAt(code, index) {
355
+ return code.slice(0, index).split(/\r?\n/).length;
356
+ }
357
+
358
+ function _leafletRules(source) {
359
+ const code = _stripNonCode(source, { keepStrings: true });
360
+ const sourceLines = source.split(/\r?\n/);
361
+ let firstLeafletLine = 0;
362
+ let importsCss = false;
363
+ let importsReactLeaflet = false;
364
+ // Whole-source scan: a Prettier-wrapped import spans several lines.
365
+ IMPORT_SPECIFIER_RE.lastIndex = 0;
366
+ let m;
367
+ while ((m = IMPORT_SPECIFIER_RE.exec(code))) {
368
+ const spec = m[1];
369
+ if (spec === _LEAFLET_CSS) importsCss = true;
370
+ const root = _packageRoot(spec);
371
+ if (!_LEAFLET_ROOTS.has(root) || _ASSET_SPECIFIER_RE.test(spec)) continue;
372
+ if (root === "react-leaflet") importsReactLeaflet = true;
373
+ if (!firstLeafletLine) firstLeafletLine = _lineAt(code, m.index + m[0].length);
374
+ }
375
+ IMPORT_SPECIFIER_RE.lastIndex = 0;
376
+ if (!firstLeafletLine) return [];
377
+ const findings = [];
378
+ if (!importsCss) {
379
+ findings.push({
380
+ rule: "leaflet-css-missing",
381
+ severity: "error",
382
+ label:
383
+ `this file uses Leaflet but never imports its stylesheet — without ` +
384
+ `it the map draws a partial patch of tiles inside an empty box and ` +
385
+ `markers are invisible. Add \`import "${_LEAFLET_CSS}";\` to this file.`,
386
+ line: firstLeafletLine,
387
+ snippet: (sourceLines[firstLeafletLine - 1] || "").trim().slice(0, 200),
388
+ });
389
+ }
390
+ if (
391
+ !importsReactLeaflet ||
392
+ _ICON_DEFAULT_CONFIGURED_RE.test(_stripNonCode(source))
393
+ ) {
394
+ return findings;
395
+ }
396
+ _MARKER_TAG_RE.lastIndex = 0;
397
+ let tag;
398
+ while ((tag = _MARKER_TAG_RE.exec(code))) {
399
+ const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
400
+ if (/(^|\s)icon\s*=/.test(code.slice(tag.index, end))) continue;
401
+ const line = _lineAt(code, tag.index);
402
+ findings.push({
403
+ rule: "leaflet-default-marker-icon",
404
+ severity: "warning",
405
+ label:
406
+ `<Marker> uses Leaflet's default icon, whose image path cannot ` +
407
+ `resolve inside a widget bundle, so the pin does not show. Import the ` +
408
+ `images (\`import markerIcon from "leaflet/dist/images/marker-icon.png"\`, ` +
409
+ `plus marker-icon-2x.png and marker-shadow.png), build ` +
410
+ `\`L.icon({ iconUrl, iconRetinaUrl, shadowUrl, iconSize: [25, 41], ` +
411
+ `iconAnchor: [12, 41] })\` once at module scope, and pass it as ` +
412
+ `\`icon={…}\`. Warning only — a widget that sets \`L.Icon.Default\` ` +
413
+ `itself (\`mergeOptions\` / \`imagePath\`) is not flagged.`,
414
+ line,
415
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
416
+ });
417
+ _MARKER_TAG_RE.lastIndex = end;
418
+ }
419
+ return findings;
420
+ }
421
+
339
422
  // Explicit opt-out. A widget legitimately calling a THIRD-PARTY API with a
340
423
  // relative path against an `axios` baseURL cannot avoid the substring match,
341
424
  // and post-sc-3589 an unavoidable finding burns AI repair turns on correct
@@ -1853,6 +1936,7 @@ function lintSource(source, options) {
1853
1936
  severity: f.severity || "error",
1854
1937
  })),
1855
1938
  );
1939
+ findings.push(..._leafletRules(source));
1856
1940
  findings.push(..._hostApiUrlRules(source));
1857
1941
  // sc-6970 — soft warning: HTML markup spliced into a content string.
1858
1942
  findings.push(..._htmlInContentRules(source));
package/dist/linter.js CHANGED
@@ -135,7 +135,9 @@ function _packageRoot(spec) {
135
135
  const parts = spec.split("/");
136
136
  // A traversal / empty segment is a non-canonical specifier — never resolve it
137
137
  // to a vetted root, or "leaflet/../evil" would pass the gate as "leaflet".
138
- if (parts.some((p) => p === "" || p === "." || p === "..")) return null;
138
+ if (parts.some((p) => p === "" || p === "." || p === ".." || p.includes("\\"))) {
139
+ return null;
140
+ }
139
141
  return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
140
142
  }
141
143
 
@@ -229,6 +231,87 @@ function _importRules(source, manifest) {
229
231
  return findings;
230
232
  }
231
233
 
234
+ // sc-10466 — Leaflet is bundled per widget; no host supplies its stylesheet.
235
+ // Without `leaflet/dist/leaflet.css` the tiles, panes and markers are
236
+ // unpositioned: a patch of map in an empty box, and no visible pin. And once
237
+ // the CSS is injected inline its default marker image resolves against the
238
+ // page URL and 404s, so a `<Marker>` needs an explicit `icon`.
239
+ const _LEAFLET_ROOTS = new Set(["leaflet", "react-leaflet"]);
240
+ const _LEAFLET_CSS = "leaflet/dist/leaflet.css";
241
+ const _MARKER_TAG_RE = /<Marker\b/g;
242
+
243
+ const _ASSET_SPECIFIER_RE = /\.(css|png|jpe?g|gif)$/i;
244
+ const _ICON_DEFAULT_CONFIGURED_RE =
245
+ /\bIcon\s*\.\s*Default\s*(\.\s*mergeOptions\s*\(|\.\s*imagePath\s*=)/;
246
+
247
+ function _lineAt(code, index) {
248
+ return code.slice(0, index).split(/\r?\n/).length;
249
+ }
250
+
251
+ function _leafletRules(source) {
252
+ const code = _stripNonCode(source, { keepStrings: true });
253
+ const sourceLines = source.split(/\r?\n/);
254
+ let firstLeafletLine = 0;
255
+ let importsCss = false;
256
+ let importsReactLeaflet = false;
257
+ // Whole-source scan: a Prettier-wrapped import spans several lines.
258
+ IMPORT_SPECIFIER_RE.lastIndex = 0;
259
+ let m;
260
+ while ((m = IMPORT_SPECIFIER_RE.exec(code))) {
261
+ const spec = m[1];
262
+ if (spec === _LEAFLET_CSS) importsCss = true;
263
+ const root = _packageRoot(spec);
264
+ if (!_LEAFLET_ROOTS.has(root) || _ASSET_SPECIFIER_RE.test(spec)) continue;
265
+ if (root === "react-leaflet") importsReactLeaflet = true;
266
+ if (!firstLeafletLine) firstLeafletLine = _lineAt(code, m.index + m[0].length);
267
+ }
268
+ IMPORT_SPECIFIER_RE.lastIndex = 0;
269
+ if (!firstLeafletLine) return [];
270
+ const findings = [];
271
+ if (!importsCss) {
272
+ findings.push({
273
+ rule: "leaflet-css-missing",
274
+ severity: "error",
275
+ label:
276
+ `this file uses Leaflet but never imports its stylesheet — without ` +
277
+ `it the map draws a partial patch of tiles inside an empty box and ` +
278
+ `markers are invisible. Add \`import "${_LEAFLET_CSS}";\` to this file.`,
279
+ line: firstLeafletLine,
280
+ snippet: (sourceLines[firstLeafletLine - 1] || "").trim().slice(0, 200),
281
+ });
282
+ }
283
+ if (
284
+ !importsReactLeaflet ||
285
+ _ICON_DEFAULT_CONFIGURED_RE.test(_stripNonCode(source))
286
+ ) {
287
+ return findings;
288
+ }
289
+ _MARKER_TAG_RE.lastIndex = 0;
290
+ let tag;
291
+ while ((tag = _MARKER_TAG_RE.exec(code))) {
292
+ const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
293
+ if (/(^|\s)icon\s*=/.test(code.slice(tag.index, end))) continue;
294
+ const line = _lineAt(code, tag.index);
295
+ findings.push({
296
+ rule: "leaflet-default-marker-icon",
297
+ severity: "warning",
298
+ label:
299
+ `<Marker> uses Leaflet's default icon, whose image path cannot ` +
300
+ `resolve inside a widget bundle, so the pin does not show. Import the ` +
301
+ `images (\`import markerIcon from "leaflet/dist/images/marker-icon.png"\`, ` +
302
+ `plus marker-icon-2x.png and marker-shadow.png), build ` +
303
+ `\`L.icon({ iconUrl, iconRetinaUrl, shadowUrl, iconSize: [25, 41], ` +
304
+ `iconAnchor: [12, 41] })\` once at module scope, and pass it as ` +
305
+ `\`icon={…}\`. Warning only — a widget that sets \`L.Icon.Default\` ` +
306
+ `itself (\`mergeOptions\` / \`imagePath\`) is not flagged.`,
307
+ line,
308
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
309
+ });
310
+ _MARKER_TAG_RE.lastIndex = end;
311
+ }
312
+ return findings;
313
+ }
314
+
232
315
  // REQ-WSDK-PLATFORM §3.5: soft warning when source contains host-API URL
233
316
  // substrings. `severity: "warning"` so a HUMAN-authored submission still
234
317
  // publishes and the marketplace review queue flags it for a human pass.
@@ -1925,6 +2008,7 @@ export function lintSource(source, options) {
1925
2008
  severity: f.severity || "error",
1926
2009
  })),
1927
2010
  );
2011
+ findings.push(..._leafletRules(source));
1928
2012
  // REQ-WSDK-PLATFORM §3.5: soft host-API URL warning (does not block).
1929
2013
  findings.push(..._hostApiUrlRules(source));
1930
2014
  // sc-6970 — soft warning: HTML markup spliced into a content string.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.186.0",
3
+ "version": "0.188.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",