@colixsystems/widget-sdk 0.202.0 → 0.205.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
@@ -83,7 +83,42 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
83
83
 
84
84
  ## Status
85
85
 
86
- `v0.202.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**.
86
+ `v0.205.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**.
87
+
88
+ ### What's new in 0.205.0 (contract 1.164.0)
89
+
90
+ **An app's user totals for its own admins — `useUserStats({ days, group_id })`.** Active and deactivated users, new users per day (7 / 30 days) or week (90 days) with the previous period's total beside them, users in no group, empty groups, and invite figures. Totals only — never a list of people. Needs `users.read:*` and the viewer's admin user access (else `error.code === "FORBIDDEN"`); `stats.invites` is null unless the widget also declares `users.write:*` and the viewer holds it.
91
+
92
+ **User activity — `useUserActivity({ days, group_id })` and `useInactiveUsers({ inactive_days, group_id, limit, offset })`.** For a workspace that records user activity: who is online now, active in the last day / week / month, never active, active users per day or week, the web / iOS / Android split and the active share per group — and a page of the users not seen recently, never-seen first. Needs `users.activity:*` and the viewer's "View user activity" permission (else `error.code === "FORBIDDEN"`); when the workspace does not record activity, `error.reason === "ACTIVITY_DISABLED"` — show that, not zeros. Before user activity is offered to the workspace at all, `error.reason` is `"ACTIVITY_UNAVAILABLE"`: hide the section, as for `FORBIDDEN`. `useInactiveUsers` also returns `remind(userId, { title, body })`, which notifies one of them and records when (each row carries `last_reminded_at`). Days follow the workspace's time zone (`period.time_zone`).
93
+
94
+ **Linter fix.** A widget that uses `useGroups` or `useUsers` is no longer refused for a write it never makes: `StyleSheet.create(...)` and an event subscription's bare `remove()` (an `AppState` listener, say) are not user or group writes.
95
+
96
+ ### What's new in 0.204.0 (contract 1.163.0)
97
+
98
+ **One loading look: `Spinner`, `Skeleton`, and the `loading` theme scope.** Widgets each drew their own loading state — a platform spinner in its default colour, hand-built placeholder bars, or a line of text. Draw these instead:
99
+
100
+ ```jsx
101
+ // You know the shape of what is coming: draw its outline.
102
+ if (loading) return <Skeleton shape="listRow" count={4} />;
103
+
104
+ // You do not: a pending action.
105
+ {saving ? <Spinner size="sm" color={theme.colors.onPrimary} /> : <Text>Save</Text>}
106
+ ```
107
+
108
+ `<Spinner size label color>` takes `size` `"sm"` | `"md"` (default) | `"lg"` or a number, and an optional `label` drawn under the ring. `<Skeleton shape>` takes `"text"` (`width`, `lines`), `"block"` (`width`, `height`), `"circle"` (`size`) or the `"listRow"` preset (`count`, `leading`), whose rows share the frame of the theme's list rows. To mirror any other layout, wrap bones in one outer `<Skeleton>` and lay them out yourself:
109
+
110
+ ```jsx
111
+ <Skeleton style={{ flexDirection: "row", gap: 12 }}>
112
+ <Skeleton shape="circle" />
113
+ <Skeleton shape="text" lines={2} style={{ flex: 1 }} />
114
+ </Skeleton>
115
+ ```
116
+
117
+ A spinner wears `theme.colors.loader`, the app's one spinner colour: set under **Design → Loading Indicator**, and re-derived for whatever surface the widget sits on, so it stays visible in a dark section. Pass `color` only inside a control you fill yourself. A skeleton's colour and corners come from **Design → Components → Loading** (`themeConfig.components.loading`: `skeletonColor`, `skeletonRadius`), defaulting to the theme's muted surface; `color` and `radius` override one skeleton.
118
+
119
+ Both stop moving when the device asks for reduced motion; the outermost skeleton pulses as a whole, so a long list costs one animation. A spinner is a labelled `progressbar` in the busy state; a string `label` is its name, and a label that is a node stays exposed as content. The outermost skeleton is the one loading region: on the web a `status` region with a label and `aria-busy`, on native one labelled accessible element in the busy state (`accessibilityLabel` overrides the app's own "Loading…"). The bones inside it are hidden from assistive technology.
120
+
121
+ The `loading` scope styles an SDK component rather than a widget's style fields, so its `targets` is empty and it names `Skeleton` in the new optional scope key **`sdkComponents`**.
87
122
 
88
123
  ### What's new in 0.202.0 (contract 1.161.0)
89
124
 
@@ -2411,6 +2446,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
2411
2446
  - `Badge`, `Avatar`, `Progress`, `Stat`, `Divider` — the themed status pill, person picture, completion bar or ring, headline figure and rule the built-in widgets of the same names render through. Reach for them inside a record row instead of hand-rolling a pill or a circle from `View`.
2412
2447
  - `Card`, `Button`, `TextField` — the themed card surface, button and text field the built-in widgets render through. With no props each takes the app's look from Theme Settings (Cards, Buttons, Muted); never hand-roll one from `View` / `Pressable` / a bare `TextInput`.
2413
2448
  - `Accordion`, `AccordionItem` — themed collapsible sections. `<Accordion><AccordionItem title subtitle trailing>…</AccordionItem></Accordion>` draws its surface from the theme's `card` scope and its headers and dividers from the `list` scope; one section open at a time, or `multiple`. Never hand-roll a collapsible from `Pressable` + a chevron.
2449
+ - `Spinner`, `Skeleton` — the themed loading states. `<Spinner size label>` for a pending action; `<Skeleton shape="text" | "block" | "circle" | "listRow">` (or a group of bones) for content whose layout you know. The spinner wears `colors.loader`, the skeleton follows the theme's `loading` scope; both stop under reduced motion and mark one busy region. Never use `ActivityIndicator` or hand-build placeholder bars.
2414
2450
  - `Tabs` — the themed tab strip. `<Tabs items value onChange>` draws a segmented control, pills or an underline as the theme's `tabs` scope says; render the selected panel yourself. Never hand-roll tabs from `Pressable` chips.
2415
2451
  - `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
2416
2452
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
package/dist/contract.cjs CHANGED
@@ -692,6 +692,18 @@ const THEME_COMPONENTS = Object.freeze({
692
692
  "appstudio.user-management": TABS_FIELDS,
693
693
  }),
694
694
  }),
695
+ // Loading placeholders: `<Skeleton>` reads this scope directly, so it names
696
+ // the SDK component it styles and binds no widget field. `<Spinner>` is not
697
+ // here: it wears `colors.loader`, the app's one spinner colour.
698
+ loading: Object.freeze({
699
+ label: "Loading",
700
+ sdkComponents: Object.freeze(["Skeleton"]),
701
+ tokens: Object.freeze({
702
+ skeletonColor: Object.freeze({ type: "color", uiDefault: "colors.surfaceMuted" }),
703
+ skeletonRadius: Object.freeze({ type: "size", min: 0, max: 24, uiDefault: "radii.sm" }),
704
+ }),
705
+ targets: Object.freeze({}),
706
+ }),
695
707
  });
696
708
 
697
709
  // sc-4505 — the value types a widget event payload field may declare. Narrower
@@ -717,7 +729,8 @@ const HOOKS = [
717
729
  "page: a container painting its own background re-derives them for its " +
718
730
  "subtree. Read them and your text is readable wherever the widget lands; " +
719
731
  "there is nothing to opt into. `loader` is that surface's spinner colour " +
720
- "(sc-6095) — paint your own loading state with it rather than a literal grey.",
732
+ "(sc-6095) — `<Spinner>` wears it by default; paint your own loading " +
733
+ "state with it rather than a literal grey.",
721
734
  spacing: "{ xs, sm, md, lg, xl }",
722
735
  spacingScale:
723
736
  "number — the app-wide spacing multiplier (REQ-THEME-LOOK, default 1). " +
@@ -1962,6 +1975,95 @@ const HOOKS = [
1962
1975
  requiredContextSlice: ["directory.users"],
1963
1976
  scopes: ["users.read:*"],
1964
1977
  },
1978
+ // sc-10691 / REQ-USERMGMT-10 — an app's user totals for its own admins.
1979
+ {
1980
+ name: "useUserStats",
1981
+ signature: "useUserStats({ days, group_id })",
1982
+ optionalArgs: ["days", "group_id"],
1983
+ description:
1984
+ "Workspace user TOTALS for an app's own administrators — never a list " +
1985
+ "of people. `days` is 7 or 30 (charted per day) or 90 (per ISO week), in " +
1986
+ "the workspace's time zone (`stats.period.time_zone`); " +
1987
+ "`group_id` (a useGroups() row id) narrows the user and invite " +
1988
+ "figures to that group. `stats.users` = { active, deactivated, " +
1989
+ "without_group }; `stats.new_users` = { total, previous_total, buckets: " +
1990
+ "[{ date, count }] } with every bucket present and previous_total the " +
1991
+ "equal-length window before the period; `stats.groups` and " +
1992
+ "`stats.empty_groups`; `stats.invites` = { " +
1993
+ "pending, expiring_soon, expired, accepted }. `without_group`, `groups` " +
1994
+ "and `empty_groups` are null on a group-scoped read. `invites` is null unless " +
1995
+ "the widget also declares users.write:* AND the viewer holds users.write " +
1996
+ "— render \"no access\" for that section rather than zeros. A viewer " +
1997
+ "without the admin user view gets error.code FORBIDDEN; show an " +
1998
+ "explicit no-access state.",
1999
+ returnShape: {
2000
+ stats:
2001
+ "{ period, group_id, users, new_users, groups, empty_groups, invites } | null",
2002
+ loading: "boolean",
2003
+ fetching: "boolean",
2004
+ error: "DirectoryError | null // FORBIDDEN without the admin user view, NOT_FOUND for a foreign group",
2005
+ refetch: "() => Promise<void>",
2006
+ },
2007
+ requiredContextSlice: ["directory.users"],
2008
+ scopes: ["users.read:*"],
2009
+ },
2010
+ // sc-10691 / REQ-USERMGMT-12 — user activity, behind users.activity.
2011
+ {
2012
+ name: "useUserActivity",
2013
+ signature: "useUserActivity({ days, group_id })",
2014
+ optionalArgs: ["days", "group_id"],
2015
+ description:
2016
+ "User ACTIVITY for an app's administrators, for a workspace that " +
2017
+ "records it: `activity.online_now` (app open in the last two minutes), " +
2018
+ "`activity.active` = { day (since midnight), week, month (last 7/30 " +
2019
+ "days), period (distinct users active in the period) }, " +
2020
+ "`activity.never_active`, " +
2021
+ "`activity.daily_active` = [{ date, count }] distinct active users per " +
2022
+ "day (7/30 days) or week (90) in the workspace's time zone, `activity.platforms` = { web, ios, " +
2023
+ "android, unknown } users active in the period, `activity.by_group` = " +
2024
+ "[{ group_id, name, users, active }] (null on a group-scoped read), " +
2025
+ "`activity.users` and `activity.recording_since` (activity before it " +
2026
+ "is unknown — say \"since <date>\" next to never-active figures). Needs " +
2027
+ "users.activity:* AND the viewer's \"View user activity\" permission " +
2028
+ "(else error.code FORBIDDEN). error.reason \"ACTIVITY_UNAVAILABLE\" means " +
2029
+ "user activity is not offered to this workspace yet: hide the section, " +
2030
+ "like FORBIDDEN. When the workspace does not record activity, " +
2031
+ "error.reason is \"ACTIVITY_DISABLED\": show that state, not zeros. " +
2032
+ "Pass null instead of a query to read nothing (a section switched off).",
2033
+ returnShape: {
2034
+ activity:
2035
+ "{ period, group_id, recording_since, users, online_now, active, never_active, daily_active, platforms, by_group } | null",
2036
+ loading: "boolean",
2037
+ fetching: "boolean",
2038
+ error: "DirectoryError | null // FORBIDDEN; reason ACTIVITY_UNAVAILABLE (hide) or ACTIVITY_DISABLED (not recorded)",
2039
+ refetch: "() => Promise<void>",
2040
+ },
2041
+ requiredContextSlice: ["directory.users"],
2042
+ scopes: ["users.activity:*"],
2043
+ },
2044
+ {
2045
+ name: "useInactiveUsers",
2046
+ signature: "useInactiveUsers({ inactive_days, group_id, limit, offset } | null)",
2047
+ optionalArgs: ["inactive_days", "group_id", "limit", "offset"],
2048
+ description:
2049
+ "One page of users not seen for `inactive_days` (1-365, default 30), " +
2050
+ "never-seen first, then least recently seen: `users` = [{ id, name, " +
2051
+ "last_active_at, last_reminded_at }] (last_active_at null = never seen " +
2052
+ "since recording began), `total` for paging. `remind(id, { title, body })` " +
2053
+ "notifies one of them, records when (last_reminded_at) and re-reads. " +
2054
+ "Same gate and errors as useUserActivity; null reads nothing.",
2055
+ returnShape: {
2056
+ users: "Array<{ id, name, last_active_at, last_reminded_at }>",
2057
+ total: "number | null",
2058
+ loading: "boolean",
2059
+ fetching: "boolean",
2060
+ error: "DirectoryError | null",
2061
+ refetch: "() => Promise<void>",
2062
+ remind: "(id, { title, body }) => Promise<void>",
2063
+ },
2064
+ requiredContextSlice: ["directory.users"],
2065
+ scopes: ["users.activity:*"],
2066
+ },
1965
2067
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
1966
2068
  // `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
1967
2069
  // Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
@@ -2608,7 +2710,7 @@ const PRIMITIVES = [
2608
2710
  {
2609
2711
  name: "ActivityIndicator",
2610
2712
  description:
2611
- "Loading spinner. See https://reactnative.dev/docs/activityindicator.",
2713
+ "The raw platform spinner. Do NOT use it for a loading state — draw `<Spinner>` (below), which wears the app's spinner colour and follows the reduced-motion setting. See https://reactnative.dev/docs/activityindicator.",
2612
2714
  rnComponent: "ActivityIndicator",
2613
2715
  docsUrl: "https://reactnative.dev/docs/activityindicator",
2614
2716
  },
@@ -2846,6 +2948,20 @@ const PRIMITIVES = [
2846
2948
  rnComponent: null,
2847
2949
  docsUrl: null,
2848
2950
  },
2951
+ {
2952
+ name: "Spinner",
2953
+ description:
2954
+ 'Themed loading spinner. `<Spinner />`, `<Spinner size="lg" label="Loading orders" />` — THE way to show that something is in progress when you cannot know its shape: a button\'s pending action, a save, a refresh, "load more", a full-widget first load with no layout yet. `size` is "sm" | "md" (default) | "lg" or a number; `label` draws a caption under the ring and is its accessibility label (unset, the app\'s own "Loading…"). Its colour is the app\'s one spinner colour (`theme.colors.loader`, set under Design → Loading Indicator and re-derived for whatever surface the widget sits on), so pass `color` ONLY inside a control you fill yourself, e.g. `color={theme.colors.onPrimary}` inside a primary button. It stops turning when the device asks for reduced motion and is a labelled element in the busy state. Never use `ActivityIndicator` or a hand-drawn spinner.',
2955
+ rnComponent: null,
2956
+ docsUrl: null,
2957
+ },
2958
+ {
2959
+ name: "Skeleton",
2960
+ description:
2961
+ 'Themed loading placeholder. `<Skeleton shape="text" width="60%" />`, `<Skeleton shape="text" lines={3} />`, `<Skeleton shape="block" height={160} />`, `<Skeleton shape="circle" size={40} />`, and the list preset `<Skeleton shape="listRow" count={4} />` (pass `leading={false}` for rows with no avatar or icon). THE loading state for content whose layout you already know — a list, a card, a profile, an article: draw the shape of what is coming so nothing jumps when it arrives. To mirror a custom layout, wrap bones in one group and lay them out yourself: `<Skeleton style={{ flexDirection: "row", gap: 12 }}><Skeleton shape="circle" /><Skeleton shape="text" lines={2} style={{ flex: 1 }} /></Skeleton>` — the outermost Skeleton is the ONE loading region (a `status` region with `aria-busy` on the web, one labelled accessible element in the busy state on native) and every bone inside is hidden from assistive technology. Its colour and corner radius come from the theme (Design → Components → Loading); `color` and `radius` override one skeleton. The outermost Skeleton pulses as a whole, and stops under reduced motion. Never hand-build placeholder bars from `View`.',
2962
+ rnComponent: null,
2963
+ docsUrl: null,
2964
+ },
2849
2965
  ];
2850
2966
 
2851
2967
  const CATEGORIES = [
@@ -5339,7 +5455,16 @@ const CONTRACT = deepFreeze({
5339
5455
  // 1.161.0: additive (sc-10930) — `Accordion` / `AccordionItem`, themed
5340
5456
  // collapsible sections drawn from the `card` and `list` scopes: single
5341
5457
  // or multiple open, controlled or uncontrolled, `aria-expanded` headers.
5342
- version: "1.161.0",
5458
+ // 1.163.0: additive (sc-10928) — the `Spinner` and `Skeleton` primitives.
5459
+ // `Spinner` wears `colors.loader`; `Skeleton` reads the new `loading`
5460
+ // component scope (colour, radius). A scope may now name `sdkComponents`
5461
+ // and bind no widget field. Both stop under reduced motion.
5462
+ // 1.164.0: additive (sc-10691) — `useUserStats(query)` reads an app's user
5463
+ // totals through the new `ctx.directory.users.stats`; `useUserActivity`
5464
+ // and `useInactiveUsers` read user activity through the new
5465
+ // `ctx.directory.users.activity` / `.inactive` / `.remind`, behind the
5466
+ // new `users.activity:*` scope.
5467
+ version: "1.164.0",
5343
5468
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5344
5469
  hooks: HOOKS,
5345
5470
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -692,6 +692,18 @@ const THEME_COMPONENTS = Object.freeze({
692
692
  "appstudio.user-management": TABS_FIELDS,
693
693
  }),
694
694
  }),
695
+ // Loading placeholders: `<Skeleton>` reads this scope directly, so it names
696
+ // the SDK component it styles and binds no widget field. `<Spinner>` is not
697
+ // here: it wears `colors.loader`, the app's one spinner colour.
698
+ loading: Object.freeze({
699
+ label: "Loading",
700
+ sdkComponents: Object.freeze(["Skeleton"]),
701
+ tokens: Object.freeze({
702
+ skeletonColor: Object.freeze({ type: "color", uiDefault: "colors.surfaceMuted" }),
703
+ skeletonRadius: Object.freeze({ type: "size", min: 0, max: 24, uiDefault: "radii.sm" }),
704
+ }),
705
+ targets: Object.freeze({}),
706
+ }),
695
707
  });
