@colixsystems/widget-sdk 0.201.0 → 0.204.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,51 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
83
83
 
84
84
  ## Status
85
85
 
86
- `v0.201.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.204.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.204.0 (contract 1.163.0)
89
+
90
+ **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:
91
+
92
+ ```jsx
93
+ // You know the shape of what is coming: draw its outline.
94
+ if (loading) return <Skeleton shape="listRow" count={4} />;
95
+
96
+ // You do not: a pending action.
97
+ {saving ? <Spinner size="sm" color={theme.colors.onPrimary} /> : <Text>Save</Text>}
98
+ ```
99
+
100
+ `<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:
101
+
102
+ ```jsx
103
+ <Skeleton style={{ flexDirection: "row", gap: 12 }}>
104
+ <Skeleton shape="circle" />
105
+ <Skeleton shape="text" lines={2} style={{ flex: 1 }} />
106
+ </Skeleton>
107
+ ```
108
+
109
+ 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.
110
+
111
+ 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.
112
+
113
+ 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`**.
114
+
115
+ ### What's new in 0.202.0 (contract 1.161.0)
116
+
117
+ **Themed collapsible sections: `Accordion` and `AccordionItem`.** Widgets each built their own expand/collapse section, with their own header, chevron and spacing, and none told a screen reader whether the section was open. The SDK now draws one, from the app's existing look: the surface from **Theme Settings > Cards** (background, border, corner radius) and the headers and dividers from **Theme Settings > Lists** (row padding, row height, divider). No new theme scope.
118
+
119
+ ```jsx
120
+ <Accordion defaultValue="shipping">
121
+ <AccordionItem value="shipping" title="Shipping" subtitle="2-4 days">
122
+ <Text>…</Text>
123
+ </AccordionItem>
124
+ <AccordionItem value="returns" title="Returns" trailing={<Badge label="New" tone="info" />}>
125
+ <Text>…</Text>
126
+ </AccordionItem>
127
+ </Accordion>
128
+ ```
129
+
130
+ By default one section is open at a time and pressing the open one closes it; `multiple` lets several stay open. Pass `value` + `onChange` to control it (a key, or an array of keys when `multiple`), or `defaultValue` to leave it uncontrolled. Each header is a 44px button with a chevron that works from the keyboard and reports `aria-expanded`. `trailing` sits outside the header's toggle, so a button there runs its own action. A section's body mounts only while it is open, so fetch its data inside it. One view renders on both the web Player and the exported app. Additive.
87
131
 
88
132
  ### What's new in 0.201.0 (contract 1.160.0)
89
133
 
@@ -2393,6 +2437,8 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
2393
2437
  - `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`.
2394
2438
  - `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`.
2395
2439
  - `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`.
2440
+ - `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.
2441
+ - `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.
2396
2442
  - `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.
