@colixsystems/widget-sdk 0.205.1 → 0.207.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,22 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
83
83
 
84
84
  ## Status
85
85
 
86
- `v0.205.1` — 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.207.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.207.0 (contract 1.166.0)
89
+
90
+ **A themed inline message: `Banner`, and the `alert` theme scope.** `<Banner tone="error" title="Could not save" message={err.message} action={{ label: "Retry", onPress: save }} onDismiss={() => setErr(null)} />` draws the box a widget shows when something needs saying in place: a list that failed to load, a form that cannot be submitted yet, a read-only notice. `tone` is `info` (default), `success`, `warning` or `error`; `title`, `icon`, `action` and `onDismiss` are all optional, and a long `message` wraps. `title` and `message` take text, which the banner themes, or an element you built, which it renders as given. An `error` is marked as an alert, and the other tones as a polite status: the web and Android announce them when they appear, while iOS reads the banner in place. Its fill, text colour, border and corner radius come from **Design > Components > Alerts** (`themeConfig.components.alert`: `radius`, `borderWidth`, and `<tone>Background` / `<tone>TextColor` for each tone). Left unset, each tone is a soft tint of the theme's own status colour (`danger`, `success`, `warning`, `info`) with text that clears 4.5:1 on it, the same colours a toast of that kind is accented with, so the two agree. A background set without a text colour takes whichever of dark or light text has the higher contrast on it, measured as the fill appears over the surface when it is translucent.
91
+
92
+ Use a banner for a message that belongs to a place and stays until it is resolved, and `useToast()` for a brief confirmation of something the user just did. The built-in Notifications and User Management widgets now render their error boxes through it.
93
+
94
+ Like the `loading` scope, `alert` styles an SDK component rather than a widget's style field: it declares `sdkComponents: ["Banner"]` and an empty `targets`. `bannerScopeDefaults(theme)` on the `/host` entry returns what each token resolves to while unset.
95
+
96
+ - **`CONTRACT.version` → `1.166.0`** (additive: the `Banner` primitive and the `alert` scope).
97
+ - **Changed:** a pill tab's automatic active-label colour (a custom `activeColor` with no `activeTextColor`) is now chosen by contrast between a fixed dark ink (`#111827`) and white, measured over the surface when the accent is translucent. It was chosen between the theme's own text colour and white, which left the label unreadable on a dark theme. Apps whose theme text colour is not the default will see that label in the fixed ink instead of their text colour; an explicit `activeTextColor` still wins.
98
+
99
+ ### What's new in 0.206.0 (contract 1.165.0)
100
+
101
+ **Who is online — `useOnlineMembers(tableId, recordIds)` and `useOnlineVisibility()`.** For the people who share a record (a conversation, a team, a case): which members of records you are a member of — users holding their own read grant on it, given by a person — have the app open now and show their online status. A table opts in: a template table declares `presence: true` (an upgrade that declares it turns it on), or a builder turns on the table's "Show members' online status" setting in the Studio. `online` is a `Set` of user ids across the records and `byRecord[recordId]` is `{ online_user_ids, online_count }` (ids capped at 100, so count with `online_count`); a record the viewer only reads (through a group or a grant to everyone) or cannot read answers with nobody, and there are never timestamps. It refreshes every minute while the app is in front, on return to it and on pull-to-refresh. `supported` is true only once the workspace answered: it is false while the workspace does not record user activity, when the table has not opted in, when the viewer hides their own status, or in a preview — then show no online state at all, never "offline". Declare `directory.read:users`; the publish checks do not require it for this hook, but it is the scope the hook belongs to. `useOnlineVisibility()` is the viewer's own "Show when I'm online" switch (`{ supported, showOnline, setShowOnline }`, on by default): hiding takes the viewer out of everyone's answers, and they then see nobody online either.
87
102
 
88
103
  ### What's new in 0.205.1
89
104
 
