@colixsystems/widget-sdk 0.200.0 → 0.202.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,75 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
83
83
 
84
84
  ## Status
85
85
 
86
- `v0.200.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.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**.
87
+
88
+ ### What's new in 0.202.0 (contract 1.161.0)
89
+
90
+ **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.
91
+
92
+ ```jsx
93
+ <Accordion defaultValue="shipping">
94
+ <AccordionItem value="shipping" title="Shipping" subtitle="2-4 days">
95
+ <Text>…</Text>
96
+ </AccordionItem>
97
+ <AccordionItem value="returns" title="Returns" trailing={<Badge label="New" tone="info" />}>
98
+ <Text>…</Text>
99
+ </AccordionItem>
100
+ </Accordion>
101
+ ```
102
+
103
+ 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.
104
+
105
+ ### What's new in 0.201.0 (contract 1.160.0)
106
+
107
+ **A page badge can stay live while its page is closed — `manifest.badgeCounter` + `useBadgeCount(props)`.** `usePageBadge` only runs while your widget is on screen, so a chat's unread count used to reach the menu only after someone opened the chat's page. Declare `badgeCounter: true` and export a counting hook from every platform entry, and the app runs it in the background for each placement on a page its menu links to (installed marketplace widgets, your workspace's own included):
108
+
109
+ ```jsx
110
+ import { useMemo } from "react";
111
+ import {
112
+ useDatastoreQuery,
113
+ useDatastoreSubscription,
114
+ usePageBadge,
115
+ useStableQuery,
116
+ useUser,
117
+ } from "@colixsystems/widget-sdk";
118
+
119
+ function useOpenTasks({ tasksTableId }) {
120
+ const me = useUser().id;
121
+ const table = me ? tasksTableId : null;
122
+ const { data, loading, refetch } = useDatastoreQuery(
123
+ table,
124
+ useStableQuery(() => ({ filter: { Assignee: "eq:me", Done: "eq:false" }, limit: 500 })),
125
+ );
126
+ // Stay live while the page is closed: re-read whenever a task changes.
127
+ const handlers = useMemo(
128
+ () => ({ onCreated: refetch, onUpdated: refetch, onDeleted: refetch }),
129
+ [refetch],
130
+ );
131
+ useDatastoreSubscription(table, handlers);
132
+ return loading ? null : data.length;
133
+ }
134
+
135
+ // The host runs this while the page is closed.
136
+ export function useBadgeCount(props) {
137
+ return useOpenTasks(props);
138
+ }
139
+
140
+ export default function TaskList(props) {
141
+ usePageBadge(useOpenTasks(props));
142
+ // ...
143
+ }
144
+ ```
145
+
146
+ - **The hook** is called with the placement's resolved props, inside the widget's own context. Return a whole number, or `null` while the count is not known yet. It must only read: no rendering, no data writes, no navigation, no events or toasts, nothing marked read, and no `usePageBadge`, `useScreenTitle`, `useSectionEmpty` or `useWidgetRoute` setter.
147
+ - **The host enforces read-only.** A counter's datastore and directory offer only their read methods and realtime subscriptions, and notification mutes only `get`/`list`. Writes, typing signals, payments, `notifications.send`, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable, exactly as on a host that does not provide them.
148
+ - **Stay live.** The counter runs for as long as the app is open, so subscribe to changes (as above) or refetch on a slow interval; a counter that reads once goes stale.
149
+ - **Share one implementation.** Count with the same function the widget badges with, so the background count and the open widget never disagree.
150
+ - **`usePageBadge(null)` is a new signal.** An explicit `null` means "not known yet": nothing is written and the current badge stays. Pass `null` until your first load settles, so opening the page never flashes the badge to 0. `undefined`, `0` and any other value that is not a positive number still clear this widget's share, exactly as before, so `usePageBadge(data?.count)` behaves as it always has.
151
+ - **New linter rules.** `badge-counter-export-missing` (error) refuses a widget that declares `badgeCounter` while an entry has no `useBadgeCount` export; `badge-counter-undeclared` (warning) flags the export without the declaration. The export name is published as `CONTRACT.badgeCounterExport`.
152
+ - **Types:** `WidgetManifest.badgeCounter` and `BadgeCounterHook`.
153
+
154
+ Additive: a widget without `badgeCounter` is unaffected.
87
155
 
88
156
  ### What's new in 0.200.0 (contract 1.159.0)
89
157
 
@@ -2342,6 +2410,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
2342
2410
  - `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`.
2343
2411
  - `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`.
2344
2412
  - `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