2397
2443
  - `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
2398
2444
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
@@ -0,0 +1,54 @@
1
+ // The values `<Accordion>` paints with, and its open-state rules. The surface
2
+ // comes from the theme's `card` scope and the rows from its `list` scope, so a
3
+ // look restyles every collapsible section without a scope of its own.
4
+ // Platform-free, so both hosts draw from the same numbers and tests can pin
5
+ // them without a renderer.
6
+
7
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
8
+ import { mixHex, isHexColor } from "./contract.js";
9
+ import { normaliseThemeComponents } from "./theme-components.js";
10
+ import { resolveListTokens, LIST_ROW_MIN_HEIGHT } from "./list-tokens.js";
11
+
12
+ /**
13
+ * Resolve one accordion: the `card` scope's surface, the `list` scope's row
14
+ * padding, height and divider, then the theme tokens.
15
+ *
16
+ * @param {object} theme — the resolved workspace theme (`useTheme()` shape).
17
+ */
18
+ export function resolveAccordionTokens(theme) {
19
+ const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
20
+ const colors = base.colors || DEFAULT_THEME_TOKENS.colors;
21
+ const radii = base.radii || DEFAULT_THEME_TOKENS.radii;
22
+ const card = normaliseThemeComponents(base.components).card || {};
23
+ const list = resolveListTokens(base);
24
+
25
+ const background = card.background ?? colors.surface;
26
+ return {
27
+ background,
28
+ borderColor: card.borderColor ?? colors.border,
29
+ radius: card.radius ?? radii.md,
30
+ headerPadding: list.rowPadding,
31
+ // A dense look may shrink a row, never the header's tap target.
32
+ headerMinHeight: Math.max(list.rowMinHeight, LIST_ROW_MIN_HEIGHT),
33
+ dividerColor: list.dividerColor,
34
+ dividerWidth: list.dividerWidth,
35
+ // The mix needs hex; a translucent surface tints the theme's own instead.
36
+ pressedBackground: mixHex(colors.onSurface, isHexColor(background) ? background : colors.surface, 0.94),
37
+ chevronColor: colors.onSurfaceMuted,
38
+ };
39
+ }
40
+
41
+ /** The open sections as an array, from a single key or a list of keys. */
42
+ export function openKeysOf(value) {
43
+ if (Array.isArray(value)) return value.filter((key) => key !== null && key !== undefined);
44
+ return value === null || value === undefined ? [] : [value];
45
+ }
46
+
47
+ /**
48
+ * The open sections after one header is pressed. Single mode keeps at most one
49
+ * open, and pressing the open one closes it.
50
+ */
51
+ export function toggleOpenKey(open, key, multiple) {
52
+ if (open.includes(key)) return open.filter((k) => k !== key);
53
+ return multiple ? [...open, key] : [key];
54
+ }
@@ -0,0 +1,210 @@
1
+ // The ONE implementation behind the `<Accordion>` and `<AccordionItem>` SDK
2
+ // primitives. The platform bindings (`accordion.js` / `accordion.native.js`)
3
+ // differ only in where the primitives come from, so the hosts cannot drift.
4
+ //
5
+ // Widgets each built their own collapsible section, with their own header,
6
+ // chevron and spacing, and none announced whether it was open. This one reads
7
+ // the theme's `card` and `list` scopes, so every section in an app matches.
8
+
9
+ import React from "react";
10
+ import { useHostTheme } from "./hooks.js";
11
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
12
+ import { Icon } from "./icon.js";
13
+ import { textRoleStyle } from "./text-roles.js";
14
+ import { resolveAccordionTokens, openKeysOf, toggleOpenKey } from "./accordion-tokens.js";
15
+
16
+ /**
17
+ * Bind the shared accordion to one platform's primitives.
18
+ *
19
+ * @param {{View: any, Text: any, Pressable: any}} rn
20
+ */
21
+ export function makeAccordion(rn) {
22
+ const { View, Text, Pressable } = rn;
23
+ const AccordionContext = React.createContext(null);
24
+
25
+ /**
26
+ * @param {boolean} [props.multiple] — several sections may be open at once.
27
+ * @param {string|string[]|null} [props.value] — the open section(s); makes
28
+ * the accordion controlled. A key in single mode, an array in multiple.
29
+ * @param {string|string[]|null} [props.defaultValue] — the initially open
30
+ * section(s) when uncontrolled.
31
+ * @param {(next: string|string[]|null) => void} [props.onChange] — the open
32
+ * section(s) after a header press, in the same shape as `value`.
33
+ */
34
+ function Accordion({
35
+ multiple = false,
36
+ value,
37
+ defaultValue,
38
+ onChange,
39
+ children,
40
+ accessibilityLabel,
41
+ style,
42
+ testID,
43
+ }) {
44
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
45
+ const tokens = resolveAccordionTokens(theme);
46
+ const controlled = value !== undefined;
47
+ const [ownOpen, setOwnOpen] = React.useState(() => openKeysOf(defaultValue));
48
+ const open = controlled ? openKeysOf(value) : ownOpen;
49
+
50
+ const toggle = (key) => {
51
+ if (key === null || key === undefined) return;
52
+ const next = toggleOpenKey(open, key, multiple);
53
+ if (!controlled) setOwnOpen(next);
54
+ if (typeof onChange === "function") onChange(multiple ? next : (next[0] ?? null));
55
+ };
56
+
57
+ return React.createElement(
58
+ AccordionContext.Provider,
59
+ { value: { open, toggle, tokens, theme } },
60
+ React.createElement(
61
+ View,
62
+ {
63
+ style: [
64
+ {
65
+ backgroundColor: tokens.background,
66
+ borderColor: tokens.borderColor,
67
+ borderWidth: 1,
68
+ borderRadius: tokens.radius,
69
+ overflow: "hidden",
70
+ },
71
+ style,
72
+ ],
73
+ // A label names the group only when it has a role to hang on.
74
+ role: accessibilityLabel ? "group" : undefined,
75
+ accessibilityLabel,
76
+ testID,
77
+ },
78
+ // The last section's divider slides under the clipped edge, so it
79
+ // ends on the surface wherever it sits in the tree.
80
+ React.createElement(
81
+ View,
82
+ { style: { marginBottom: -tokens.dividerWidth } },
83
+ children,
84
+ ),
85
+ ),
86
+ );
87
+ }
88
+ Accordion.displayName = "Accordion";
89
+
90
+ function AccordionItem(props) {
91
+ const context = React.useContext(AccordionContext);
92
+ // Stable per section, so a wrapped or conditional one keeps its own key.
93
+ const ownKey = React.useId();
94
+ // A lone section is an accordion of one, so it still opens and closes.
95
+ if (!context) return React.createElement(Accordion, null, React.createElement(AccordionItem, props));
96
+
97
+ const {
98
+ value = ownKey,
99
+ title,
100
+ subtitle,
101
+ trailing,
102
+ disabled = false,
103
+ divider = true,
104
+ children,
105
+ accessibilityLabel,
106
+ style,
107
+ testID,
108
+ } = props;
109
+ const { open: openKeys, toggle, tokens, theme } = context;
110
+ const open = openKeys.includes(value);
111
+ const { colors, typography } = theme;
112
+
113
+ const header = React.createElement(
114
+ View,
115
+ {
116
+ style: {
117
+ flexDirection: "row",
118
+ alignItems: "center",
119
+ paddingRight: trailing ? tokens.headerPadding : 0,
120
+ },
121
+ },
122
+ React.createElement(
123
+ Pressable,
124
+ {
125
+ onPress: () => toggle(value),
126
+ disabled,
127
+ // accessibilityRole + aria-*: the pair that announces on both hosts.
128
+ accessibilityRole: "button",
129
+ accessibilityLabel: accessibilityLabel ?? headerLabel(title, subtitle),
130
+ "aria-expanded": open,
131
+ "aria-disabled": disabled,
132
+ testID: testID ? `${testID}-header` : undefined,
133
+ style: ({ pressed }) => [
134
+ {
135
+ flex: 1,
136
+ minWidth: 0,
137
+ flexDirection: "row",
138
+ alignItems: "center",
139
+ gap: theme.spacing.sm,
140
+ minHeight: tokens.headerMinHeight,
141
+ padding: tokens.headerPadding,
142
+ },
143
+ pressed ? { backgroundColor: tokens.pressedBackground } : null,
144
+ disabled ? { opacity: 0.5 } : null,
145
+ ],
146
+ },
147
+ React.createElement(Icon, {
148
+ name: open ? "chevron-down" : "chevron-right",
149
+ size: 18,
150
+ color: tokens.chevronColor,
151
+ }),
152
+ React.createElement(
153
+ View,
154
+ { style: { flex: 1, minWidth: 0, gap: 2 } },
155
+ title !== undefined && title !== null
156
+ ? React.createElement(
157
+ Text,
158
+ { style: textRoleStyle("subtitle", typography, colors), numberOfLines: 1 },
159
+ title,
160
+ )
161
+ : null,
162
+ subtitle !== undefined && subtitle !== null
163
+ ? React.createElement(
164
+ Text,
165
+ { style: textRoleStyle("caption", typography, colors), numberOfLines: 2 },
166
+ subtitle,
167
+ )
168
+ : null,
169
+ ),
170
+ ),
171
+ // Outside the toggle, so a button here never opens the section.
172
+ trailing || null,
173
+ );
174
+
175
+ return React.createElement(
176
+ View,
177
+ {
178
+ style: [
179
+ divider && tokens.dividerWidth > 0
180
+ ? { borderBottomWidth: tokens.dividerWidth, borderColor: tokens.dividerColor }
181
+ : null,
182
+ style,
183
+ ],
184
+ testID,
185
+ },
186
+ header,
187
+ // Mounted only while open, so a closed section costs nothing.
188
+ open
189
+ ? React.createElement(
190
+ View,
191
+ {
192
+ style: { padding: tokens.headerPadding, paddingTop: 0 },
193
+ testID: testID ? `${testID}-panel` : undefined,
194
+ },
195
+ // A section nested in this body is not one of this accordion's.
196
+ React.createElement(AccordionContext.Provider, { value: null }, children),
197
+ )
198
+ : null,
199
+ );
200
+ }
201
+ AccordionItem.displayName = "AccordionItem";
202
+
203
+ // The label replaces the visible text, so it must carry the subtitle too.
204
+ function headerLabel(title, subtitle) {
205
+ const parts = [title, subtitle].filter((part) => typeof part === "string" && part !== "");
206
+ return parts.length ? parts.join(", ") : undefined;
207
+ }
208
+
209
+ return { Accordion, AccordionItem };
210
+ }
@@ -0,0 +1,7 @@
1
+ // `<Accordion>` / `<AccordionItem>` (web). Binds ./accordion-view.js to
2
+ // react-native-web; see ./primitives.js for why not the bare specifier.
3
+
4
+ import { View, Text, Pressable } from "react-native-web";
5
+ import { makeAccordion } from "./accordion-view.js";
6
+
7
+ export const { Accordion, AccordionItem } = makeAccordion({ View, Text, Pressable });
@@ -0,0 +1,7 @@
1
+ // `<Accordion>` / `<AccordionItem>` (native). Binds ./accordion-view.js to
2
+ // React Native's own primitives; only the import differs from ./accordion.js.
3
+
4
+ import { View, Text, Pressable } from "react-native";
5
+ import { makeAccordion } from "./accordion-view.js";
6
+
7
+ export const { Accordion, AccordionItem } = makeAccordion({ View, Text, Pressable });
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). " +
@@ -1450,7 +1463,7 @@ const HOOKS = [
1450
1463
  message: "string | null",
1451
1464
  signerName: "string | null",
1452
1465
  signedAt: "string | null",
1453
- verdict: "{ valid, checks, content_status, ... } | null",
1466
+ verdict: "{ valid, checks, content_status, signed_by_others_since, seal, ... } | null",
1454
1467
  loading: "boolean",
1455
1468
  error: "PermissionError | null // { code, message, retryable }",
1456
1469
  initiate: "() => Promise<{ signature_id, qr, auto_start_token, status }>",
@@ -2608,7 +2621,7 @@ const PRIMITIVES = [
2608
2621
  {
2609
2622
  name: "ActivityIndicator",
2610
2623
  description:
2611
- "Loading spinner. See https://reactnative.dev/docs/activityindicator.",
2624
+ "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
2625
  rnComponent: "ActivityIndicator",
2613
2626
  docsUrl: "https://reactnative.dev/docs/activityindicator",
2614
2627
  },
@@ -2804,6 +2817,20 @@ const PRIMITIVES = [
2804
2817
  rnComponent: null,
2805
2818
  docsUrl: null,
2806
2819
  },
2820
+ {
2821
+ name: "Accordion",
2822
+ description:
2823
+ 'Themed collapsible sections. `<Accordion defaultValue="shipping"><AccordionItem value="shipping" title="Shipping" subtitle="2-4 days">…</AccordionItem><AccordionItem value="returns" title="Returns">…</AccordionItem></Accordion>`. THE way to build a section that expands and collapses (an FAQ, a settings group, a record\'s details, a group whose members show on demand): the surface takes the app\'s card look (Theme Settings > Cards: background, border, corner radius) and the headers and dividers its list look (Theme Settings > Lists: row padding, row height, divider), so every collapsible section in the app matches. Each header has a chevron, a 44px touch target, works from the keyboard and tells assistive technology whether its section is open, so never hand-roll a collapsible from `Pressable` + a chevron + a `useState`. By default one section is open at a time and pressing the open one closes it; `multiple` lets several stay open. Props: `multiple`, `value` (the open key, or an array of keys when `multiple`; makes it controlled), `defaultValue` (the same shape, uncontrolled), `onChange(next)` (the open key(s) after a press, in the same shape), `accessibilityLabel`, `style`, `testID`. Its children are `<AccordionItem>`s.',
2824
+ rnComponent: null,
2825
+ docsUrl: null,
2826
+ },
2827
+ {
2828
+ name: "AccordionItem",
2829
+ description:
2830
+ 'One section of an `<Accordion>`. `<AccordionItem value={group.id} title={group.name} subtitle={`${group.count} members`} trailing={<Button title="Delete" variant="danger" onPress={remove} />}>…</AccordionItem>`. Props: `value` (the key `value` / `defaultValue` / `onChange` name it by — give one whenever you name sections; an item may sit inside your own component or a fragment), `title`, `subtitle` (the subtitle / caption text tiers), `trailing` (a node beside the header and OUTSIDE its toggle, so a button there runs its own action without opening the section), `disabled`, `divider` (default true; the surface edge hides the rule under the last section), `children` (the body: it mounts only while the section is open, so fetch its data inside it; an `<AccordionItem>` placed in a body is its own section, never part of the outer accordion), `accessibilityLabel` (defaults to the string `title` and `subtitle`), `style`, `testID` (also tags the header `<testID>-header` and the open body `<testID>-panel`). On its own, outside an `<Accordion>`, it is an accordion of one.',
2831
+ rnComponent: null,
2832
+ docsUrl: null,
2833
+ },
2807
2834
  {
2808
2835
  name: "Tabs",
2809
2836
  description:
@@ -2832,6 +2859,20 @@ const PRIMITIVES = [
2832
2859
  rnComponent: null,
2833
2860
  docsUrl: null,
2834
2861
  },
2862
+ {
2863
+ name: "Spinner",
2864
+ description:
2865
+ '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.',
2866
+ rnComponent: null,
2867
+ docsUrl: null,
2868
+ },
2869
+ {
2870
+ name: "Skeleton",
2871
+ description:
2872
+ '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`.',
2873
+ rnComponent: null,
2874
+ docsUrl: null,
2875
+ },
2835
2876
  ];
