@colixsystems/widget-sdk 0.175.0 → 0.179.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
@@ -43,7 +43,8 @@ The data layer lives in **four separate domain-client packages**, each instantia
43
43
  | **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
44
44
  | **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
45
45
  | **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
46
- | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
46
+ | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, total, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching`; `total` is the envelope's `meta.total` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
47
+ | **DATASTORE** | `useDatastoreFigure(table, { aggregate, column, filter })` (optional: options) | `{ value, text, truncated, loading, fetching, error, refetch }` | `records(table).list` (a count reads the envelope total; a sum or average up to 1000 rows) — `datastore.read:*`. One count / sum / average for a `<Stat>`, computed exactly as the built-in Stat widget does. |
47
48
  | **DATASTORE** | `useDatastoreRecord(table, id, options)` (optional: options) | `{ data, loading, fetching, error, refetch }` — a refetch of the same record keeps `data` while `fetching`; `{ includeCapabilities: true }` adds `data._capabilities` | `records(table).get` — `datastore.read:<table>` |
48
49
  | **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
49
50
  | **DATASTORE** | `useBoundColumns(tableId, shape, props)` | `{ columns, resolved, missing, loading, error }` | `schema(tableId)` (built on `useDatastoreSchema`) — `datastore.read:<table>`. Resolves author-bound column NAMES from `props` by exact name → case-insensitive name → first unclaimed column matching `shape[key].dataType`, so a column an author renamed after install still resolves instead of `record[props.titleField]` reading `undefined`. `columns` holds the resolved NAME (`record[columns.titleField]`); `resolved` holds the full `Column`; `missing` lists non-`optional` keys that never resolved. Falsy `tableId` collapses to `{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null }`. |
@@ -73,7 +74,33 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
73
74
 
74
75
  ## Status
75
76
 
76
- `v0.175.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**.
77
+ `v0.179.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
78
+
79
+ ### What's new in 0.179.0 (contract 1.140.0)
80
+
81
+ **`<Card>` no longer discards the layout in its `style` (sc-9200).** `style` was read for the `card*` design fields alone, so `<Card style={{ width: "100%" }}>` sized nothing and every layout key vanished without a warning — a widget could not lay its cards out by its measured width, because the `widthTier()` branch set exactly that. A card that brings no `baseStyle` now also takes the box's **layout** from `style`: width, height, the flex properties, `alignSelf`, `gap` and position (`CARD_LAYOUT_STYLE_FIELDS`). The app's card look is kept:
82
+
83
+ ```jsx
84
+ <Card style={{ width: "100%", maxWidth: 480, alignSelf: "center" }}>…</Card>
85
+ ```
86
+
87
+ Layout only, deliberately: a widget is told to hand `<Card>` its whole declared style bag, so forwarding everything that is not a `card*` field would put design fields (`background`, `fontSize`, `titleColor`) on the box — and react-native-web renders an unknown key as real CSS where React Native drops it, which is one widget rendering two different cards. The paint stays the card's own vocabulary (`cardBackground`, `cardRadius`, `padding`, `shadow`); anything else about the container goes on `containerStyle`.
88
+
89
+ A card given a `baseStyle` is unchanged: that caller owns the container's layout, so its `style` is still read for the `card*` fields only — the contract the built-in widgets pass their per-instance style on.
90
+
91
+ ### What's new in 0.178.0 (contract 1.139.0)
92
+
93
+ **New optional manifest field `drawsFigures` (sc-9150).** A boolean (default `false`) that declares your widget draws a strip of live figures, such as "12 pending" or "€4,200 this month", about the records it shows, or is itself a strip of figures. Set it `true` when your widget draws them (for example with `useDatastoreFigure` and `<Stat>`). The AppStudio app-builder then counts the widget as the page's figures when it reuses it on a page, and does not plan a second strip of figures beside it. Leave it `false` or omit it when the widget draws no figures. Additive: existing manifests omit it and read `false`, so no widget needs changing.
94
+
95
+ ### What's new in 0.177.0 (contract 1.138.0)
96
+
97
+ **`useDatastoreFigure(tableId, { aggregate, column, filter })` — one live figure over a table, for a `<Stat>`.** A custom widget showing "12 pending" above its list had to reduce `useDatastoreQuery` rows by hand, and nothing told it to skip blank cells, apply a filter, or admit the 1000-row query ceiling. The built-in Stat widget's figure logic is now this hook, and the Stat widget computes through it. `aggregate` is `"count"` (the default), `"sum"` or `"average"` of the number `column`, and `filter` takes the same shapes as `useDatastoreQuery`. It returns `{ value, text, truncated, loading, fetching, error, refetch }`: pass `text` to `<Stat value>`. It is null while loading or when there is no figure, so the tile prints its empty value, never a fake 0. A count is exact: it reads the server's total and fetches a single row. A sum or average reads up to 1000 rows and, when the table holds more, reads "≈ n" with `truncated: true`. Filter on a bound column only once `useBoundColumns` has resolved (pass `null` as the table until then), or the first read is unfiltered.
98
+
99
+ **`useDatastoreQuery` returns `total`**: how many rows the query matches in all (the envelope's `meta.total`), not only the page in `data`.
100
+
101
+ ### What's new in 0.176.0 (contract 1.137.0)
102
+
103
+ **`manifest.description` is optional.** A widget publishes with a name and no description; when present, the description must still be a string. The storefront shows no blurb for a widget without one.
77
104
 
78
105
  ### What's new in 0.175.0 (contract 1.136.0)
79
106
 
@@ -156,7 +183,7 @@ Installed widgets pick up the row fix with no rebuild on the web: the Player han
156
183
  - **`<Badge>`** is a record's state (Active, Paid, Overdue) beside the thing it describes. `tone` is `neutral` | `info` | `success` | `warning` | `danger`, tinted from the app's theme; the label always shows.
157
184
  - **`<Avatar>`** is a person: a picture, else the name's initials, else a person glyph. `size` is `sm` | `md` | `lg` | `xl` or a number of px.
158
185
  - **`<Progress>`** is real progress toward a goal, as a `bar` or a `ring`; the percent is clamped to 0-100 and printed.
159
- - **`<Stat>`** displays a headline figure you computed, with an optional caption, icon and delta. An empty value prints `emptyValue` (default "—"), never a fake 0.
186
+ - **`<Stat>`** displays a headline figure (compute a live one with `useDatastoreFigure`), with an optional caption, icon and delta. An empty value prints `emptyValue` (default "—"), never a fake 0.
160
187
  - **`<Divider>`** splits two groups inside one surface, optionally captioned. A `<List>` and a `<Card>` already draw their own edges.
161
188
 
162
189
  `initialsFor(name)` is exported too. Additive: the built-in widgets render as before.
@@ -62,6 +62,50 @@ export function cardOverlay(style) {
62
62
  return { ...cardSurfaceStyle(style), ...marginStyle(style || {}) };
63
63
  }
64
64
 
65
+ /**
66
+ * The React Native layout properties a card may be given through `style` —
67
+ * how big the box is and where it sits, nothing about how it is painted.
68
+ *
69
+ * An ALLOWLIST, because a widget is told to hand `<Card>` its whole declared
70
+ * style bag: forwarding whatever is not a `card*` field would put design
71
+ * fields (`background`, `fontSize`, `titleColor`) on the box, and
72
+ * react-native-web renders an unknown key as real CSS where native drops it —
73
+ * two hosts, one widget, different cards (CLAUDE.md §8). Paint stays the
74
+ * card's own vocabulary (`cardBackground`, `cardRadius`, `padding`, `shadow`);
75
+ * anything else on the container goes through `containerStyle`.
76
+ */
77
+ export const CARD_LAYOUT_STYLE_FIELDS = Object.freeze([
78
+ "width", "minWidth", "maxWidth",
79
+ "height", "minHeight", "maxHeight",
80
+ "aspectRatio",
81
+ "flex", "flexBasis", "flexGrow", "flexShrink", "alignSelf",
82
+ "flexDirection", "flexWrap", "justifyContent", "alignItems", "alignContent",
83
+ "gap", "rowGap", "columnGap",
84
+ "position", "top", "right", "bottom", "left", "start", "end", "zIndex",
85
+ "display", "overflow",
86
+ ]);
87
+
88
+ /**
89
+ * The layout a card's `style` declares. Returning it is what keeps
90
+ * `<Card style={{ width: "100%" }}>` from being silently dropped; a card
91
+ * declaring none yields null, so it emits no extra style object.
92
+ *
93
+ * An `undefined` value is skipped rather than copied: react-native-web's
94
+ * styleq ignores it while React Native's flattenStyle assigns it, so copying
95
+ * one would clear the theme's value on native alone.
96
+ */
97
+ export function cardLayoutStyle(style) {
98
+ if (!style || typeof style !== "object" || Array.isArray(style)) return null;
99
+ const out = {};
100
+ let any = false;
101
+ for (const key of CARD_LAYOUT_STYLE_FIELDS) {
102
+ if (style[key] === undefined) continue;
103
+ out[key] = style[key];
104
+ any = true;
105
+ }
106
+ return any ? out : null;
107
+ }
108
+
65
109
  /**
66
110
  * A gradient value as `<Gradient>` props, or null when unpaintable. A
67
111
  * per-instance value runs through the same normaliser the theme uses, so the
package/dist/card-view.js CHANGED
@@ -9,7 +9,12 @@ import React from "react";
9
9
  import { useHostTheme } from "./hooks.js";
10
10
  import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
11
11
  import { CONTRACT } from "./contract.js";
12
- import { cardOverlay, cardSurfaceStyle, cardGradientProps } from "./card-style.js";
12
+ import {
13
+ cardOverlay,
14
+ cardSurfaceStyle,
15
+ cardGradientProps,
16
+ cardLayoutStyle,
17
+ } from "./card-style.js";
13
18
  import { normaliseThemeComponents } from "./theme-components.js";
14
19
 
15
20
  // A wrapped scroller stays transparent and fits inside the gradient.
@@ -75,9 +80,16 @@ export function makeCard(rn) {
75
80
 
76
81
  /**
77
82
  * @param {object} [props.style] — `card*` fields, `shadow`, `margin`
78
- * (a widget's per-instance style, theme tokens already folded in).
83
+ * (a widget's per-instance style, theme tokens already folded in). On a
84
+ * card with no `baseStyle` it also carries the box's LAYOUT
85
+ * (`CARD_LAYOUT_STYLE_FIELDS`), so `style={{ width: "100%" }}` sizes the
86
+ * card rather than vanishing. Under `as` + a gradient the layout rides the
87
+ * gradient — the card's real box — so the content keys there
88
+ * (`flexDirection`, `gap`, …) arrange the wrapper, not the inner children.
79
89
  * @param {object|Array} [props.baseStyle] — the caller's own base style. A
80
- * card given none starts from the theme's surface and `card` scope.
90
+ * card given none starts from the theme's surface and `card` scope. A
91
+ * caller that supplies one owns the container's layout, so its `style` is
92
+ * read for `card*` fields only — that is the built-in widgets' contract.
81
93
  * @param {React.ElementType} [props.as] — container; anything but `View`
82
94
  * renders INSIDE the gradient, never as it. Other props land on it.
83
95
  * @param {boolean} [props.fillInGradient] — the inner container stretches to
@@ -117,6 +129,8 @@ export function makeCard(rn) {
117
129
  ...explicit,
118
130
  };
119
131
  const base = standalone ? themedBase(theme) : baseStyle;
132
+ // Last, so an author's own declaration beats the theme's surface.
133
+ const layout = standalone ? cardLayoutStyle(style) : null;
120
134
 
121
135
  const overlay = withoutMargin ? cardSurfaceStyle(fields) : cardOverlay(fields);
122
136
  const gradient = cardGradientProps(fields);
@@ -124,7 +138,7 @@ export function makeCard(rn) {
124
138
  if (!gradient) {
125
139
  return React.createElement(
126
140
  Container,
127
- { style: composeStyle([base, overlay], containerStyle), ...rest },
141
+ { style: composeStyle([base, overlay, layout], containerStyle), ...rest },
128
142
  children,
129
143
  );
130
144
  }
@@ -139,7 +153,7 @@ export function makeCard(rn) {
139
153
  {
140
154
  colors: gradient.colors,
141
155
  angle: gradient.angle,
142
- style: composeStyle([base, paint], containerStyle),
156
+ style: composeStyle([base, paint, layout], containerStyle),
143
157
  ...rest,
144
158
  },
145
159
  children,
@@ -153,7 +167,8 @@ export function makeCard(rn) {
153
167
  {
154
168
  colors: gradient.colors,
155
169
  angle: gradient.angle,
156
- style: [base, paint, CLIP_TO_RADIUS],
170
+ // Before the clip: it keeps the gradient inside the corners, not a default.
171
+ style: [base, paint, layout, CLIP_TO_RADIUS],
157
172
  },
158
173
  React.createElement(
159
174
  Container,
package/dist/contract.cjs CHANGED
@@ -1395,9 +1395,43 @@ const HOOKS = [
1395
1395
  "`_capabilities` { can_read, can_write, can_delete, can_grant } — what " +
1396
1396
  "the signed-in user may do to THAT row, the answer update/delete " +
1397
1397
  "enforce, computed for the whole page in one lookup. Gate a row's " +
1398
- "Edit on `row._capabilities?.can_write` and Delete on `can_delete`.",
1398
+ "Edit on `row._capabilities?.can_write` and Delete on `can_delete`. " +
1399
+ "`total` is how many rows the query matches in all (the envelope's " +
1400
+ "meta.total), not just the page in `data`; null until known.",
1399
1401
  returnShape: {
1400
1402
  data: "Record[]",
1403
+ total: "number | null",
1404
+ loading: "boolean",
1405
+ fetching: "boolean",
1406
+ error: "DatastoreError | null",
1407
+ refetch: "() => Promise<void>",
1408
+ },
1409
+ requiredContextSlice: ["datastore.records"],
1410
+ scopes: ["datastore.read:*"],
1411
+ },
1412
+ {
1413
+ name: "useDatastoreFigure",
1414
+ signature: "useDatastoreFigure(tableId, { aggregate, column, filter })",
1415
+ optionalArgs: ["aggregate", "column", "filter"],
1416
+ description:
1417
+ "ONE live figure over a table, for a `<Stat>`: `aggregate` is " +
1418
+ "\"count\" (the default; the rows, or the rows `filter` matches), \"sum\" " +
1419
+ "or \"average\" (of the NUMBER `column`). `filter` takes the same shapes " +
1420
+ "as useDatastoreQuery's; name a bound column through useBoundColumns and " +
1421
+ "pass null as the table until its `loading` is false, or the first read " +
1422
+ "runs unfiltered: useDatastoreFigure(columnsLoading ? null : tableId, " +
1423
+ "{ filter: [{ column: bound.statusField, operator: \"eq\", value: \"Pending\" }] }). " +
1424
+ "Render `<Stat value={figure.text} label=\"Pending\" />`: `text` is null " +
1425
+ "while loading or when there is no figure, so the tile prints its empty " +
1426
+ "value, never a fake 0. A count is exact, from the server's total. A sum " +
1427
+ "or average reads up to 1000 rows, skips blank cells rather than counting " +
1428
+ "them as zero, and past that reads \"≈ n\" (`truncated: true`). The stock " +
1429
+ "Stat widget computes its figure through this hook, so use it rather than " +
1430
+ "reducing useDatastoreQuery rows by hand. Call it once per figure.",
1431
+ returnShape: {
1432
+ value: "number | null",
1433
+ text: "string | null // the formatted figure for <Stat value>",
1434
+ truncated: "boolean",
1401
1435
  loading: "boolean",
1402
1436
  fetching: "boolean",
1403
1437
  error: "DatastoreError | null",
@@ -2418,7 +2452,7 @@ const PRIMITIVES = [
2418
2452
  {
2419
2453
  name: "Stat",
2420
2454
  description:
2421
- 'Themed headline figure. `<Stat value={openCount} label="Open orders" delta="+3 this week" deltaTone="positive" icon="package" />`. THE way to show one headline number in a summary tile; put peer tiles side by side in a row. It only DISPLAYS a figure, so compute it yourself (e.g. from `useDatastoreQuery` rows). An empty value (null, undefined, blank text, NaN) prints `emptyValue` (default "—") in the muted tier, never a fake 0. Props: `value` (string or number), `label`, `icon` (lucide name), `delta`, `deltaTone` ("neutral" | "positive" | "negative"), `emptyValue`, `color` and `fontSize` (12-96) for the figure, `style`, `testID`.',
2455
+ 'Themed headline figure. `<Stat value={openCount} label="Open orders" delta="+3 this week" deltaTone="positive" icon="package" />`. THE way to show one headline number in a summary tile; put peer tiles side by side in a row. It only DISPLAYS a figure: compute a count, sum or average over a table with `useDatastoreFigure` and pass its `text`. An empty value (null, undefined, blank text, NaN) prints `emptyValue` (default "—") in the muted tier, never a fake 0. Props: `value` (string or number), `label`, `icon` (lucide name), `delta`, `deltaTone` ("neutral" | "positive" | "negative"), `emptyValue`, `color` and `fontSize` (12-96) for the figure, `style`, `testID`.',
2422
2456
  rnComponent: null,
2423
2457
  docsUrl: null,
2424
2458
  },
@@ -2439,7 +2473,7 @@ const PRIMITIVES = [
2439
2473
  {
2440
2474
  name: "Card",
2441
2475
  description:
2442
- 'Themed card surface. `<Card>…</Card>` — THE way to draw a card, panel or tile: with no props it takes the app\'s card look (Theme Settings > Cards: background, border, corner radius, padding, shadow, gradient) so it matches every built-in card. Override one card with `background`, `borderColor`, `radius`, `padding` (a number or `{ top, right, bottom, left }`), `shadow` ("none" | "sm" | "md" | "lg") or `gradient` (`{ from, to, angle }`), or pass `style={props.style}` when your widget declares the `cardBackground` / `cardBorderColor` / `cardRadius` / `cardPadding` / `cardGradient` / `shadow` / `margin` style fields, so an author can restyle it. A gradient paints on both hosts. `as` renders the card as another container (e.g. `Pressable`, `ScrollView`) and every other prop lands on it. Never hand-roll a card from `View` + border + radius.',
2476
+ 'Themed card surface. `<Card>…</Card>` — THE way to draw a card, panel or tile: with no props it takes the app\'s card look (Theme Settings > Cards: background, border, corner radius, padding, shadow, gradient) so it matches every built-in card. Override one card with `background`, `borderColor`, `radius`, `padding` (a number or `{ top, right, bottom, left }`), `shadow` ("none" | "sm" | "md" | "lg") or `gradient` (`{ from, to, angle }`), or pass `style={props.style}` when your widget declares the `cardBackground` / `cardBorderColor` / `cardRadius` / `cardPadding` / `cardGradient` / `shadow` / `margin` style fields, so an author can restyle it. A card that brings no `baseStyle` also takes the box\'s LAYOUT from `style` — width, height, the flex properties, `alignSelf`, `gap`, position — so `style={{ width: \'100%\', maxWidth: 480, alignSelf: \'center\' }}` sizes the card and still keeps the app\'s card look; put anything else about the container on `containerStyle`. A gradient paints on both hosts. `as` renders the card as another container (e.g. `Pressable`, `ScrollView`) and every other prop lands on it. Never hand-roll a card from `View` + border + radius.',
2443
2477
  rnComponent: null,
2444
2478
  docsUrl: null,
2445
2479
  },
@@ -2598,8 +2632,8 @@ const MANIFEST_SCHEMA = {
2598
2632
  },
2599
2633
  description: {
2600
2634
  type: "string",
2601
- required: true,
2602
- description: "1-2 sentence storefront description.",
2635
+ required: false,
2636
+ description: "Optional. 1-2 sentence storefront description.",
2603
2637
  },
2604
2638
  author: {
2605
2639
  type: "object",
@@ -2655,6 +2689,13 @@ const MANIFEST_SCHEMA = {
2655
2689
  "Optional (default false). TRUE when the widget renders its OWN section header — a heading (and optional subtitle), plus any primary action for its section — making it a self-contained section. The AppStudio app-builder reads this when it places an already-installed widget: it lays the widget out as the WHOLE section and does NOT add a standalone heading or a duplicate action button above it, so the section is never double-titled. Set it to match what the component actually renders; leave it false (or omit) for a content-only widget whose heading the page supplies.",
2656
2690
  default: false,
2657
2691
  },
2692
+ drawsFigures: {
2693
+ type: "boolean",
2694
+ required: false,
2695
+ description:
2696
+ "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.",
2697
+ default: false,
2698
+ },
2658
2699
  events: {
2659
2700
  type: "object[]",
2660
2701
  required: true,
@@ -4716,7 +4757,14 @@ const CONTRACT = deepFreeze({
4716
4757
  // without saving it, through the new `device.pdfViewer` host slice. And
4717
4758
  // `createObjectURL` joins `bannedApis`: widgets reached for it to fake a
4718
4759
  // "print version" as a plain-text blob that never worked on native.
4719
- version: "1.136.0",
4760
+ // 1.137.0: additive (sc-9142) — `manifest.description` is optional.
4761
+ // 1.138.0: additive (sc-9150) — `useDatastoreFigure`, and
4762
+ // `useDatastoreQuery` returns the envelope's `total`.
4763
+ // 1.139.0: additive (sc-9150) — new OPTIONAL manifest field `drawsFigures`
4764
+ // (boolean, default false): the widget draws live figures about its
4765
+ // records, so the app-builder counts it as the page's figures when it
4766
+ // reuses it. Existing manifests omit it and read false.
4767
+ version: "1.140.0",
4720
4768
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4721
4769
  hooks: HOOKS,
4722
4770
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1395,9 +1395,43 @@ const HOOKS = [
1395
1395
  "`_capabilities` { can_read, can_write, can_delete, can_grant } — what " +
1396
1396
  "the signed-in user may do to THAT row, the answer update/delete " +
1397
1397
  "enforce, computed for the whole page in one lookup. Gate a row's " +
1398
- "Edit on `row._capabilities?.can_write` and Delete on `can_delete`.",
1398
+ "Edit on `row._capabilities?.can_write` and Delete on `can_delete`. " +
1399
+ "`total` is how many rows the query matches in all (the envelope's " +
1400
+ "meta.total), not just the page in `data`; null until known.",
1399
1401
  returnShape: {
1400
1402
  data: "Record[]",
1403
+ total: "number | null",
1404
+ loading: "boolean",
1405
+ fetching: "boolean",
1406
+ error: "DatastoreError | null",
1407
+ refetch: "() => Promise<void>",
1408
+ },
1409
+ requiredContextSlice: ["datastore.records"],
1410
+ scopes: ["datastore.read:*"],
1411
+ },
1412
+ {
1413
+ name: "useDatastoreFigure",
1414
+ signature: "useDatastoreFigure(tableId, { aggregate, column, filter })",
1415
+ optionalArgs: ["aggregate", "column", "filter"],
1416
+ description:
1417
+ "ONE live figure over a table, for a `<Stat>`: `aggregate` is " +
1418
+ "\"count\" (the default; the rows, or the rows `filter` matches), \"sum\" " +
1419
+ "or \"average\" (of the NUMBER `column`). `filter` takes the same shapes " +
1420
+ "as useDatastoreQuery's; name a bound column through useBoundColumns and " +
1421
+ "pass null as the table until its `loading` is false, or the first read " +
1422
+ "runs unfiltered: useDatastoreFigure(columnsLoading ? null : tableId, " +
1423
+ "{ filter: [{ column: bound.statusField, operator: \"eq\", value: \"Pending\" }] }). " +
1424
+ "Render `<Stat value={figure.text} label=\"Pending\" />`: `text` is null " +
1425
+ "while loading or when there is no figure, so the tile prints its empty " +
1426
+ "value, never a fake 0. A count is exact, from the server's total. A sum " +
1427
+ "or average reads up to 1000 rows, skips blank cells rather than counting " +
1428
+ "them as zero, and past that reads \"≈ n\" (`truncated: true`). The stock " +
1429
+ "Stat widget computes its figure through this hook, so use it rather than " +
1430
+ "reducing useDatastoreQuery rows by hand. Call it once per figure.",
1431
+ returnShape: {
1432
+ value: "number | null",
1433
+ text: "string | null // the formatted figure for <Stat value>",
1434
+ truncated: "boolean",
1401
1435
  loading: "boolean",
1402
1436
  fetching: "boolean",
1403
1437
  error: "DatastoreError | null",
@@ -2418,7 +2452,7 @@ const PRIMITIVES = [
2418
2452
  {
2419
2453
  name: "Stat",
2420
2454
  description:
2421
- 'Themed headline figure. `<Stat value={openCount} label="Open orders" delta="+3 this week" deltaTone="positive" icon="package" />`. THE way to show one headline number in a summary tile; put peer tiles side by side in a row. It only DISPLAYS a figure, so compute it yourself (e.g. from `useDatastoreQuery` rows). An empty value (null, undefined, blank text, NaN) prints `emptyValue` (default "—") in the muted tier, never a fake 0. Props: `value` (string or number), `label`, `icon` (lucide name), `delta`, `deltaTone` ("neutral" | "positive" | "negative"), `emptyValue`, `color` and `fontSize` (12-96) for the figure, `style`, `testID`.',
2455
+ 'Themed headline figure. `<Stat value={openCount} label="Open orders" delta="+3 this week" deltaTone="positive" icon="package" />`. THE way to show one headline number in a summary tile; put peer tiles side by side in a row. It only DISPLAYS a figure: compute a count, sum or average over a table with `useDatastoreFigure` and pass its `text`. An empty value (null, undefined, blank text, NaN) prints `emptyValue` (default "—") in the muted tier, never a fake 0. Props: `value` (string or number), `label`, `icon` (lucide name), `delta`, `deltaTone` ("neutral" | "positive" | "negative"), `emptyValue`, `color` and `fontSize` (12-96) for the figure, `style`, `testID`.',
2422
2456
  rnComponent: null,
2423
2457
  docsUrl: null,
2424
2458
  },
@@ -2439,7 +2473,7 @@ const PRIMITIVES = [
2439
2473
  {
2440
2474
  name: "Card",
2441
2475
  description:
2442
- 'Themed card surface. `<Card>…</Card>` — THE way to draw a card, panel or tile: with no props it takes the app\'s card look (Theme Settings > Cards: background, border, corner radius, padding, shadow, gradient) so it matches every built-in card. Override one card with `background`, `borderColor`, `radius`, `padding` (a number or `{ top, right, bottom, left }`), `shadow` ("none" | "sm" | "md" | "lg") or `gradient` (`{ from, to, angle }`), or pass `style={props.style}` when your widget declares the `cardBackground` / `cardBorderColor` / `cardRadius` / `cardPadding` / `cardGradient` / `shadow` / `margin` style fields, so an author can restyle it. A gradient paints on both hosts. `as` renders the card as another container (e.g. `Pressable`, `ScrollView`) and every other prop lands on it. Never hand-roll a card from `View` + border + radius.',
2476
+ 'Themed card surface. `<Card>…</Card>` — THE way to draw a card, panel or tile: with no props it takes the app\'s card look (Theme Settings > Cards: background, border, corner radius, padding, shadow, gradient) so it matches every built-in card. Override one card with `background`, `borderColor`, `radius`, `padding` (a number or `{ top, right, bottom, left }`), `shadow` ("none" | "sm" | "md" | "lg") or `gradient` (`{ from, to, angle }`), or pass `style={props.style}` when your widget declares the `cardBackground` / `cardBorderColor` / `cardRadius` / `cardPadding` / `cardGradient` / `shadow` / `margin` style fields, so an author can restyle it. A card that brings no `baseStyle` also takes the box\'s LAYOUT from `style` — width, height, the flex properties, `alignSelf`, `gap`, position — so `style={{ width: \'100%\', maxWidth: 480, alignSelf: \'center\' }}` sizes the card and still keeps the app\'s card look; put anything else about the container on `containerStyle`. A gradient paints on both hosts. `as` renders the card as another container (e.g. `Pressable`, `ScrollView`) and every other prop lands on it. Never hand-roll a card from `View` + border + radius.',
2443
2477
  rnComponent: null,
2444
2478
  docsUrl: null,
2445
2479
  },
@@ -2594,8 +2628,8 @@ const MANIFEST_SCHEMA = {
2594
2628
  },
2595
2629
  description: {
2596
2630
  type: "string",
2597
- required: true,
2598
- description: "1-2 sentence storefront description.",
2631
+ required: false,
2632
+ description: "Optional. 1-2 sentence storefront description.",
2599
2633
  },
2600
2634
  author: {
2601
2635
  type: "object",
@@ -2651,6 +2685,13 @@ const MANIFEST_SCHEMA = {
2651
2685
  "Optional (default false). TRUE when the widget renders its OWN section header — a heading (and optional subtitle), plus any primary action for its section — making it a self-contained section. The AppStudio app-builder reads this when it places an already-installed widget: it lays the widget out as the WHOLE section and does NOT add a standalone heading or a duplicate action button above it, so the section is never double-titled. Set it to match what the component actually renders; leave it false (or omit) for a content-only widget whose heading the page supplies.",
2652
2686
  default: false,
2653
2687
  },
2688
+ drawsFigures: {
2689
+ type: "boolean",
2690
+ required: false,
2691
+ description:
2692
+ "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.",
2693
+ default: false,
2694
+ },
2654
2695
  events: {
2655
2696
  type: "object[]",
2656
2697
  required: true,
@@ -4712,7 +4753,14 @@ const CONTRACT = deepFreeze({
4712
4753
  // without saving it, through the new `device.pdfViewer` host slice. And
4713
4754
  // `createObjectURL` joins `bannedApis`: widgets reached for it to fake a
4714
4755
  // "print version" as a plain-text blob that never worked on native.
4715
- version: "1.136.0",
4756
+ // 1.137.0: additive (sc-9142) — `manifest.description` is optional.
4757
+ // 1.138.0: additive (sc-9150) — `useDatastoreFigure`, and
4758
+ // `useDatastoreQuery` returns the envelope's `total`.
4759
+ // 1.139.0: additive (sc-9150) — new OPTIONAL manifest field `drawsFigures`
4760
+ // (boolean, default false): the widget draws live figures about its
4761
+ // records, so the app-builder counts it as the page's figures when it
4762
+ // reuses it. Existing manifests omit it and read false.
4763
+ version: "1.140.0",
4716
4764
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4717
4765
  hooks: HOOKS,
4718
4766
  primitives: PRIMITIVES,
@@ -0,0 +1,27 @@
1
+ // The hook half of ./figure.js: one live figure over a table, shared by the
2
+ // stock Stat widget and custom widgets on both hosts.
3
+ import { useDatastoreQuery } from "./hooks.js";
4
+ import { figureQuery, reduceFigure } from "./figure.js";
5
+
6
+ /**
7
+ * sc-9150 — one live count, sum or average over a table; see the
8
+ * useDatastoreFigure CONTRACT entry. Built on useDatastoreQuery, so a changed
9
+ * `filter` keeps the previous figure on screen while `fetching`.
10
+ */
11
+ export function useDatastoreFigure(table, options) {
12
+ const { aggregate, column, filter } = options || {};
13
+ const rows = useDatastoreQuery(table || null, figureQuery(filter, aggregate));
14
+ const figure =
15
+ table && !rows.loading && !rows.error
16
+ ? reduceFigure(rows.data, { aggregate, column }, rows.total)
17
+ : null;
18
+ return {
19
+ value: figure ? figure.value : null,
20
+ text: figure ? figure.text : null,
21
+ truncated: figure ? figure.truncated : false,
22
+ loading: rows.loading,
23
+ fetching: rows.fetching,
24
+ error: rows.error,
25
+ refetch: rows.refetch,
26
+ };
27
+ }
package/dist/figure.js ADDED
@@ -0,0 +1,59 @@
1
+ // The pure rules behind `useDatastoreFigure`: which rows a figure reads and how
2
+ // they reduce to the one number a `<Stat>` shows. Plain JS, no React, so the
3
+ // stock Stat and every custom widget count the same way on both hosts.
4
+
5
+ export const FIGURE_AGGREGATES = Object.freeze(["count", "sum", "average"]);
6
+
7
+ // REQ-QUERY-02 clamps every datastore query to 1000 rows, so a sum or average
8
+ // over a bigger table is reduced from a truncated page and must say so.
9
+ export const FIGURE_ROW_CEILING = 1000;
10
+
11
+ /** The aggregate to run: an unknown or missing one counts rows. */
12
+ export function figureAggregate(aggregate) {
13
+ return FIGURE_AGGREGATES.includes(aggregate) ? aggregate : "count";
14
+ }
15
+
16
+ function hasFilter(filter) {
17
+ return Array.isArray(filter)
18
+ ? filter.length > 0
19
+ : Boolean(filter) && typeof filter === "object" && Object.keys(filter).length > 0;
20
+ }
21
+
22
+ /**
23
+ * The rows query a figure reads. A count needs only the envelope's exact
24
+ * `meta.total`, so it fetches one row; a sum or average reads up to the ceiling.
25
+ */
26
+ export function figureQuery(filter, aggregate) {
27
+ const limit = figureAggregate(aggregate) === "count" ? 1 : FIGURE_ROW_CEILING;
28
+ return hasFilter(filter) ? { limit, filter } : { limit };
29
+ }
30
+
31
+ // Whole numbers stay whole; an average keeps two decimals at most.
32
+ function formatNumber(n) {
33
+ return Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100);
34
+ }
35
+
36
+ /**
37
+ * Reduce a figure query's result to `{ value, text, truncated }`, or null when
38
+ * there is nothing honest to show. `total` is how many rows the query matched;
39
+ * a sum or average over fewer rows than that is prefixed "≈ ".
40
+ */
41
+ export function reduceFigure(rows, { aggregate, column } = {}, total = null) {
42
+ if (!Array.isArray(rows)) return null;
43
+ const known = Number.isFinite(total);
44
+ if (figureAggregate(aggregate) === "count") {
45
+ return known ? { value: total, text: String(total), truncated: false } : null;
46
+ }
47
+ if (!column) return null;
48
+ // Number(null) and Number("") are 0: coercing would count a blank or
49
+ // permission-hidden cell as a real zero. A NUMBER cell arrives as a number.
50
+ const numbers = rows
51
+ .map((row) => (row ? row[column] : undefined))
52
+ .filter((cell) => typeof cell === "number" && Number.isFinite(cell));
53
+ if (numbers.length === 0) return null;
54
+ const sum = numbers.reduce((acc, n) => acc + n, 0);
55
+ const value = aggregate === "average" ? sum / numbers.length : sum;
56
+ if (!Number.isFinite(value)) return null;
57
+ const truncated = known ? total > rows.length : rows.length >= FIGURE_ROW_CEILING;
58
+ return { value, text: `${truncated ? "≈ " : ""}${formatNumber(value)}`, truncated };
59
+ }
package/dist/hooks.js CHANGED
@@ -2173,7 +2173,7 @@ function toDatastoreError(err) {
2173
2173
  // on a genuine loop.
2174
2174
  const VOLATILE_QUERY_RENDER_LIMIT = 10;
2175
2175
 
2176
- const NO_QUERY_RESULT = { key: null, table: null, data: null, error: null, loaded: false };
2176
+ const NO_QUERY_RESULT = { key: null, table: null, data: null, total: null, error: null, loaded: false };
2177
2177
  const NO_RECORD_RESULT = { key: null, data: null, error: null, loaded: false };
2178
2178
 
2179
2179
  function serializeQuery(query) {
@@ -2189,8 +2189,8 @@ const recordResultKey = (table, recordId, includeCapabilities = false) =>
2189
2189
  JSON.stringify([table, recordId, includeCapabilities]);
2190
2190
 
2191
2191
  /**
2192
- * Stateful datastore query hook. Returns { data, loading, fetching, error,
2193
- * refetch }.
2192
+ * Stateful datastore query hook. Returns { data, total, loading, fetching,
2193
+ * error, refetch }.
2194
2194
  *
2195
2195
  * The host injects a `@colixsystems/datastore-client` instance at
2196
2196
  * `ctx.datastore`. We call `ctx.datastore.records(table).list(query)`, which
@@ -2207,6 +2207,9 @@ const recordResultKey = (table, recordId, includeCapabilities = false) =>
2207
2207
  * `_capabilities` ({ can_read, can_write, can_delete, can_grant }), computed
2208
2208
  * for the whole page in one server lookup. Gate a row's Edit / Delete on it.
2209
2209
  *
2210
+ * sc-9150 — `total` is the envelope's `meta.total`: how many rows the query
2211
+ * matches, beyond the page in `data`. null until known.
2212
+ *
2210
2213
  * sc-8595 — stale-while-revalidate. `loading` is true only until the first
2211
2214
  * result for the bound TABLE lands. A changed query on that table, `refetch()`
2212
2215
  * and a page pull-to-refresh keep the rows already in `data` on screen with
@@ -2274,9 +2277,10 @@ export function useDatastoreQuery(table, query) {
2274
2277
  // The datastore-client returns the { data, meta } list envelope
2275
2278
  // verbatim — unwrap to the rows array (default []).
2276
2279
  const rows = res && Array.isArray(res.data) ? res.data : [];
2280
+ const total = res && res.meta && Number.isFinite(res.meta.total) ? res.meta.total : null;
2277
2281
  // Discard the result if a newer fetch has started since we kicked off.
2278
2282
  if (runRef.current !== myRun) return;
2279
- setSettled({ key, table: t, data: rows, error: null, loaded: true });
2283
+ setSettled({ key, table: t, data: rows, total, error: null, loaded: true });
2280
2284
  setInFlight(false);
2281
2285
  } catch (err) {
2282
2286
  if (runRef.current !== myRun) return;
@@ -2285,7 +2289,7 @@ export function useDatastoreQuery(table, query) {
2285
2289
  setSettled((prev) =>
2286
2290
  prev.table === t && prev.loaded
2287
2291
  ? { ...prev, key, error }
2288
- : { key, table: t, data: emptyRows, error, loaded: false },
2292
+ : { key, table: t, data: emptyRows, total: null, error, loaded: false },
2289
2293
  );
2290
2294
  setInFlight(false);
2291
2295
  }
@@ -2340,6 +2344,7 @@ export function useDatastoreQuery(table, query) {
2340
2344
  const hasRows = Boolean(table) && settled.table === table && settled.loaded;
2341
2345
  return {
2342
2346
  data: hasRows ? settled.data : emptyRows,
2347
+ total: hasRows ? settled.total : null,
2343
2348
  loading: !hasRows && busy,
2344
2349
  fetching: busy,
2345
2350
  error: table && !busy && settled.table === table ? settled.error : null,
package/dist/index.d.ts CHANGED
@@ -843,6 +843,8 @@ export interface RecordCapabilities {
843
843
 
844
844
  export interface QueryResult<T> {
845
845
  data: T[];
846
+ /** How many rows the query matches in all, not just this page; null until known. */
847
+ total: number | null;
846
848
  /**
847
849
  * True only until the first result for the bound table lands. A changed
848
850
  * query on the same table, `refetch()` and pull-to-refresh keep the rows
@@ -866,6 +868,34 @@ export function useDatastoreQuery<T = unknown>(
866
868
  query?: Query,
867
869
  ): QueryResult<T>;
868
870
 
871
+ export interface FigureOptions {
872
+ /** Default `"count"`. `"sum"` and `"average"` read the number `column`. */
873
+ aggregate?: "count" | "sum" | "average";
874
+ column?: string;
875
+ /** The rows the figure covers, in any shape `Query.filter` accepts. */
876
+ filter?:
877
+ | Query["filter"]
878
+ | Array<{ column: string; operator: string; value?: unknown; valueMode?: string }>;
879
+ }
880
+
881
+ export interface FigureResult {
882
+ value: number | null;
883
+ /** The formatted figure for `<Stat value>`; null while loading or when there is none. */
884
+ text: string | null;
885
+ /** A sum or average read fewer rows than the table holds, so `text` reads "≈ n". */
886
+ truncated: boolean;
887
+ loading: boolean;
888
+ fetching: boolean;
889
+ error: Error | null;
890
+ refetch(): Promise<void>;
891
+ }
892
+
893
+ /** One live count, sum or average over a table, as the stock Stat computes it. */
894
+ export function useDatastoreFigure(
895
+ table: string | null | undefined,
896
+ options?: FigureOptions,
897
+ ): FigureResult;
898
+
869
899
  export function useDatastoreMutation<T = unknown>(
870
900
  table: string,
871
901
  ): MutationApi<T>;
@@ -2775,7 +2805,14 @@ export const Card: (props: {
2775
2805
  padding?: number | { top?: number; right?: number; bottom?: number; left?: number };
2776
2806
  shadow?: "none" | "sm" | "md" | "lg";
2777
2807
  gradient?: { from: string; to: string; angle?: number };
2778
- /** `card*` style fields, `shadow`, `margin` — a widget's per-instance style. */
2808
+ /**
2809
+ * `card*` style fields, `shadow`, `margin` — a widget's per-instance style.
2810
+ * With no `baseStyle` it also carries the box's layout (width, height, the
2811
+ * flex properties, `alignSelf`, `gap`, position), so `{ width: "100%" }`
2812
+ * sizes the card; anything else about the container goes on
2813
+ * `containerStyle`. Read for the `card*` fields alone when `baseStyle` is
2814
+ * given — the contract the built-in widgets pass their whole style bag on.
2815
+ */
2779
2816
  style?: Record<string, any>;
2780
2817
  /** The caller's own base style; a card given none starts from the theme. */
2781
2818
  baseStyle?: any;
package/dist/index.js CHANGED
@@ -104,6 +104,7 @@ export {
104
104
  // the web variant and index.native.js picks the native variant.
105
105
  export { useClipboard, ClipboardError } from "./clipboard.js";
106
106
  export { useToast } from "./toast.js";
107
+ export { useDatastoreFigure } from "./datastore-figure.js";
107
108
  // sc-6607 — `<Overlay>` is one shared component (./overlay-view.js) bound to
108
109
  // each platform's primitives, so a widget's modal escapes its container box
109
110
  // identically on the Player and the Expo export.
@@ -102,6 +102,7 @@ export {
102
102
  // REQ-WSDK-PLATFORM §6 — Tier A hooks (native variants).
103
103
  export { useClipboard, ClipboardError } from "./clipboard.native.js";
104
104
  export { useToast } from "./toast.native.js";
105
+ export { useDatastoreFigure } from "./datastore-figure.js";
105
106
  // sc-6607 — see the note on the web mirror in ./index.js.
106
107
  export { Overlay } from "./overlay.native.js";
107
108
  // sc-8104 — see the note on the web mirror in ./index.js.
package/dist/manifest.cjs CHANGED
@@ -196,7 +196,12 @@ function validateManifest(m) {
196
196
  );
197
197
 
198
198
  pushIf(errors, isNonEmptyString(manifest.icon), "manifest.icon must be a non-empty string");
199
- pushIf(errors, isNonEmptyString(manifest.description), "manifest.description must be a non-empty string");
199
+ // sc-9142: optional — a listing needs a name, not a blurb.
200
+ pushIf(
201
+ errors,
202
+ manifest.description === undefined || typeof manifest.description === "string",
203
+ "manifest.description must be a string when present",
204
+ );
200
205
 
201
206
  if (manifest.author === null || typeof manifest.author !== "object") {
202
207
  errors.push("manifest.author must be an object with a name");
package/dist/manifest.js CHANGED
@@ -242,7 +242,12 @@ function validateManifest(m) {
242
242
  );
243
243
 
244
244
  pushIf(errors, isNonEmptyString(manifest.icon), "manifest.icon must be a non-empty string");
245
- pushIf(errors, isNonEmptyString(manifest.description), "manifest.description must be a non-empty string");
245
+ // sc-9142: optional — a listing needs a name, not a blurb.
246
+ pushIf(
247
+ errors,
248
+ manifest.description === undefined || typeof manifest.description === "string",
249
+ "manifest.description must be a string when present",
250
+ );
246
251
 
247
252
  if (manifest.author === null || typeof manifest.author !== "object") {
248
253
  errors.push("manifest.author must be an object with a name");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.175.0",
3
+ "version": "0.179.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",