+ - `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.
2345
2414
  - `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.
2346
2415
  - `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
2347
2416
  - `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
@@ -1065,7 +1065,10 @@ const HOOKS = [
1065
1065
  description:
1066
1066
  "Puts count on the menu entry or tab of the page this widget is on (a chat's unread messages, open tasks). " +
1067
1067
  "The host sums every widget on the page and keeps the last count after the widget leaves the screen, until it " +
1068
- "reports again; 0, or anything not a positive number, clears this widget's share. Returns { supported }: false " +
1068
+ "reports again; 0, undefined, or any other value that is not a positive number, clears this widget's share. " +
1069
+ "An explicit null means the count is not known yet and keeps the current badge (pass null until the first load " +
1070
+ "settles, so the badge never flashes to 0). manifest.badgeCounter keeps the badge live while the page is closed; " +
1071
+ "see that field for who may declare it. Returns { supported }: false " +
1069
1072
  "on a host without page badges (the Builder canvas), where it does nothing. Call it at render with the current " +
1070
1073
  "count, never in a loop. Backed by ctx.navigation.setBadge. Identical on web (Player) and the Expo export.",
1071
1074
  returnShape: {
@@ -1447,7 +1450,7 @@ const HOOKS = [
1447
1450
  message: "string | null",
1448
1451
  signerName: "string | null",
1449
1452
  signedAt: "string | null",
1450
- verdict: "{ valid, checks, content_status, ... } | null",
1453
+ verdict: "{ valid, checks, content_status, signed_by_others_since, seal, ... } | null",
1451
1454
  loading: "boolean",
1452
1455
  error: "PermissionError | null // { code, message, retryable }",
1453
1456
  initiate: "() => Promise<{ signature_id, qr, auto_start_token, status }>",
@@ -2801,6 +2804,20 @@ const PRIMITIVES = [
2801
2804
  rnComponent: null,
2802
2805
  docsUrl: null,
2803
2806
  },
2807
+ {
2808
+ name: "Accordion",
2809
+ description:
2810
+ '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.',
2811
+ rnComponent: null,
2812
+ docsUrl: null,
2813
+ },
2814
+ {
2815
+ name: "AccordionItem",
2816
+ description:
2817
+ '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.',
2818
+ rnComponent: null,
2819
+ docsUrl: null,
2820
+ },
2804
2821
  {
2805
2822
  name: "Tabs",
2806
2823
  description:
@@ -3047,6 +3064,13 @@ const MANIFEST_SCHEMA = {
3047
3064
  "Optional (default false). TRUE when the widget draws a strip of live figures (counts, totals, averages) about the records it shows, or is itself a strip of figures. The AppStudio app-builder reads this when it places an already-installed widget: it counts the widget as the page's figures, so it does not plan a second strip of figures beside it. Set it to match what the component actually renders; leave it false (or omit) when the widget draws no figures.",
3048
3065
  default: false,
3049
3066
  },
3067
+ badgeCounter: {
3068
+ type: "boolean",
3069
+ required: false,
3070
+ description:
3071
+ "Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
3072
+ default: false,
3073
+ },
3050
3074
  events: {
3051
3075
  type: "object[]",
3052
3076
  required: true,
@@ -5305,7 +5329,17 @@ const CONTRACT = deepFreeze({
5305
5329
  // flat `assets/` directory (`bundledAssets`, within `packageMaxBytes`).
5306
5330
  // New `useBundledAsset(fileName)` resolves one through the new
5307
5331
  // `widget.assets` map; linter rule `bundled-asset-missing` (error).
5308
- version: "1.159.0",
5332
+ // 1.160.0: additive (sc-10829) — optional manifest field `badgeCounter`:
5333
+ // the entry exports `useBadgeCount(props)` (name in `badgeCounterExport`)
5334
+ // and the host runs it while the widget's page is closed, so the page
5335
+ // badge stays live; the host gives a counter a read-only context.
5336
+ // `usePageBadge(null)` now means "not known yet" and keeps the current
5337
+ // badge; `undefined` still clears it. Linter rules `badge-counter-export-missing`
5338
+ // (error) and `badge-counter-undeclared` (warning).
5339
+ // 1.161.0: additive (sc-10930) — `Accordion` / `AccordionItem`, themed
5340
+ // collapsible sections drawn from the `card` and `list` scopes: single
5341
+ // or multiple open, controlled or uncontrolled, `aria-expanded` headers.
5342
+ version: "1.161.0",
5309
5343
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5310
5344
  hooks: HOOKS,
5311
5345
  primitives: PRIMITIVES,
@@ -5331,6 +5365,8 @@ const CONTRACT = deepFreeze({
5331
5365
  themeWidgetStyles: THEME_WIDGET_STYLES,
5332
5366
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
5333
5367
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
5368
+ // sc-10829 — the hook a `badgeCounter: true` entry exports by name.
5369
+ badgeCounterExport: "useBadgeCount",
5334
5370
  bannedApis: BANNED_APIS,
5335
5371
  vettedImports: VETTED_IMPORTS,
5336
5372
  allowedBareImports: ALLOWED_BARE_IMPORTS,
package/dist/contract.js CHANGED
@@ -1065,7 +1065,10 @@ const HOOKS = [
1065
1065
  description:
1066
1066
  "Puts count on the menu entry or tab of the page this widget is on (a chat's unread messages, open tasks). " +
1067
1067
  "The host sums every widget on the page and keeps the last count after the widget leaves the screen, until it " +
1068
- "reports again; 0, or anything not a positive number, clears this widget's share. Returns { supported }: false " +
1068
+ "reports again; 0, undefined, or any other value that is not a positive number, clears this widget's share. " +
1069
+ "An explicit null means the count is not known yet and keeps the current badge (pass null until the first load " +
1070
+ "settles, so the badge never flashes to 0). manifest.badgeCounter keeps the badge live while the page is closed; " +
1071
+ "see that field for who may declare it. Returns { supported }: false " +
1069
1072
  "on a host without page badges (the Builder canvas), where it does nothing. Call it at render with the current " +
1070
1073
  "count, never in a loop. Backed by ctx.navigation.setBadge. Identical on web (Player) and the Expo export.",
1071
1074
  returnShape: {
@@ -1447,7 +1450,7 @@ const HOOKS = [
1447
1450
  message: "string | null",
1448
1451
  signerName: "string | null",
1449
1452
  signedAt: "string | null",
1450
- verdict: "{ valid, checks, content_status, ... } | null",
1453
+ verdict: "{ valid, checks, content_status, signed_by_others_since, seal, ... } | null",
1451
1454
  loading: "boolean",
1452
1455
  error: "PermissionError | null // { code, message, retryable }",
1453
1456
  initiate: "() => Promise<{ signature_id, qr, auto_start_token, status }>",
@@ -2801,6 +2804,20 @@ const PRIMITIVES = [
2801
2804
  rnComponent: null,
2802
2805
  docsUrl: null,
2803
2806
  },
2807
+ {
2808
+ name: "Accordion",
2809
+ description:
2810
+ '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.',
2811
+ rnComponent: null,
2812
+ docsUrl: null,
2813
+ },
2814
+ {
2815
+ name: "AccordionItem",
2816
+ description:
2817
+ '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.',
2818
+ rnComponent: null,
2819
+ docsUrl: null,
2820
+ },
2804
2821
  {
2805
2822
  name: "Tabs",
2806
2823
  description:
@@ -3043,6 +3060,13 @@ const MANIFEST_SCHEMA = {
3043
3060
  "Optional (default false). TRUE when the widget draws a strip of live figures (counts, totals, averages) about the records it shows, or is itself a strip of figures. The AppStudio app-builder reads this when it places an already-installed widget: it counts the widget as the page's figures, so it does not plan a second strip of figures beside it. Set it to match what the component actually renders; leave it false (or omit) when the widget draws no figures.",
3044
3061
  default: false,
3045
3062
  },
3063
+ badgeCounter: {
3064
+ type: "boolean",
3065
+ required: false,
3066
+ description:
3067
+ "Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
3068
+ default: false,
3069
+ },
3046
3070
  events: {
3047
3071
  type: "object[]",
3048
3072
  required: true,
@@ -5301,7 +5325,17 @@ const CONTRACT = deepFreeze({
5301
5325
  // flat `assets/` directory (`bundledAssets`, within `packageMaxBytes`).
5302
5326
  // New `useBundledAsset(fileName)` resolves one through the new
5303
5327
  // `widget.assets` map; linter rule `bundled-asset-missing` (error).
5304
- version: "1.159.0",
5328
+ // 1.160.0: additive (sc-10829) — optional manifest field `badgeCounter`:
5329
+ // the entry exports `useBadgeCount(props)` (name in `badgeCounterExport`)
5330
+ // and the host runs it while the widget's page is closed, so the page
5331
+ // badge stays live; the host gives a counter a read-only context.
5332
+ // `usePageBadge(null)` now means "not known yet" and keeps the current
5333
+ // badge; `undefined` still clears it. Linter rules `badge-counter-export-missing`
5334
+ // (error) and `badge-counter-undeclared` (warning).
5335
+ // 1.161.0: additive (sc-10930) — `Accordion` / `AccordionItem`, themed
5336
+ // collapsible sections drawn from the `card` and `list` scopes: single
5337
+ // or multiple open, controlled or uncontrolled, `aria-expanded` headers.
5338
+ version: "1.161.0",
5305
5339
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5306
5340
  hooks: HOOKS,
5307
5341
  primitives: PRIMITIVES,
@@ -5327,6 +5361,8 @@ const CONTRACT = deepFreeze({
5327
5361
  themeWidgetStyles: THEME_WIDGET_STYLES,
5328
5362
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
5329
5363
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
5364
+ // sc-10829 — the hook a `badgeCounter: true` entry exports by name.
5365
+ badgeCounterExport: "useBadgeCount",
5330
5366
  bannedApis: BANNED_APIS,
5331
5367
  vettedImports: VETTED_IMPORTS,
5332
5368
  allowedBareImports: ALLOWED_BARE_IMPORTS,
package/dist/hooks.js CHANGED
@@ -621,8 +621,10 @@ export function useFilePreview(file) {
621
621
  return { uri };
622
622
  }
623
623
 
624
- // A count the host can draw: a whole number, 0 for nothing to show.
624
+ // A count the host can draw: a whole number, 0 for nothing to show, null
625
+ // only when the widget explicitly says it does not know yet.
625
626
  function _badgeCount(count) {
627
+ if (count === null) return null;
626
628
  return typeof count === "number" && Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
627
629
  }
628
630
 
@@ -631,8 +633,9 @@ function _badgeCount(count) {
631
633
  * on, such as a chat's unread messages. Returns `{ supported }`. The host sums
632
634
  * every widget on the page and keeps the last count after the widget leaves
633
635
  * the screen, until it reports again; 0 (or anything not a positive number)
634
- * clears this widget's share. A host without page badges (the Builder canvas)
635
- * ignores it.
636
+ * clears this widget's share. sc-10829: an explicit null means "not known
637
+ * yet" and keeps the current badge; undefined still clears. A host without
638
+ * page badges (the Builder canvas) ignores it.
636
639
  */
637
640
  export function usePageBadge(count) {
638
641
  const ctx = useWidgetContextOrThrow("usePageBadge");
@@ -640,7 +643,7 @@ export function usePageBadge(count) {
640
643
  const setBadge = typeof nav.setBadge === "function" ? nav.setBadge : null;
641
644
  const value = _badgeCount(count);
642
645
  useEffect(() => {
643
- if (setBadge) setBadge(value);
646
+ if (setBadge && value !== null) setBadge(value);
644
647
  }, [setBadge, value]);
645
648
  return { supported: Boolean(setBadge) };
646
649
  }
package/dist/index.d.ts CHANGED
@@ -397,8 +397,23 @@ export interface WidgetManifest {
397
397
  * /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, value ≤1 KB.
398
398
  */
399
399
  translations?: Record<string, { en: string } & Record<string, string>>;
400
+ /**
401
+ * Optional (sc-10829, default false). True when every platform entry also
402
+ * exports `useBadgeCount(props)` (a `BadgeCounterHook`), which the host runs
403
+ * while the widget's page is closed so its menu badge stays live.
404
+ */
405
+ badgeCounter?: boolean;
400
406
  }
401
407
 
408
+ /**
409
+ * sc-10829 — the `useBadgeCount` export of a `badgeCounter: true` widget. Called
410
+ * with the instance's resolved props inside the widget's own context; returns
411
+ * the page badge count, or `null` while it is not known yet. It only reads (the
412
+ * host gives it read-only clients and no device, payment or AI access): no
413
+ * rendering, writes, navigation, events, toasts or page-chrome hooks.
414
+ */
415
+ export type BadgeCounterHook = (props: Record<string, unknown>) => number | null;
416
+
402
417
  /** A React Native shadow style object — one level of `ThemeTokens.elevation`. */
403
418
  export interface ElevationLevel {
404
419
  shadowColor?: string;
@@ -1821,17 +1836,19 @@ export function useNavigation(): {
1821
1836
  */
1822
1837
  export function useRouteParams(): Record<string, unknown>;
1823
1838
 
1824
- /**
1825
- * sc-10268 — puts `count` on the menu entry or tab of the page this widget is
1826
- * on. The host sums the page's widgets and keeps the last count after the
1827
- * widget leaves the screen; 0 clears this widget's share.
1828
- */
1829
1839
  /**
1830
1840
  * sc-10268 — `{ uri }` an `<Image>` can show for a picked file before it is
1831
1841
  * uploaded. Null for no file, or a host that cannot preview one.
1832
1842
  */
1833
1843
  export function useFilePreview(file: unknown): { uri: string | null };
1834
1844
 
1845
+ /**
1846
+ * sc-10268 — puts `count` on the menu entry or tab of the page this widget is
1847
+ * on. The host sums the page's widgets and keeps the last count after the
1848
+ * widget leaves the screen; 0 or `undefined` clears this widget's share.
1849
+ * sc-10829: an explicit `null` means the count is not known yet and keeps the
1850
+ * current badge.
1851
+ */
1835
1852
  export function usePageBadge(count: number | null | undefined): {
1836
1853
  supported: boolean;
1837
1854
  };
@@ -3424,6 +3441,52 @@ export const Tabs: (props: {
3424
3441
  testID?: string;
3425
3442
  }) => any;
3426
3443
 
3444
+ /**
3445
+ * Themed collapsible sections. The surface comes from the theme's `card`
3446
+ * scope and the headers and dividers from its `list` scope, so every
3447
+ * collapsible section in the app matches. Each header announces whether its
3448
+ * section is open, and a section's body mounts only while it is open.
3449
+ *
3450
+ * @example
3451
+ * <Accordion defaultValue="shipping">
3452
+ * <AccordionItem value="shipping" title="Shipping" subtitle="2-4 days">…</AccordionItem>
3453
+ * <AccordionItem value="returns" title="Returns">…</AccordionItem>
3454
+ * </Accordion>
3455
+ */
3456
+ export const Accordion: (props: {
3457
+ /** Several sections may be open at once; by default opening one closes the other. */
3458
+ multiple?: boolean;
3459
+ /** The open section key (an array of keys when `multiple`); makes it controlled. */
3460
+ value?: string | string[] | null;
3461
+ /** The initially open section(s) when uncontrolled. */
3462
+ defaultValue?: string | string[] | null;
3463
+ /** The open section(s) after a header press, in the same shape as `value`. */
3464
+ onChange?: (next: string | string[] | null) => void;
3465
+ children?: ReactNode;
3466
+ /** Names the group of sections for assistive technology. */
3467
+ accessibilityLabel?: string;
3468
+ style?: any;
3469
+ testID?: string;
3470
+ }) => any;
3471
+
3472
+ /** One section of an `<Accordion>`; on its own it is an accordion of one. */
3473
+ export const AccordionItem: (props: {
3474
+ /** The key `value` / `defaultValue` / `onChange` name it by; give one whenever you name sections. */
3475
+ value?: string;
3476
+ title?: ReactNode;
3477
+ subtitle?: ReactNode;
3478
+ /** Beside the header and outside its toggle, so a button here does its own thing. */
3479
+ trailing?: ReactNode;
3480
+ disabled?: boolean;
3481
+ /** Rule under the section (default true); the surface edge hides the last one's. */
3482
+ divider?: boolean;
3483
+ children?: ReactNode;
3484
+ accessibilityLabel?: string;
3485
+ style?: any;
3486
+ /** Also tags the header (`<testID>-header`) and open body (`<testID>-panel`). */
3487
+ testID?: string;
3488
+ }) => any;
3489
+
3427
3490
  export type TextRole = "display" | "title" | "subtitle" | "body" | "caption" | "overline";
3428
3491
  export const TEXT_ROLES: readonly TextRole[];
3429
3492
  export const TEXT_TRANSFORMS: readonly string[];
@@ -3851,6 +3914,8 @@ export interface AiWidgetContract {
3851
3914
  >
3852
3915
  >;
3853
3916
  readonly bundleExportContract: ReadonlyArray<ContractBundleShape>;
3917
+ /** sc-10829 — the export name of a `badgeCounter: true` entry's hook. */
3918
+ readonly badgeCounterExport: "useBadgeCount";
3854
3919
  readonly bannedApis: ReadonlyArray<ContractBannedApi>;
3855
3920
  readonly allowedBareImports: ReadonlyArray<string>;
3856
3921
  /** Bare specifiers the web host provides by exact name; a widget bundle never inlines them. */
package/dist/index.js CHANGED
@@ -257,6 +257,8 @@ export {
257
257
  } from "./button-style.js";
258
258
  // The themed text-entry field; follows the theme's `muted` scope.
259
259
  export { TextField } from "./text-field.js";
260
+ // Themed collapsible sections, drawn from the `card` and `list` scopes.
261
+ export { Accordion, AccordionItem } from "./accordion.js";
260
262
  // A themed tab strip; follows the theme's `tabs` scope.
261
263
  export { Tabs } from "./tabs.js";
262
264
  // The semantic text tiers, shared with the built-in widgets.
@@ -250,6 +250,8 @@ export {
250
250
  } from "./button-style.js";
251
251
  // The themed text-entry field; follows the theme's `muted` scope.
252
252
  export { TextField } from "./text-field.native.js";
253
+ // Themed collapsible sections, drawn from the `card` and `list` scopes.
254
+ export { Accordion, AccordionItem } from "./accordion.native.js";
253
255
  // A themed tab strip; follows the theme's `tabs` scope.
254
256
  export { Tabs } from "./tabs.native.js";
255
257
  // The semantic text tiers, shared with the built-in widgets.
package/dist/linter.cjs CHANGED
@@ -1992,6 +1992,54 @@ function lintEditability(manifest, files) {
1992
1992
  return { ok: true, findings };
1993
1993
  }
1994
1994
 
1995
+ // sc-10829 — `manifest.badgeCounter` promises the host a hook it can run
1996
+ // while the page is closed; each shipped entry must export it by name.
1997
+ function _exportedNameLine(code, name) {
1998
+ const declared = new RegExp(
1999
+ `\\bexport\\s+(?:(?:async\\s+)?function\\b\\s*\\*?\\s*|(?:const|let|var)\\s+)${name}\\b`,
2000
+ );
2001
+ const hit = declared.exec(code);
2002
+ if (hit) return code.slice(0, hit.index).split("\n").length;
2003
+ for (const clause of code.matchAll(/\bexport\s*\{([^}]*)\}/g)) {
2004
+ const names = clause[1].split(",").map((part) => part.trim().split(/\s+as\s+/).pop().trim());
2005
+ if (names.includes(name)) return code.slice(0, clause.index).split("\n").length;
2006
+ }
2007
+ return 0;
2008
+ }
2009
+
2010
+ function _badgeCounterRules(source, manifest) {
2011
+ if (!manifest || typeof manifest !== "object") return [];
2012
+ const name = CONTRACT.badgeCounterExport;
2013
+ const line = _exportedNameLine(_stripNonCode(source), name);
2014
+ if (manifest.badgeCounter === true && line === 0) {
2015
+ return [
2016
+ {
2017
+ rule: "badge-counter-export-missing",
2018
+ severity: "error",
2019
+ label:
2020
+ `manifest.badgeCounter is true but this entry does not export ${name}(props). ` +
2021
+ "Export the hook from every platform entry, or drop badgeCounter.",
2022
+ line: 0,
2023
+ snippet: "",
2024
+ },
2025
+ ];
2026
+ }
2027
+ if (manifest.badgeCounter !== true && line > 0) {
2028
+ return [
2029
+ {
2030
+ rule: "badge-counter-undeclared",
2031
+ severity: "warning",
2032
+ label:
2033
+ `This entry exports ${name} but manifest.badgeCounter is not true, so the host never runs it. ` +
2034
+ "Declare badgeCounter: true, or remove the export.",
2035
+ line,
2036
+ snippet: source.split(/\r?\n/)[line - 1].trim().slice(0, 200),
2037
+ },
2038
+ ];
2039
+ }
2040
+ return [];
2041
+ }
2042
+
1995
2043
  function lintSource(source, options) {
1996
2044
  if (typeof source !== "string") {
1997
2045
  return {
@@ -2070,6 +2118,7 @@ function lintSource(source, options) {
2070
2118
  })),
2071
2119
  );
2072
2120
  findings.push(..._manifestActionRules(options && options.manifest));
2121
+ findings.push(..._badgeCounterRules(source, options && options.manifest));
2073
2122
  const hasErrors = findings.some((f) => f.severity !== "warning");
2074
2123
  return { ok: !hasErrors, findings };
2075
2124
  }
package/dist/linter.js CHANGED
@@ -2055,6 +2055,54 @@ export function lintEditability(manifest, files) {
2055
2055
  return { ok: true, findings };
2056
2056
  }
2057
2057
 
2058
+ // sc-10829 — `manifest.badgeCounter` promises the host a hook it can run
2059
+ // while the page is closed; each shipped entry must export it by name.
2060
+ function _exportedNameLine(code, name) {
2061
+ const declared = new RegExp(
2062
+ `\\bexport\\s+(?:(?:async\\s+)?function\\b\\s*\\*?\\s*|(?:const|let|var)\\s+)${name}\\b`,
2063
+ );
2064
+ const hit = declared.exec(code);
2065
+ if (hit) return code.slice(0, hit.index).split("\n").length;
2066
+ for (const clause of code.matchAll(/\bexport\s*\{([^}]*)\}/g)) {
2067
+ const names = clause[1].split(",").map((part) => part.trim().split(/\s+as\s+/).pop().trim());
2068
+ if (names.includes(name)) return code.slice(0, clause.index).split("\n").length;
2069
+ }
2070
+ return 0;
2071
+ }
2072
+
2073
+ function _badgeCounterRules(source, manifest) {
2074
+ if (!manifest || typeof manifest !== "object") return [];
2075
+ const name = CONTRACT.badgeCounterExport;
2076
+ const line = _exportedNameLine(_stripNonCode(source), name);
2077
+ if (manifest.badgeCounter === true && line === 0) {
2078
+ return [
2079
+ {
2080
+ rule: "badge-counter-export-missing",
2081
+ severity: "error",
2082
+ label:
2083
+ `manifest.badgeCounter is true but this entry does not export ${name}(props). ` +
2084
+ "Export the hook from every platform entry, or drop badgeCounter.",
2085
+ line: 0,
2086
+ snippet: "",
2087
+ },
2088
+ ];
2089
+ }
2090
+ if (manifest.badgeCounter !== true && line > 0) {
2091
+ return [
2092
+ {
2093
+ rule: "badge-counter-undeclared",
2094
+ severity: "warning",
2095
+ label:
2096
+ `This entry exports ${name} but manifest.badgeCounter is not true, so the host never runs it. ` +
2097
+ "Declare badgeCounter: true, or remove the export.",
2098
+ line,
2099
+ snippet: source.split(/\r?\n/)[line - 1].trim().slice(0, 200),
2100
+ },
2101
+ ];
2102
+ }
2103
+ return [];
2104
+ }
2105
+
2058
2106
  export function lintSource(source, options) {
2059
2107
  if (typeof source !== "string") {
2060
2108
  return {
@@ -2142,6 +2190,7 @@ export function lintSource(source, options) {
2142
2190
  );
2143
2191
  // REQ-WIDGET-ACTION — structural validation of manifest-declared actions.
2144
2192
  findings.push(..._manifestActionRules(options && options.manifest));
2193
+ findings.push(..._badgeCounterRules(source, options && options.manifest));
2145
2194
  const hasErrors = findings.some((f) => f.severity !== "warning");
2146
2195
  return { ok: !hasErrors, findings };
2147
2196
  }
package/dist/manifest.cjs CHANGED
@@ -325,6 +325,12 @@ function validateManifest(m) {
325
325
  validateManifestTranslations(manifest.translations, manifest.id, errors);
326
326
  }
327
327
 
328
+ pushIf(
329
+ errors,
330
+ manifest.badgeCounter === undefined || typeof manifest.badgeCounter === "boolean",
331
+ "manifest.badgeCounter must be a boolean when present",
332
+ );
333
+
328
334
  return errors.length === 0 ? { ok: true } : { ok: false, errors };
329
335
  }
330
336
 
package/dist/manifest.js CHANGED
@@ -371,6 +371,12 @@ function validateManifest(m) {
371
371
  validateManifestTranslations(manifest.translations, manifest.id, errors);
372
372
  }
373
373
 
374
+ pushIf(
375
+ errors,
376
+ manifest.badgeCounter === undefined || typeof manifest.badgeCounter === "boolean",
377
+ "manifest.badgeCounter must be a boolean when present",
378
+ );
379
+
374
380
  return errors.length === 0 ? { ok: true } : { ok: false, errors };
375
381
  }
376
382
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.200.0",
3
+ "version": "0.202.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -1065,7 +1065,10 @@ const HOOKS = [
1065
1065
  description:
1066
1066
  "Puts count on the menu entry or tab of the page this widget is on (a chat's unread messages, open tasks). " +
1067
1067
  "The host sums every widget on the page and keeps the last count after the widget leaves the screen, until it " +
1068
- "reports again; 0, or anything not a positive number, clears this widget's share. Returns { supported }: false " +
1068
+ "reports again; 0, undefined, or any other value that is not a positive number, clears this widget's share. " +
1069
+ "An explicit null means the count is not known yet and keeps the current badge (pass null until the first load " +
1070
+ "settles, so the badge never flashes to 0). manifest.badgeCounter keeps the badge live while the page is closed; " +
1071
+ "see that field for who may declare it. Returns { supported }: false " +
1069
1072
  "on a host without page badges (the Builder canvas), where it does nothing. Call it at render with the current " +
1070
1073
  "count, never in a loop. Backed by ctx.navigation.setBadge. Identical on web (Player) and the Expo export.",
1071
1074
  returnShape: {
@@ -1447,7 +1450,7 @@ const HOOKS = [
1447
1450
  message: "string | null",
1448
1451
  signerName: "string | null",
1449
1452
  signedAt: "string | null",
1450
- verdict: "{ valid, checks, content_status, ... } | null",
1453
+ verdict: "{ valid, checks, content_status, signed_by_others_since, seal, ... } | null",
1451
1454
  loading: "boolean",
1452
1455
  error: "PermissionError | null // { code, message, retryable }",
1453
1456
  initiate: "() => Promise<{ signature_id, qr, auto_start_token, status }>",
@@ -2801,6 +2804,20 @@ const PRIMITIVES = [
2801
2804
  rnComponent: null,
2802
2805
  docsUrl: null,
2803
2806
  },
2807
+ {
2808
+ name: "Accordion",
2809
+ description:
2810
+ '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.',
2811
+ rnComponent: null,
2812
+ docsUrl: null,
2813
+ },
2814
+ {
2815
+ name: "AccordionItem",
2816
+ description:
2817
+ '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.',
2818
+ rnComponent: null,
2819
+ docsUrl: null,
2820
+ },
2804
2821
  {
2805
2822
  name: "Tabs",
2806
2823
  description:
@@ -3043,6 +3060,13 @@ const MANIFEST_SCHEMA = {
3043
3060
  "Optional (default false). TRUE when the widget draws a strip of live figures (counts, totals, averages) about the records it shows, or is itself a strip of figures. The AppStudio app-builder reads this when it places an already-installed widget: it counts the widget as the page's figures, so it does not plan a second strip of figures beside it. Set it to match what the component actually renders; leave it false (or omit) when the widget draws no figures.",
3044
3061
  default: false,
3045
3062
  },
3063
+ badgeCounter: {
3064
+ type: "boolean",
3065
+ required: false,
3066
+ description:
3067
+ "Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
3068
+ default: false,
3069
+ },
3046
3070
  events: {
3047
3071
  type: "object[]",
3048
3072
  required: true,
@@ -5301,7 +5325,17 @@ const CONTRACT = deepFreeze({
5301
5325
  // flat `assets/` directory (`bundledAssets`, within `packageMaxBytes`).
5302
5326
  // New `useBundledAsset(fileName)` resolves one through the new
5303
5327
  // `widget.assets` map; linter rule `bundled-asset-missing` (error).
5304
- version: "1.159.0",
5328
+ // 1.160.0: additive (sc-10829) — optional manifest field `badgeCounter`:
5329
+ // the entry exports `useBadgeCount(props)` (name in `badgeCounterExport`)
5330
+ // and the host runs it while the widget's page is closed, so the page
5331
+ // badge stays live; the host gives a counter a read-only context.
5332
+ // `usePageBadge(null)` now means "not known yet" and keeps the current
5333
+ // badge; `undefined` still clears it. Linter rules `badge-counter-export-missing`
5334
+ // (error) and `badge-counter-undeclared` (warning).
5335
+ // 1.161.0: additive (sc-10930) — `Accordion` / `AccordionItem`, themed
5336
+ // collapsible sections drawn from the `card` and `list` scopes: single
5337
+ // or multiple open, controlled or uncontrolled, `aria-expanded` headers.
5338
+ version: "1.161.0",
5305
5339
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5306
5340
  hooks: HOOKS,
5307
5341
  primitives: PRIMITIVES,
@@ -5327,6 +5361,8 @@ const CONTRACT = deepFreeze({
5327
5361
  themeWidgetStyles: THEME_WIDGET_STYLES,
5328
5362
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
5329
5363
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
5364
+ // sc-10829 — the hook a `badgeCounter: true` entry exports by name.
5365
+ badgeCounterExport: "useBadgeCount",
5330
5366
  bannedApis: BANNED_APIS,
5331
5367
  vettedImports: VETTED_IMPORTS,
5332
5368
  allowedBareImports: ALLOWED_BARE_IMPORTS,