@colixsystems/widget-sdk 0.185.0 → 0.187.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,7 +75,53 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
74
75
 
75
76
  ## Status
76
77
 
77
- `v0.185.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.187.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**.
79
+
80
+ ### What's new in 0.187.0 (contract 1.148.0)
81
+
82
+ **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:
83
+
84
+ ```jsx
85
+ import { useUser, useUserPhoto, Avatar, FilePicker, Pressable, Text, View } from "@colixsystems/widget-sdk";
86
+
87
+ function ProfileHeader() {
88
+ const user = useUser();
89
+ const { canEdit, upload, remove, uploading, error } = useUserPhoto();
90
+
91
+ return (
92
+ <View>
93
+ <Avatar name={user.displayName} url={user.photoUrl} size="xl" />
94
+ {canEdit ? (
95
+ <FilePicker
96
+ accept="image/png,image/jpeg,image/webp"
97
+ disabled={uploading}
98
+ onPick={(file) => upload(file).catch(() => {})}
99
+ >
100
+ <Text>{uploading ? "Saving…" : "Change photo"}</Text>
101
+ </FilePicker>
102
+ ) : null}
103
+ {canEdit && user.photoUrl ? (
104
+ <Pressable disabled={uploading} onPress={() => remove().catch(() => {})}>
105
+ <Text>Remove photo</Text>
106
+ </Pressable>
107
+ ) : null}
108
+ {error ? <Text>{error.status === 413 ? "That image is too large." : "Could not save the photo."}</Text> : null}
109
+ </View>
110
+ );
111
+ }
112
+ ```
113
+
114
+ - **`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.
115
+ - **`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`.
116
+ - **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.
117
+ - **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.
118
+ - **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.
119
+ - **No new scope.** The hook only ever touches the caller's own account.
120
+
121
+ 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.
122
+ ### What's new in 0.186.0 (contract 1.147.0)
123
+
124
+ **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.
78
125
 
79
126
  ### What's new in 0.185.0 (contract 1.146.0)
80
127
 
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:
@@ -4813,7 +4857,14 @@ const CONTRACT = deepFreeze({
4813
4857
  // sized to their content. `<Button>` / `<ListRow>` read their `style`
4814
4858
  // through the same flatten; their behaviour is unchanged. `<Stat>`'s
4815
4859
  // icon, figure and delta wrap in a tile too narrow to hold them.
4816
- version: "1.146.0",
4860
+ // 1.147.0: narrowing (sc-10123) — `resolveTopBarTokens().tabAlignment` is
4861
+ // "left" | "right". A stored "center" resolves to "left".
4862
+ // 1.148.0: additive (sc-10142) — `useUser()` returns `photoUrl`, the
4863
+ // signed-in user's own profile photo (null when unset or not provided).
4864
+ // New `useUserPhoto()` changes it through the new OPTIONAL context
4865
+ // slice `account` ({ setPhoto, removePhoto }); a host without it reads
4866
+ // `canEdit: false`.
4867
+ version: "1.148.0",
4817
4868
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4818
4869
  hooks: HOOKS,
4819
4870
  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:
@@ -4809,7 +4853,14 @@ const CONTRACT = deepFreeze({
4809
4853
  // sized to their content. `<Button>` / `<ListRow>` read their `style`
4810
4854
  // through the same flatten; their behaviour is unchanged. `<Stat>`'s
4811
4855
  // icon, figure and delta wrap in a tile too narrow to hold them.
4812
- version: "1.146.0",
4856
+ // 1.147.0: narrowing (sc-10123) — `resolveTopBarTokens().tabAlignment` is
4857
+ // "left" | "right". A stored "center" resolves to "left".
4858
+ // 1.148.0: additive (sc-10142) — `useUser()` returns `photoUrl`, the
4859
+ // signed-in user's own profile photo (null when unset or not provided).
4860
+ // New `useUserPhoto()` changes it through the new OPTIONAL context
4861
+ // slice `account` ({ setPhoto, removePhoto }); a host without it reads
4862
+ // `canEdit: false`.
4863
+ version: "1.148.0",
4813
4864
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4814
4865
  hooks: HOOKS,
4815
4866
  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/host.d.ts CHANGED
@@ -292,7 +292,7 @@ export interface TopBarTokens {
292
292
  * current tab so the page shows through it. */
293
293
  tabStyle: "underline" | "attached";
294
294
  /** Where the row's tabs sit across its width; `left` unless authored. */
295
- tabAlignment: "left" | "center" | "right";
295
+ tabAlignment: "left" | "right";
296
296
  /** The column the tabs sit in: the whole bar, or the page content's. The
297
297
  * bar's surface spans the full width either way. */
298
298
  tabRowWidth: "full" | "content";
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,
@@ -535,7 +535,7 @@ function resolveTopStripeLayout(theme) {
535
535
  };
536
536
  }
537
537
 
538
- const TAB_ALIGNMENTS = ["left", "center", "right"];
538
+ const TAB_ALIGNMENTS = ["left", "right"];
539
539
  const TAB_ROW_WIDTHS = ["full", "content"];
540
540
 
541
541
  function resolveTopBarTokens(theme) {
@@ -569,8 +569,8 @@ function resolveTopBarTokens(theme) {
569
569
  // surface and meets the page beneath it, so the two read as one plane. Meaningless on a shape that draws no tab row — a host
570
570
  // reads it only where it has tabs to draw.
571
571
  tabStyle: topBar.tabStyle === "attached" ? "attached" : "underline",
572
- // sc-10123: where the row's tabs sit across its width. Both hosts place
573
- // them with flexible spacers rather than centring the row, so a row that
572
+ // sc-10123: which end of the row the tabs sit at. Both hosts place them
573
+ // with a flexible spacer rather than justifying the row, so a row that
574
574
  // overflows still scrolls from its first tab instead of clipping it.
575
575
  tabAlignment: TAB_ALIGNMENTS.includes(topBar.tabAlignment)
576
576
  ? topBar.tabAlignment
@@ -526,7 +526,7 @@ export function resolveTopStripeLayout(theme) {
526
526
  };
527
527
  }
528
528
 
529
- const TAB_ALIGNMENTS = ["left", "center", "right"];
529
+ const TAB_ALIGNMENTS = ["left", "right"];
530
530
  const TAB_ROW_WIDTHS = ["full", "content"];
531
531
 
532
532
  export function resolveTopBarTokens(theme) {
@@ -560,8 +560,8 @@ export function resolveTopBarTokens(theme) {
560
560
  // surface and meets the page beneath it, so the two read as one plane. Meaningless on a shape that draws no tab row — a host
561
561
  // reads it only where it has tabs to draw.
562
562
  tabStyle: topBar.tabStyle === "attached" ? "attached" : "underline",
563
- // sc-10123: where the row's tabs sit across its width. Both hosts place
564
- // them with flexible spacers rather than centring the row, so a row that
563
+ // sc-10123: which end of the row the tabs sit at. Both hosts place them
564
+ // with a flexible spacer rather than justifying the row, so a row that
565
565
  // overflows still scrolls from its first tab instead of clipping it.
566
566
  tabAlignment: TAB_ALIGNMENTS.includes(topBar.tabAlignment)
567
567
  ? topBar.tabAlignment
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.185.0",
3
+ "version": "0.187.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",