@@ -2449,6 +2464,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
2449
2464
  - `List`, `ListRow`, `ListHeader`, `EmptyState` — the themed list building blocks. `<ListRow title subtitle leading trailing onPress selected divider>` draws one record row from the theme's `list` scope (padding, height, gap, radius, background, divider); `<List>` spaces its rows and hands overrides down; `<ListHeader>` labels a table's columns on the rows' own inset; `<EmptyState icon title message>` is the "nothing here" panel. Never hand-roll a row or a header row from `View` + `Pressable`.
2450
2465
  - `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`.
2451
2466
  - `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`.
2467
+ - `Banner` — the themed inline message. `<Banner tone message>` shows an error, warning, success or notice in place, styled by the theme's `alert` scope; `action` adds one button and `onDismiss` a close control. Use `useToast()` for a brief confirmation instead. Never hand-roll a tinted error box.
2452
2468
  - `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.
2453
2469
  - `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.
2454
2470
  - `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.
@@ -0,0 +1,116 @@
1
+ // The values `<Banner>` paints with, resolved from the theme's `alert`
2
+ // component scope. Platform-free, like tabs-tokens.js, so both hosts draw one
3
+ // banner from the same numbers and tests can pin them without a renderer.
4
+
5
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
6
+ import { CONTRACT, deriveAccentTints, mixHex } from "./contract.js";
7
+ import { compositeOver, readableInk } from "./readable-ink.js";
8
+ import { normaliseThemeComponents } from "./theme-components.js";
9
+ import { KIND_COLOR_ROLE } from "./toast-host.js";
10
+
11
+ export const BANNER_TONES = Object.freeze(["info", "success", "warning", "error"]);
12
+ export const DEFAULT_BANNER_TONE = "info";
13
+ export const DEFAULT_BANNER_BORDER_WIDTH = 1;
14
+
15
+ // The glyph a tone carries when the caller names none.
16
+ export const BANNER_TONE_ICONS = Object.freeze({
17
+ info: "info",
18
+ success: "circle-check",
19
+ warning: "triangle-alert",
20
+ error: "circle-alert",
21
+ });
22
+
23
+ // How far the outline sits from the text colour toward the fill.
24
+ const BORDER_FILL_WEIGHT = 0.7;
25
+
26
+ export function normaliseBannerTone(tone) {
27
+ return BANNER_TONES.includes(tone) ? tone : DEFAULT_BANNER_TONE;
28
+ }
29
+
30
+ /** The `alert` scope's token names for one tone's fill and text. */
31
+ export function bannerToneTokenNames(tone) {
32
+ return { background: `${tone}Background`, textColor: `${tone}TextColor` };
33
+ }
34
+
35
+ // The soft fill + readable text of a tone, from the status colour a toast of
36
+ // the same kind is accented with.
37
+ function defaultTonePair(colors, tone) {
38
+ const fallback = DEFAULT_THEME_TOKENS.colors;
39
+ const role = KIND_COLOR_ROLE[tone];
40
+ const tints = deriveAccentTints(
41
+ colors[role] || fallback[role],
42
+ colors.surface || fallback.surface,
43
+ colors.onSurface || fallback.onSurface,
44
+ );
45
+ return tints
46
+ ? { background: tints.primarySoft, textColor: tints.onPrimarySoft }
47
+ : { background: colors.surfaceMuted, textColor: colors.onSurface };
48
+ }
49
+
50
+ /**
51
+ * Resolve one banner: the theme's `alert` scope, then the tone's status
52
+ * colour. A themed fill with no themed text takes whichever of dark or light
53
+ * text has the higher contrast on it, so the message stays legible.
54
+ *
55
+ * @param {object} theme — the resolved workspace theme (`useTheme()` shape).
56
+ * @param {string} tone — `info` | `success` | `warning` | `error`.
57
+ */
58
+ export function resolveBannerTokens(theme, tone) {
59
+ const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
60
+ const fallback = DEFAULT_THEME_TOKENS;
61
+ const colors = base.colors || fallback.colors;
62
+ const radii = base.radii || fallback.radii;
63
+ const safeTone = normaliseBannerTone(tone);
64
+ const scope = normaliseThemeComponents(base.components).alert || {};
65
+ const names = bannerToneTokenNames(safeTone);
66
+ const derived = defaultTonePair(colors, safeTone);
67
+
68
+ const background = scope[names.background] ?? derived.background;
69
+ const textColor =
70
+ scope[names.textColor] ??
71
+ (scope[names.background] === undefined
72
+ ? derived.textColor
73
+ : readableInk(background, colors.surface));
74
+ return {
75
+ tone: safeTone,
76
+ background,
77
+ textColor,
78
+ borderColor: mixHex(
79
+ textColor,
80
+ compositeOver(background, colors.surface),
81
+ BORDER_FILL_WEIGHT,
82
+ ),
83
+ borderWidth: scope.borderWidth ?? DEFAULT_BANNER_BORDER_WIDTH,
84
+ radius: scope.radius ?? radii.md,
85
+ icon: BANNER_TONE_ICONS[safeTone],
86
+ };
87
+ }
88
+
89
+ // The tone a token belongs to and the resolved value it sets.
90
+ function tokenReading(token) {
91
+ for (const tone of BANNER_TONES) {
92
+ const names = bannerToneTokenNames(tone);
93
+ if (token === names.background) return { tone, value: "background" };
94
+ if (token === names.textColor) return { tone, value: "textColor" };
95
+ }
96
+ return { tone: DEFAULT_BANNER_TONE, value: token };
97
+ }
98
+
99
+ /**
100
+ * What each `alert` token resolves to once THAT token is unset, keyed by token
101
+ * name, so the Studio previews the value each control would replace. The rest
102
+ * of the scope stays: a tone's default text depends on its themed fill.
103
+ *
104
+ * @param {object} theme — the resolved workspace theme (`useTheme()` shape).
105
+ */
106
+ export function bannerScopeDefaults(theme) {
107
+ const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
108
+ const scope = (base.components && base.components.alert) || {};
109
+ const out = {};
110
+ for (const token of Object.keys(CONTRACT.themeComponents.alert.tokens)) {
111
+ const { tone, value } = tokenReading(token);
112
+ const without = { ...base, components: { ...base.components, alert: { ...scope, [token]: undefined } } };
113
+ out[token] = resolveBannerTokens(without, tone)[value];
114
+ }
115
+ return out;
116
+ }
@@ -0,0 +1,198 @@
1
+ // The ONE implementation behind the `<Banner>` SDK primitive. The platform
2
+ // bindings (`banner.js` / `banner.native.js`) differ only in where the
3
+ // primitives come from, so the hosts cannot drift.
4
+ //
5
+ // Widgets each drew their own error box, so the same kind of message looked
6
+ // different in every widget. This one reads the theme's `alert` scope.
7
+
8
+ import React from "react";
9
+ import { useHostTheme } from "./hooks.js";
10
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
11
+ import { Icon } from "./icon.js";
12
+ import { resolveBannerTokens } from "./banner-tokens.js";
13
+
14
+ const ICON_SIZE = 18;
15
+ const DISMISS_ICON_SIZE = 16;
16
+ const LINE_MIN_HEIGHT = 24;
17
+ // A real 36px target: the web host ignores hitSlop, so size carries it.
18
+ const CONTROL_MIN_SIZE = 36;
19
+ const CONTROL_HIT_SLOP = 4;
20
+
21
+ const h = React.createElement;
22
+
23
+ function isText(value) {
24
+ return typeof value === "string" || typeof value === "number";
25
+ }
26
+
27
+ function isShown(value) {
28
+ return value !== undefined && value !== null && value !== false && value !== "";
29
+ }
30
+
31
+ // An error is an alert on both hosts. The other tones are a polite live
32
+ // region on the web and Android; iOS has no equivalent and reads them in place.
33
+ function announcement(tone) {
34
+ return tone === "error"
35
+ ? { accessibilityRole: "alert", accessibilityLiveRegion: "assertive" }
36
+ : { role: "status", accessibilityLiveRegion: "polite" };
37
+ }
38
+
39
+ /**
40
+ * Bind the shared banner to one platform's primitives.
41
+ *
42
+ * @param {{View: any, Text: any, Pressable: any}} rn
43
+ */
44
+ export function makeBanner(rn) {
45
+ const { View, Text, Pressable } = rn;
46
+
47
+ function BannerAction({ action, ink, typography, fallbackTestID }) {
48
+ return h(
49
+ Pressable,
50
+ {
51
+ onPress: action.onPress,
52
+ accessibilityRole: "button",
53
+ accessibilityLabel: action.accessibilityLabel || action.label,
54
+ hitSlop: CONTROL_HIT_SLOP,
55
+ testID: action.testID || fallbackTestID,
56
+ style: ({ pressed }) => [
57
+ {
58
+ minWidth: CONTROL_MIN_SIZE,
59
+ minHeight: CONTROL_MIN_SIZE,
60
+ paddingHorizontal: 4,
61
+ alignItems: "center",
62
+ justifyContent: "center",
63
+ flexShrink: 0,
64
+ },
65
+ pressed ? { opacity: 0.6 } : null,
66
+ ],
67
+ },
68
+ h(
69
+ Text,
70
+ {
71
+ style: {
72
+ color: ink,
73
+ fontFamily: typography.fontFamily,
74
+ fontSize: typography.sizes.sm,
75
+ fontWeight: "600",
76
+ textDecorationLine: "underline",
77
+ },
78
+ },
79
+ action.label,
80
+ ),
81
+ );
82
+ }
83
+
84
+ function BannerDismiss({ onDismiss, label, ink, testID }) {
85
+ return h(
86
+ Pressable,
87
+ {
88
+ onPress: onDismiss,
89
+ accessibilityRole: "button",
90
+ accessibilityLabel: label,
91
+ hitSlop: CONTROL_HIT_SLOP,
92
+ testID,
93
+ style: ({ pressed }) => [
94
+ {
95
+ minWidth: CONTROL_MIN_SIZE,
96
+ minHeight: CONTROL_MIN_SIZE,
97
+ alignItems: "center",
98
+ justifyContent: "center",
99
+ flexShrink: 0,
100
+ },
101
+ pressed ? { opacity: 0.6 } : null,
102
+ ],
103
+ },
104
+ h(Icon, { name: "x", size: DISMISS_ICON_SIZE, color: ink }),
105
+ );
106
+ }
107
+
108
+ function Banner({
109
+ tone,
110
+ title,
111
+ message,
112
+ icon,
113
+ action,
114
+ onDismiss,
115
+ dismissAccessibilityLabel = "Dismiss",
116
+ theme: explicitTheme,
117
+ style,
118
+ testID,
119
+ }) {
120
+ const hostTheme = useHostTheme();
121
+ // A Studio preview renders outside a widget context, so it passes its own.
122
+ const theme = explicitTheme || hostTheme || DEFAULT_THEME_TOKENS;
123
+ const t = resolveBannerTokens(theme, tone);
124
+ const spacing = theme.spacing || DEFAULT_THEME_TOKENS.spacing;
125
+ const typography = theme.typography || DEFAULT_THEME_TOKENS.typography;
126
+ const glyph = icon === false || icon === null ? null : icon || t.icon;
127
+ const hasAction =
128
+ action && isShown(action.label) && typeof action.onPress === "function";
129
+ const hasDismiss = typeof onDismiss === "function";
130
+ // Text sits level with a control when the row has one.
131
+ const lineMinHeight = hasAction || hasDismiss ? CONTROL_MIN_SIZE : LINE_MIN_HEIGHT;
132
+ const text = {
133
+ color: t.textColor,
134
+ fontFamily: typography.fontFamily,
135
+ fontSize: typography.sizes.sm,
136
+ };
137
+ // Text is wrapped and themed; an element the caller built renders as given.
138
+ const slot = (value, style) => {
139
+ if (!isShown(value)) return null;
140
+ return isText(value) ? h(Text, { style }, value) : value;
141
+ };
142
+
143
+ return h(
144
+ View,
145
+ {
146
+ ...announcement(t.tone),
147
+ testID,
148
+ style: [
149
+ {
150
+ flexDirection: "row",
151
+ alignItems: "flex-start",
152
+ gap: spacing.sm,
153
+ paddingVertical: spacing.sm,
154
+ paddingHorizontal: spacing.md,
155
+ borderRadius: t.radius,
156
+ borderWidth: t.borderWidth,
157
+ borderColor: t.borderColor,
158
+ backgroundColor: t.background,
159
+ },
160
+ style,
161
+ ],
162
+ },
163
+ glyph
164
+ ? h(
165
+ View,
166
+ { style: { minHeight: lineMinHeight, justifyContent: "center" } },
167
+ h(Icon, { name: glyph, size: ICON_SIZE, color: t.textColor }),
168
+ )
169
+ : null,
170
+ h(
171
+ View,
172
+ // minWidth 0 lets a long message wrap instead of pushing the row wide.
173
+ { style: { flex: 1, minWidth: 0, minHeight: lineMinHeight, justifyContent: "center", gap: 2 } },
174
+ slot(title, { ...text, fontWeight: "600" }),
175
+ slot(message, text),
176
+ ),
177
+ hasAction
178
+ ? h(BannerAction, {
179
+ action,
180
+ ink: t.textColor,
181
+ typography,
182
+ fallbackTestID: testID ? `${testID}-action` : undefined,
183
+ })
184
+ : null,
185
+ hasDismiss
186
+ ? h(BannerDismiss, {
187
+ onDismiss,
188
+ label: dismissAccessibilityLabel,
189
+ ink: t.textColor,
190
+ testID: testID ? `${testID}-dismiss` : undefined,
191
+ })
192
+ : null,
193
+ );
194
+ }
195
+
196
+ Banner.displayName = "Banner";
197
+ return Banner;
198
+ }
package/dist/banner.js ADDED
@@ -0,0 +1,7 @@
1
+ // `<Banner>` (web). Binds ./banner-view.js to react-native-web; see
2
+ // ./primitives.js for why not the bare specifier.
3
+
4
+ import * as ReactNative from "react-native-web";
5
+ import { makeBanner } from "./banner-view.js";
6
+
7
+ export const Banner = makeBanner(ReactNative);
@@ -0,0 +1,7 @@
1
+ // `<Banner>` (native). Binds ./banner-view.js to React Native's own
2
+ // primitives; only the import differs from ./banner.js.
3
+
4
+ import { View, Text, Pressable } from "react-native";
5
+ import { makeBanner } from "./banner-view.js";
6
+
7
+ export const Banner = makeBanner({ View, Text, Pressable });
package/dist/contract.cjs CHANGED
@@ -704,6 +704,25 @@ const THEME_COMPONENTS = Object.freeze({
704
704
  }),
705
705
  targets: Object.freeze({}),
706
706
  }),
707
+ // Inline messages: `<Banner>` reads this scope directly, so it names the
708
+ // SDK component it styles and binds no widget field.
709
+ alert: Object.freeze({
710
+ label: "Alerts",
711
+ sdkComponents: Object.freeze(["Banner"]),
712
+ tokens: Object.freeze({
713
+ radius: Object.freeze({ type: "size", min: 0, max: 32, uiDefault: "radii.md" }),
714
+ borderWidth: Object.freeze({ type: "size", min: 0, max: 4 }),
715
+ infoBackground: Object.freeze({ type: "color" }),
716
+ infoTextColor: Object.freeze({ type: "color" }),
717
+ successBackground: Object.freeze({ type: "color" }),
718
+ successTextColor: Object.freeze({ type: "color" }),
719
+ warningBackground: Object.freeze({ type: "color" }),
720
+ warningTextColor: Object.freeze({ type: "color" }),
721
+ errorBackground: Object.freeze({ type: "color" }),
722
+ errorTextColor: Object.freeze({ type: "color" }),
723
+ }),
724
+ targets: Object.freeze({}),
725
+ }),
707
726
  });
708
727
 
709
728
  // sc-4505 — the value types a widget event payload field may declare. Narrower
@@ -2064,6 +2083,62 @@ const HOOKS = [
2064
2083
  requiredContextSlice: ["directory.users"],
2065
2084
  scopes: ["users.activity:*"],
2066
2085
  },
2086
+ // sc-11025 / REQ-USERMGMT-12 — online status for people who share a record.
2087
+ {
2088
+ name: "useOnlineMembers",
2089
+ signature: "useOnlineMembers(tableId, recordIds)",
2090
+ description:
2091
+ "Who is online among the members of each record the viewer is a " +
2092
+ "member of (a conversation, a team, a case), on a table that shows its " +
2093
+ "members' online status (a template table's `presence: true`, or the " +
2094
+ "table setting in the Studio). Members hold their OWN read grant on the " +
2095
+ "record, given to them by a person (a grant from a template or one " +
2096
+ "inherited from a parent row names no member). `online` is a Set of " +
2097
+ "the user ids online across the records; `byRecord[recordId]` is " +
2098
+ "{ online_user_ids, online_count } — the ids are capped at 100 per " +
2099
+ "record, so count with `online_count`. A record the viewer only reads " +
2100
+ "(through a group or a grant to everyone) or cannot read answers with " +
2101
+ "nobody. Refreshes every minute while the app is in front, on return " +
2102
+ "to it and on pull-to-refresh. `supported` is true only once the " +
2103
+ "workspace answered: it is false while the workspace does not record " +
2104
+ "user activity, when the table has not opted in, when the viewer hides " +
2105
+ "their own status (useOnlineVisibility), and in a preview — then show " +
2106
+ "NO online state at all, never \"offline\". The viewer's own id may be " +
2107
+ "in `online`; leave it out when you mean \"someone else is here\". " +
2108
+ "Never timestamps. Declare `directory.read:users`: the publish checks " +
2109
+ "do not require it for this hook, but it is the scope the hook belongs to.",
2110
+ returnShape: {
2111
+ supported: "boolean // false: render no online dots, no counts, no 'offline' state",
2112
+ online: "Set<string> // user ids online across the records",
2113
+ byRecord: "{ [recordId]: { online_user_ids: string[], online_count: number } }",
2114
+ loading: "boolean",
2115
+ error: "DirectoryError | null // reason ACTIVITY_UNAVAILABLE, ACTIVITY_DISABLED, ONLINE_STATUS_HIDDEN, PRESENCE_NOT_ENABLED or FORBIDDEN (not a live person) when refused",
2116
+ refetch: "() => Promise<void>",
2117
+ },
2118
+ requiredContextSlice: ["directory.presence"],
2119
+ scopes: ["directory.read:users"],
2120
+ },
2121
+ {
2122
+ name: "useOnlineVisibility",
2123
+ signature: "useOnlineVisibility()",
2124
+ description:
2125
+ "The viewer's own \"Show when I'm online\" switch, on by default. " +
2126
+ "`supported` is false where the workspace shows nobody online: offer " +
2127
+ "the switch only when it is true. `setShowOnline(false)` hides the " +
2128
+ "viewer from everyone, and they then see nobody online either; every " +
2129
+ "useOnlineMembers on the page re-reads. No scope: it is the viewer's " +
2130
+ "own setting.",
2131
+ returnShape: {
2132
+ supported: "boolean",
2133
+ showOnline: "boolean | null // null until known",
2134
+ loading: "boolean",
2135
+ saving: "boolean",
2136
+ error: "DirectoryError | null",
2137
+ setShowOnline: "(showOnline: boolean) => Promise<void> // rejects with DirectoryError",
2138
+ },
2139
+ requiredContextSlice: ["directory.presence"],
2140
+ scopes: null,
2141
+ },
2067
2142
  // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
2068
2143
  // `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