2836
2877
 
2837
2878
  const CATEGORIES = [
@@ -5322,7 +5363,14 @@ const CONTRACT = deepFreeze({
5322
5363
  // `usePageBadge(null)` now means "not known yet" and keeps the current
5323
5364
  // badge; `undefined` still clears it. Linter rules `badge-counter-export-missing`
5324
5365
  // (error) and `badge-counter-undeclared` (warning).
5325
- version: "1.160.0",
5366
+ // 1.161.0: additive (sc-10930) — `Accordion` / `AccordionItem`, themed
5367
+ // collapsible sections drawn from the `card` and `list` scopes: single
5368
+ // or multiple open, controlled or uncontrolled, `aria-expanded` headers.
5369
+ // 1.163.0: additive (sc-10928) — the `Spinner` and `Skeleton` primitives.
5370
+ // `Spinner` wears `colors.loader`; `Skeleton` reads the new `loading`
5371
+ // component scope (colour, radius). A scope may now name `sdkComponents`
5372
+ // and bind no widget field. Both stop under reduced motion.
5373
+ version: "1.163.0",
5326
5374
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5327
5375
  hooks: HOOKS,
5328
5376
  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). " +
@@ -1450,7 +1463,7 @@ const HOOKS = [
1450
1463
  message: "string | null",
1451
1464
  signerName: "string | null",
1452
1465
  signedAt: "string | null",
1453
- verdict: "{ valid, checks, content_status, ... } | null",
1466
+ verdict: "{ valid, checks, content_status, signed_by_others_since, seal, ... } | null",
1454
1467
  loading: "boolean",
1455
1468
  error: "PermissionError | null // { code, message, retryable }",
1456
1469
  initiate: "() => Promise<{ signature_id, qr, auto_start_token, status }>",
@@ -2608,7 +2621,7 @@ const PRIMITIVES = [
2608
2621
  {
2609
2622
  name: "ActivityIndicator",
2610
2623
  description:
2611
- "Loading spinner. See https://reactnative.dev/docs/activityindicator.",
2624
+ "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
2625
  rnComponent: "ActivityIndicator",
2613
2626
  docsUrl: "https://reactnative.dev/docs/activityindicator",
2614
2627
  },
@@ -2804,6 +2817,20 @@ const PRIMITIVES = [
2804
2817
  rnComponent: null,
2805
2818
  docsUrl: null,
2806
2819
  },
2820
+ {
2821
+ name: "Accordion",
2822
+ description:
2823
+ 'Themed collapsible sections. `<Accordion defaultValue="shipping"><AccordionItem value="shipping" title="Shipping" subtitle="2-4 days">…</AccordionItem><AccordionItem value="returns" title="Returns">…</AccordionItem></Accordion>`. THE way to build a section that expands and collapses (an FAQ, a settings group, a record\'s details, a group whose members show on demand): the surface takes the app\'s card look (Theme Settings > Cards: background, border, corner radius) and the headers and dividers its list look (Theme Settings > Lists: row padding, row height, divider), so every collapsible section in the app matches. Each header has a chevron, a 44px touch target, works from the keyboard and tells assistive technology whether its section is open, so never hand-roll a collapsible from `Pressable` + a chevron + a `useState`. By default one section is open at a time and pressing the open one closes it; `multiple` lets several stay open. Props: `multiple`, `value` (the open key, or an array of keys when `multiple`; makes it controlled), `defaultValue` (the same shape, uncontrolled), `onChange(next)` (the open key(s) after a press, in the same shape), `accessibilityLabel`, `style`, `testID`. Its children are `<AccordionItem>`s.',
2824
+ rnComponent: null,
2825
+ docsUrl: null,
2826
+ },
2827
+ {
2828
+ name: "AccordionItem",
2829
+ description:
2830
+ 'One section of an `<Accordion>`. `<AccordionItem value={group.id} title={group.name} subtitle={`${group.count} members`} trailing={<Button title="Delete" variant="danger" onPress={remove} />}>…</AccordionItem>`. Props: `value` (the key `value` / `defaultValue` / `onChange` name it by — give one whenever you name sections; an item may sit inside your own component or a fragment), `title`, `subtitle` (the subtitle / caption text tiers), `trailing` (a node beside the header and OUTSIDE its toggle, so a button there runs its own action without opening the section), `disabled`, `divider` (default true; the surface edge hides the rule under the last section), `children` (the body: it mounts only while the section is open, so fetch its data inside it; an `<AccordionItem>` placed in a body is its own section, never part of the outer accordion), `accessibilityLabel` (defaults to the string `title` and `subtitle`), `style`, `testID` (also tags the header `<testID>-header` and the open body `<testID>-panel`). On its own, outside an `<Accordion>`, it is an accordion of one.',
2831
+ rnComponent: null,
2832
+ docsUrl: null,
2833
+ },
2807
2834
  {
2808
2835
  name: "Tabs",
2809
2836
  description:
@@ -2832,6 +2859,20 @@ const PRIMITIVES = [
2832
2859
  rnComponent: null,
2833
2860
  docsUrl: null,
2834
2861
  },
2862
+ {
2863
+ name: "Spinner",
2864
+ description:
2865
+ '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.',
2866
+ rnComponent: null,
2867
+ docsUrl: null,
2868
+ },
2869
+ {
2870
+ name: "Skeleton",
2871
+ description:
2872
+ '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`.',
2873
+ rnComponent: null,
2874
+ docsUrl: null,
2875
+ },
2835
2876
  ];
2836
2877
 
2837
2878
  const CATEGORIES = [
@@ -5318,7 +5359,14 @@ const CONTRACT = deepFreeze({
5318
5359
  // `usePageBadge(null)` now means "not known yet" and keeps the current
5319
5360
  // badge; `undefined` still clears it. Linter rules `badge-counter-export-missing`
5320
5361
  // (error) and `badge-counter-undeclared` (warning).
5321
- version: "1.160.0",
5362
+ // 1.161.0: additive (sc-10930) — `Accordion` / `AccordionItem`, themed
5363
+ // collapsible sections drawn from the `card` and `list` scopes: single
5364
+ // or multiple open, controlled or uncontrolled, `aria-expanded` headers.
5365
+ // 1.163.0: additive (sc-10928) — the `Spinner` and `Skeleton` primitives.
5366
+ // `Spinner` wears `colors.loader`; `Skeleton` reads the new `loading`
5367
+ // component scope (colour, radius). A scope may now name `sdkComponents`
5368
+ // and bind no widget field. Both stop under reduced motion.
5369
+ version: "1.163.0",
5322
5370
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5323
5371
  hooks: HOOKS,
5324
5372
  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).