696
708
 
697
709
  // sc-4505 — the value types a widget event payload field may declare. Narrower
@@ -717,7 +729,8 @@ const HOOKS = [
717
729
  "page: a container painting its own background re-derives them for its " +
718
730
  "subtree. Read them and your text is readable wherever the widget lands; " +
719
731
  "there is nothing to opt into. `loader` is that surface's spinner colour " +
720
- "(sc-6095) — paint your own loading state with it rather than a literal grey.",
732
+ "(sc-6095) — `<Spinner>` wears it by default; paint your own loading " +
733
+ "state with it rather than a literal grey.",
721
734
  spacing: "{ xs, sm, md, lg, xl }",
722
735
  spacingScale:
723
736
  "number — the app-wide spacing multiplier (REQ-THEME-LOOK, default 1). " +
@@ -1962,6 +1975,95 @@ const HOOKS = [
1962
1975
  requiredContextSlice: ["directory.users"],
1963
1976
  scopes: ["users.read:*"],
1964
1977
  },
1978
+ // sc-10691 / REQ-USERMGMT-10 — an app's user totals for its own admins.
1979
+ {
1980
+ name: "useUserStats",
1981
+ signature: "useUserStats({ days, group_id })",
1982
+ optionalArgs: ["days", "group_id"],
1983
+ description:
1984
+ "Workspace user TOTALS for an app's own administrators — never a list " +
1985
+ "of people. `days` is 7 or 30 (charted per day) or 90 (per ISO week), in " +
1986
+ "the workspace's time zone (`stats.period.time_zone`); " +
1987
+ "`group_id` (a useGroups() row id) narrows the user and invite " +
1988
+ "figures to that group. `stats.users` = { active, deactivated, " +
1989
+ "without_group }; `stats.new_users` = { total, previous_total, buckets: " +
1990
+ "[{ date, count }] } with every bucket present and previous_total the " +
1991
+ "equal-length window before the period; `stats.groups` and " +
1992
+ "`stats.empty_groups`; `stats.invites` = { " +
1993
+ "pending, expiring_soon, expired, accepted }. `without_group`, `groups` " +
1994
+ "and `empty_groups` are null on a group-scoped read. `invites` is null unless " +
1995
+ "the widget also declares users.write:* AND the viewer holds users.write " +
1996
+ "— render \"no access\" for that section rather than zeros. A viewer " +
1997
+ "without the admin user view gets error.code FORBIDDEN; show an " +
1998
+ "explicit no-access state.",
1999
+ returnShape: {
2000
+ stats:
2001
+ "{ period, group_id, users, new_users, groups, empty_groups, invites } | null",
2002
+ loading: "boolean",
2003
+ fetching: "boolean",
2004
+ error: "DirectoryError | null // FORBIDDEN without the admin user view, NOT_FOUND for a foreign group",
2005
+ refetch: "() => Promise<void>",
2006
+ },
2007
+ requiredContextSlice: ["directory.users"],
2008
+ scopes: ["users.read:*"],
2009
+ },
2010
+ // sc-10691 / REQ-USERMGMT-12 — user activity, behind users.activity.
2011
+ {
2012
+ name: "useUserActivity",
2013
+ signature: "useUserActivity({ days, group_id })",
2014
+ optionalArgs: ["days", "group_id"],
2015
+ description:
2016
+ "User ACTIVITY for an app's administrators, for a workspace that " +
2017
+ "records it: `activity.online_now` (app open in the last two minutes), " +
2018
+ "`activity.active` = { day (since midnight), week, month (last 7/30 " +
2019
+ "days), period (distinct users active in the period) }, " +
2020
+ "`activity.never_active`, " +
2021
+ "`activity.daily_active` = [{ date, count }] distinct active users per " +
2022
+ "day (7/30 days) or week (90) in the workspace's time zone, `activity.platforms` = { web, ios, " +
2023
+ "android, unknown } users active in the period, `activity.by_group` = " +
2024
+ "[{ group_id, name, users, active }] (null on a group-scoped read), " +
2025
+ "`activity.users` and `activity.recording_since` (activity before it " +
2026
+ "is unknown — say \"since <date>\" next to never-active figures). Needs " +
2027
+ "users.activity:* AND the viewer's \"View user activity\" permission " +
2028
+ "(else error.code FORBIDDEN). error.reason \"ACTIVITY_UNAVAILABLE\" means " +
2029
+ "user activity is not offered to this workspace yet: hide the section, " +
2030
+ "like FORBIDDEN. When the workspace does not record activity, " +
2031
+ "error.reason is \"ACTIVITY_DISABLED\": show that state, not zeros. " +
2032
+ "Pass null instead of a query to read nothing (a section switched off).",
2033
+ returnShape: {
2034
+ activity:
2035
+ "{ period, group_id, recording_since, users, online_now, active, never_active, daily_active, platforms, by_group } | null",
2036
+ loading: "boolean",
2037
+ fetching: "boolean",
2038
+ error: "DirectoryError | null // FORBIDDEN; reason ACTIVITY_UNAVAILABLE (hide) or ACTIVITY_DISABLED (not recorded)",
2039
+ refetch: "() => Promise<void>",
2040
+ },
2041
+ requiredContextSlice: ["directory.users"],
2042
+ scopes: ["users.activity:*"],
2043
+ },
2044
+ {
2045
+ name: "useInactiveUsers",
2046
+ signature: "useInactiveUsers({ inactive_days, group_id, limit, offset } | null)",
2047
+ optionalArgs: ["inactive_days", "group_id", "limit", "offset"],
2048
+ description:
2049
+ "One page of users not seen for `inactive_days` (1-365, default 30), " +
2050
+ "never-seen first, then least recently seen: `users` = [{ id, name, " +
2051
+ "last_active_at, last_reminded_at }] (last_active_at null = never seen " +
2052
+ "since recording began), `total` for paging. `remind(id, { title, body })` " +
2053
+ "notifies one of them, records when (last_reminded_at) and re-reads. " +
2054
+ "Same gate and errors as useUserActivity; null reads nothing.",
2055
+ returnShape: {
2056
+ users: "Array<{ id, name, last_active_at, last_reminded_at }>",
2057
+ total: "number | null",
2058
+ loading: "boolean",
2059
+ fetching: "boolean",
2060
+ error: "DirectoryError | null",
2061
+ refetch: "() => Promise<void>",
2062
+ remind: "(id, { title, body }) => Promise<void>",
2063
+ },
2064
+ requiredContextSlice: ["directory.users"],
2065
+ scopes: ["users.activity:*"],
2066
+ },
1965
2067
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
1966
2068
  // `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
1967
2069
  // Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
@@ -2608,7 +2710,7 @@ const PRIMITIVES = [
2608
2710
  {
2609
2711
  name: "ActivityIndicator",
2610
2712
  description:
2611
- "Loading spinner. See https://reactnative.dev/docs/activityindicator.",
2713
+ "The raw platform spinner. Do NOT use it for a loading state — draw `<Spinner>` (below), which wears the app's spinner colour and follows the reduced-motion setting. See https://reactnative.dev/docs/activityindicator.",
2612
2714
  rnComponent: "ActivityIndicator",
2613
2715
  docsUrl: "https://reactnative.dev/docs/activityindicator",
2614
2716
  },
@@ -2846,6 +2948,20 @@ const PRIMITIVES = [
2846
2948
  rnComponent: null,
2847
2949
  docsUrl: null,
2848
2950
  },
2951
+ {
2952
+ name: "Spinner",
2953
+ description:
2954
+ 'Themed loading spinner. `<Spinner />`, `<Spinner size="lg" label="Loading orders" />` — THE way to show that something is in progress when you cannot know its shape: a button\'s pending action, a save, a refresh, "load more", a full-widget first load with no layout yet. `size` is "sm" | "md" (default) | "lg" or a number; `label` draws a caption under the ring and is its accessibility label (unset, the app\'s own "Loading…"). Its colour is the app\'s one spinner colour (`theme.colors.loader`, set under Design → Loading Indicator and re-derived for whatever surface the widget sits on), so pass `color` ONLY inside a control you fill yourself, e.g. `color={theme.colors.onPrimary}` inside a primary button. It stops turning when the device asks for reduced motion and is a labelled element in the busy state. Never use `ActivityIndicator` or a hand-drawn spinner.',
2955
+ rnComponent: null,
2956
+ docsUrl: null,
2957
+ },
2958
+ {
2959
+ name: "Skeleton",
2960
+ description:
2961
+ 'Themed loading placeholder. `<Skeleton shape="text" width="60%" />`, `<Skeleton shape="text" lines={3} />`, `<Skeleton shape="block" height={160} />`, `<Skeleton shape="circle" size={40} />`, and the list preset `<Skeleton shape="listRow" count={4} />` (pass `leading={false}` for rows with no avatar or icon). THE loading state for content whose layout you already know — a list, a card, a profile, an article: draw the shape of what is coming so nothing jumps when it arrives. To mirror a custom layout, wrap bones in one group and lay them out yourself: `<Skeleton style={{ flexDirection: "row", gap: 12 }}><Skeleton shape="circle" /><Skeleton shape="text" lines={2} style={{ flex: 1 }} /></Skeleton>` — the outermost Skeleton is the ONE loading region (a `status` region with `aria-busy` on the web, one labelled accessible element in the busy state on native) and every bone inside is hidden from assistive technology. Its colour and corner radius come from the theme (Design → Components → Loading); `color` and `radius` override one skeleton. The outermost Skeleton pulses as a whole, and stops under reduced motion. Never hand-build placeholder bars from `View`.',
2962
+ rnComponent: null,
2963
+ docsUrl: null,
2964
+ },
2849
2965
  ];
2850
2966
 
2851
2967
  const CATEGORIES = [
@@ -5335,7 +5451,16 @@ const CONTRACT = deepFreeze({
5335
5451
  // 1.161.0: additive (sc-10930) — `Accordion` / `AccordionItem`, themed
5336
5452
  // collapsible sections drawn from the `card` and `list` scopes: single
5337
5453
  // or multiple open, controlled or uncontrolled, `aria-expanded` headers.
5338
- version: "1.161.0",
5454
+ // 1.163.0: additive (sc-10928) — the `Spinner` and `Skeleton` primitives.
5455
+ // `Spinner` wears `colors.loader`; `Skeleton` reads the new `loading`
5456
+ // component scope (colour, radius). A scope may now name `sdkComponents`
5457
+ // and bind no widget field. Both stop under reduced motion.
5458
+ // 1.164.0: additive (sc-10691) — `useUserStats(query)` reads an app's user
5459
+ // totals through the new `ctx.directory.users.stats`; `useUserActivity`
5460
+ // and `useInactiveUsers` read user activity through the new
5461
+ // `ctx.directory.users.activity` / `.inactive` / `.remind`, behind the
5462
+ // new `users.activity:*` scope.
5463
+ version: "1.164.0",
5339
5464
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5340
5465
  hooks: HOOKS,
5341
5466
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -221,6 +221,23 @@ export function useHostNavigation() {
221
221
  return ctx ? ctx.navigation : undefined;
222
222
  }
223
223
 
224
+ // A host translation, or null on a miss: both hosts answer a miss with the
225
+ // key itself or a `{{t:…}}` placeholder.
226
+ function hostTranslation(hostT, key) {
227
+ const text = hostT(key);
228
+ const hit =
229
+ typeof text === "string" && text.length > 0 && text !== key && !text.startsWith("{{t:");
230
+ return hit ? text : null;
231
+ }
232
+
233
+ // @internal — a predefined shared string in the host's language, or
234
+ // `fallback` outside a widget context. Not re-exported from index.js.
235
+ export function useHostSharedText(key, fallback) {
236
+ const ctx = useContext(HostWidgetContext);
237
+ const hostT = ctx && ctx.i18n && typeof ctx.i18n.t === "function" ? ctx.i18n.t : null;
238
+ return (hostT && hostTranslation(hostT, sharedTranslationKey(key))) || fallback;
239
+ }
240
+
224
241
  /**
225
242
  * REQ-THEME-13 — returns the author-set per-widget style values: the object the
226
243
  * host delivers under `props.style`, keyed by the style-field names the widget
@@ -863,16 +880,8 @@ export function useI18n() {
863
880
  // miss form both hosts emit). No fallback is passed here so a miss
864
881
  // is unambiguous and we can fall through to the raw key.
865
882
  if (widgetId) {
866
- const namespaced = widgetTranslationKey(widgetId, key);
867
- const scoped = hostT(namespaced);
868
- if (
869
- typeof scoped === "string" &&
870
- scoped.length > 0 &&
871
- scoped !== namespaced &&
872
- !scoped.startsWith("{{t:")
873
- ) {
874
- return scoped;
875
- }
883
+ const scoped = hostTranslation(hostT, widgetTranslationKey(widgetId, key));
884
+ if (scoped !== null) return scoped;
876
885
  }
877
886
  // 2) REQ-L10N-SHARED: for a predefined SHARED key, try the tenant-wide
878
887
  // `shared.<key>` namespace next. This is what lets identical
@@ -883,16 +892,8 @@ export function useI18n() {
883
892
  // instance. Only the predefined keys are tried here — an
884
893
  // author-invented bare key is never silently shared.
885
894
  if (isSharedTranslationKey(key)) {
886
- const shared = sharedTranslationKey(key);
887
- const scoped = hostT(shared);
888
- if (
889
- typeof scoped === "string" &&
890
- scoped.length > 0 &&
891
- scoped !== shared &&
892
- !scoped.startsWith("{{t:")
893
- ) {
894
- return scoped;
895
- }
895
+ const scoped = hostTranslation(hostT, sharedTranslationKey(key));
896
+ if (scoped !== null) return scoped;
896
897
  }
897
898
  // 3) Fall back to the raw key (shared app keys + widgets with no
898
899
  // manifest translations — unchanged from pre-1.10 behaviour).
@@ -5612,7 +5613,8 @@ export class DirectoryError extends Error {
5612
5613
  }
5613
5614
  }
5614
5615
 
5615
- function toDirectoryError(err) {
5616
+ // Internal: shared with stats-hooks.js, not re-exported by the index.
5617
+ export function toDirectoryError(err) {
5616
5618
  // See the note in toDatastoreError: the directory CLIENT has a same-named
5617
5619
  // class, so a client rejection is read from the envelope below, not here.
5618
5620
  if (err instanceof DirectoryError) return err;
package/dist/index.d.ts CHANGED
@@ -1037,6 +1037,87 @@ export function useDatastoreFigure(
1037
1037
  options?: FigureOptions,
1038
1038
  ): FigureResult;
1039
1039
 
1040
+ export interface UserStatsQuery {
1041
+ days?: 7 | 30 | 90;
1042
+ /** A `useGroups()` row id: narrows user and invite figures to that group. */
1043
+ group_id?: string;
1044
+ }
1045
+
1046
+ export interface UserStats {
1047
+ /** Days follow the workspace's `time_zone` (IANA; "UTC" when unset). */
1048
+ period: { days: 7 | 30 | 90; bucket: "day" | "week"; from: string; to: string; time_zone: string };
1049
+ group_id: string | null;
1050
+ users: { active: number; deactivated: number; without_group: number | null };
1051
+ /** `previous_total`: the equal-length window just before the period. */
1052
+ new_users: {
1053
+ total: number;
1054
+ previous_total: number;
1055
+ buckets: Array<{ date: string; count: number }>;
1056
+ };
1057
+ /** The workspace's groups; null on a group-scoped read. */
1058
+ groups: number | null;
1059
+ empty_groups: number | null;
1060
+ /** Null unless the viewer may list invites (`users.write`). */
1061
+ invites: { pending: number; expiring_soon: number; expired: number; accepted: number } | null;
1062
+ }
1063
+
1064
+ /** An app's user totals for its own admins — never a list of people. */
1065
+ export function useUserStats(query?: UserStatsQuery): {
1066
+ stats: UserStats | null;
1067
+ loading: boolean;
1068
+ fetching: boolean;
1069
+ error: Error | null;
1070
+ refetch(): Promise<void>;
1071
+ };
1072
+
1073
+ export interface UserActivity {
1074
+ /** Days follow the workspace's `time_zone` (IANA; "UTC" when unset). */
1075
+ period: { days: 7 | 30 | 90; bucket: "day" | "week"; from: string; to: string; time_zone: string };
1076
+ group_id: string | null;
1077
+ /** When the workspace started recording. */
1078
+ recording_since: string | null;
1079
+ users: number;
1080
+ /** Seen with the app open in the last two minutes. */
1081
+ online_now: number;
1082
+ /** `day`: since midnight; `week`/`month`: last 7/30 days; `period`: distinct over the period. */
1083
+ active: { day: number; week: number; month: number; period: number };
1084
+ never_active: number;
1085
+ daily_active: Array<{ date: string; count: number }>;
1086
+ platforms: { web: number; ios: number; android: number; unknown: number };
1087
+ by_group: Array<{ group_id: string; name: string; users: number; active: number }> | null;
1088
+ }
1089
+
1090
+ /**
1091
+ * User activity for an app's admins. Needs `users.activity:*` and the
1092
+ * viewer's "View user activity" permission; `error.reason` is
1093
+ * "ACTIVITY_DISABLED" when the workspace does not record activity, and
1094
+ * "ACTIVITY_UNAVAILABLE" (hide the section) before it is offered at all.
1095
+ */
1096
+ export function useUserActivity(query?: { days?: 7 | 30 | 90; group_id?: string }): {
1097
+ activity: UserActivity | null;
1098
+ loading: boolean;
1099
+ fetching: boolean;
1100
+ error: Error | null;
1101
+ refetch(): Promise<void>;
1102
+ };
1103
+
1104
+ /** Users not seen for `inactive_days` (default 30), never-seen first. */
1105
+ export function useInactiveUsers(query?: {
1106
+ inactive_days?: number;
1107
+ group_id?: string;
1108
+ limit?: number;
1109
+ offset?: number;
1110
+ } | null): {
1111
+ users: Array<{ id: string; name: string; last_active_at: string | null; last_reminded_at: string | null }>;
1112
+ total: number | null;
1113
+ loading: boolean;
1114
+ fetching: boolean;
1115
+ error: Error | null;
1116
+ refetch(): Promise<void>;
1117
+ /** Notify one user, record when, then re-read the page. */
1118
+ remind(id: string, message: { title: string; body: string }): Promise<void>;
1119
+ };
1120
+
1040
1121
  export function useDatastoreMutation<T = unknown>(
1041
1122
  table: string,
1042
1123
  ): MutationApi<T>;
@@ -3487,6 +3568,74 @@ export const AccordionItem: (props: {
3487
3568
  testID?: string;
3488
3569
  }) => any;
3489
3570
 
3571
+ export type SpinnerSize = "sm" | "md" | "lg";
3572
+
3573
+ /**
3574
+ * Themed loading spinner. It wears the theme's `colors.loader`, stops turning
3575
+ * under reduced motion, and is a labelled element in the busy state.
3576
+ *
3577
+ * @example
3578
+ * <Spinner />
3579
+ * <Spinner size="lg" label="Loading orders" />
3580
+ */
3581
+ export const Spinner: (props: {
3582
+ /** A named size, or a diameter in px. Defaults to "md". */
3583
+ size?: SpinnerSize | number;
3584
+ /** Overrides `colors.loader`, for contrast inside a control you fill yourself. */
3585
+ color?: string;
3586
+ /** Drawn under the ring. A string is also the accessibility label; a node stays exposed as content. */
3587
+ label?: ReactNode;
3588
+ /** The accessibility label, instead of `label` / the app's own "Loading…". */
3589
+ accessibilityLabel?: string;
3590
+ /** For hosts rendering outside a widget context; widgets omit it. */
3591
+ theme?: any;
3592
+ style?: any;
3593
+ testID?: string;
3594
+ }) => any;
3595
+
3596
+ export type SkeletonShape = "text" | "block" | "circle" | "listRow";
3597
+
3598
+ /**
3599
+ * Themed loading placeholder. Its colour and corners come from the theme's
3600
+ * `loading` scope. The outermost Skeleton pulses as a whole (still under
3601
+ * reduced motion), is the one loading region (`role="status"` + `aria-busy`
3602
+ * on the web; a labelled accessible element in the busy state on native), and
3603
+ * hides every bone inside it from assistive technology — pass children to
3604
+ * group bones.
3605
+ *
3606
+ * @example
3607
+ * <Skeleton shape="listRow" count={4} />
3608
+ * <Skeleton style={{ flexDirection: "row", gap: 12 }}>
3609
+ * <Skeleton shape="circle" />
3610
+ * <Skeleton shape="text" lines={2} style={{ flex: 1 }} />
3611
+ * </Skeleton>
3612
+ */
3613
+ export const Skeleton: (props: {
3614
+ /** Defaults to "text". Ignored when children are passed. */
3615
+ shape?: SkeletonShape;
3616
+ /** `text` / `block`. Defaults to "100%". */
3617
+ width?: number | string;
3618
+ /** `text` / `block`. */
3619
+ height?: number;
3620
+ /** `circle` diameter. */
3621
+ size?: number;
3622
+ /** `text`: how many lines; the last one stops short. */
3623
+ lines?: number;
3624
+ /** `listRow`: how many rows. Defaults to 3. */
3625
+ count?: number;
3626
+ /** `listRow`: draw the leading avatar. Defaults to true. */
3627
+ leading?: boolean;
3628
+ radius?: number;
3629
+ color?: string;
3630
+ /** The region's accessibility label, instead of the app's own "Loading…". */
3631
+ accessibilityLabel?: string;
3632
+ /** For hosts rendering outside a widget context; widgets omit it. */
3633
+ theme?: any;
3634
+ style?: any;
3635
+ testID?: string;
3636
+ children?: ReactNode;
3637
+ }) => any;
3638
+
3490
3639
  export type TextRole = "display" | "title" | "subtitle" | "body" | "caption" | "overline";
3491
3640
  export const TEXT_ROLES: readonly TextRole[];
3492
3641
  export const TEXT_TRANSFORMS: readonly string[];