2069
2144
  // Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
@@ -2906,6 +2981,13 @@ const PRIMITIVES = [
2906
2981
  rnComponent: null,
2907
2982
  docsUrl: null,
2908
2983
  },
2984
+ {
2985
+ name: "Banner",
2986
+ description:
2987
+ 'Themed inline message. `<Banner tone="error" title="Could not save" message={err.message} action={{ label: "Retry", onPress: save }} onDismiss={() => setErr(null)} />`. THE way to show an error, warning, success or notice that belongs to a place in the widget and stays until it is resolved: a list that failed to load, a form the user cannot submit yet, a "read-only" or "offline" notice. Render it where the problem is — above the list or form it concerns. Use `useToast()` instead for a brief confirmation of something the user just did ("Saved", "Deleted") that needs no decision and may disappear on its own; never both for one event. Its fill, text colour, border and corner radius come from the app\'s theme per tone (Design > Components > Alerts), so never hand-roll a tinted box from `View` + a border + a hex. Props: `tone` ("info" default | "success" | "warning" | "error"), `title` (optional, one short line) and `message` (wraps — never truncate it): text, or an element you built when part of it needs its own styling, `icon` (a lucide name replacing the tone\'s own glyph, or `false` for none), `action` (`{ label, onPress, accessibilityLabel?, testID? }`, ONE action such as Retry), `onDismiss` (pass it to show a close control; you hide the banner in the handler), `dismissAccessibilityLabel` (default "Dismiss" — pass a translated one), `style` (spacing around it), `testID`. An `error` is marked as an alert, and the other tones as a polite status: the web and Android announce them when they appear, while iOS reads the banner in place.',
2988
+ rnComponent: null,
2989
+ docsUrl: null,
2990
+ },
2909
2991
  {
2910
2992
  name: "Accordion",
2911
2993
  description:
@@ -3218,7 +3300,7 @@ const MANIFEST_SCHEMA = {
3218
3300
  type: "object",
3219
3301
  required: false,
3220
3302
  description:
3221
- "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation).",
3303
+ "Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation). A table may declare `presence: true` so the people who share a row see which of each other are online (useOnlineMembers); it takes effect only while the workspace records user activity, and an upgrade that declares it turns it on for the existing table.",
3222
3304
  },
3223
3305
  translations: {
3224
3306
  type: "object",
@@ -3369,10 +3451,11 @@ const WIDGET_CONTEXT_SHAPE = {
3369
3451
  "{ me(), users: { list(query?) -> Promise<{ data, meta }>, get(id), invite(body), deactivate(id), reactivate(id) }, " +
3370
3452
  "groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
3371
3453
  "invites: { list(), revoke(id), resend(id) }, " +
3372
- "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
3373
- "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke pending invites, gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
3454
+ "bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() }, " +
3455
+ "presence: { online(tableId, recordIds) -> { data: [{ record_id, online_user_ids, online_count }] }, me() -> { show_online }, setShowOnline(bool) -> { show_online } } }. " +
3456
+ "users backs useDirectory() + useUsers(); groups backs useGroups(); invites backs useInvites() (list/resend/revoke pending invites, gated by users.write:* + the users.write capability); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope); presence backs useOnlineMembers() + useOnlineVisibility() (session-gated, no scope header). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
3374
3457
  required: true,
3375
- fields: { users: "object", groups: "object", invites: "object", bankid: "object" },
3458
+ fields: { users: "object", groups: "object", invites: "object", bankid: "object", presence: "object" },
3376
3459
  },
3377
3460
  assets: {
3378
3461
  description:
@@ -5464,7 +5547,14 @@ const CONTRACT = deepFreeze({
5464
5547
  // and `useInactiveUsers` read user activity through the new
5465
5548
  // `ctx.directory.users.activity` / `.inactive` / `.remind`, behind the
5466
5549
  // new `users.activity:*` scope.
5467
- version: "1.164.0",
5550
+ // 1.165.0: additive (sc-11025) — `useOnlineMembers(tableId, recordIds)`
5551
+ // and `useOnlineVisibility()` read who of a record's members is online
5552
+ // and the viewer's own switch, through the new `ctx.directory.presence`.
5553
+ // A `datastoreTemplate` table may declare `presence: true`.
5554
+ // 1.166.0: additive (sc-10927) — the `Banner` primitive (a themed inline
5555
+ // error, warning, success or notice) and the `alert` component scope it
5556
+ // reads.
5557
+ version: "1.166.0",
5468
5558
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5469
5559
  hooks: HOOKS,
5470
5560
  primitives: PRIMITIVES,