@colixsystems/widget-sdk 0.176.0 → 0.180.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 }`. |
@@ -56,7 +57,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
56
57
  | **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
57
58
  | **FILES** | `useAssetsByTag(tag, { type })` (optional: the whole argument, and `type` within it) | `{ assets, loading, error, refetch }` | `ctx.assets.list` (unwraps `{ data, meta }` to `assets`) — no scope. `type` defaults to `"image"`; pass `"all"` / `"audio"` / `"video"` / `"document"` to widen. Falsy `tag` collapses to `assets: []` without a round-trip. |
58
59
  | **DIRECTORY** (`ctx.directory`) | `useDirectory(query)` (optional: query) | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
59
- | **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset }` | `directory.users.*` — `users.read:*` (edits, incl. `sendPasswordReset()`, also `users.write:*`; `remove()` also `users.delete:*`) |
60
+ | **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset }` | `directory.users.*` — `users.read:*` plus the `users.read` (or `users.write`) capability, else `FORBIDDEN` (edits, incl. `sendPasswordReset()`, also `users.write:*`; `remove()` also `users.delete:*`) |
60
61
  | **DIRECTORY** | `useGroups(query)` (optional: query) | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
61
62
  | **DIRECTORY** | `useInvites(query)` (optional: query) | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| all` (endpoint default `all`). |
62
63
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
@@ -73,7 +74,39 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
73
74
 
74
75
  ## Status
75
76
 
76
- `v0.176.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.180.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.180.0 (contract 1.141.0)
80
+
81
+ **`<Button>` stays readable on a coloured section.** A section that paints its own fill (a card or band on the theme's colours, or a gradient wash) already gave its text a colour that reads there. Its buttons kept the page's paint, so an `outlined` button drew its label and border in the primary colour on a band washed in that same colour, and a `secondary` fill disappeared into it. A button there is now measured against the section by the same rule as its text: a colour that already reads is kept, and one the section makes unreadable takes the section's text colour, but only when that reads better. "The section makes it unreadable" is measured against the surface the look was designed for: a colour moves only when the section costs it at least a third of the contrast it had there, so a white or lightly tinted panel changes nothing.
82
+
83
+ - `outlined`: a label under 4.5:1 and a border under 3:1 against the section are re-inked.
84
+ - `secondary`: a fill under 3:1 against the section becomes the section's outlined button.
85
+ - `primary` and `danger`: a fill under 3:1 turns inverse, with the section's text colour as its fill and its own colour as its label, so it still leads the quieter buttons. Where that label would not reach 4.5:1, or you set the label colour, the fill is kept and gains an edge in the section's text colour instead.
86
+
87
+ A colour you pass in `fields`, `style` (`backgroundColor`, `borderColor`) or `labelStyle` (`color`), or that the app's button look sets, is never changed. A `<Card>` inside such a section becomes the surface its content reads: it re-derives `surface`, `onSurface`, `border` and the rest from its own fill, as a layout container does, and its buttons are measured against it. Inside a section `useTheme().colors.backdrop` is the fill your widget sits on and `colors.backdropBase` the surface the look was designed for; on the page itself both are absent.
88
+
89
+ ### What's new in 0.179.0 (contract 1.140.0)
90
+
91
+ **`<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:
92
+
93
+ ```jsx
94
+ <Card style={{ width: "100%", maxWidth: 480, alignSelf: "center" }}>…</Card>
95
+ ```
96
+
97
+ 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`.
98
+
99
+ 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.
100
+
101
+ ### What's new in 0.178.0 (contract 1.139.0)
102
+
103
+ **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.
104
+
105
+ ### What's new in 0.177.0 (contract 1.138.0)
106
+
107
+ **`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.
108
+
109
+ **`useDatastoreQuery` returns `total`**: how many rows the query matches in all (the envelope's `meta.total`), not only the page in `data`.
77
110
 
78
111
  ### What's new in 0.176.0 (contract 1.137.0)
79
112
 
@@ -160,7 +193,7 @@ Installed widgets pick up the row fix with no rebuild on the web: the Player han
160
193
  - **`<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.
161
194
  - **`<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.
162
195
  - **`<Progress>`** is real progress toward a goal, as a `bar` or a `ring`; the percent is clamped to 0-100 and printed.
163
- - **`<Stat>`** displays a headline figure you computed, with an optional caption, icon and delta. An empty value prints `emptyValue` (default "—"), never a fake 0.
196
+ - **`<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.
164
197
  - **`<Divider>`** splits two groups inside one surface, optionally captioned. A `<List>` and a `<Card>` already draw their own edges.
165
198
 
166
199
  `initialsFor(name)` is exported too. Additive: the built-in widgets render as before.
@@ -2,6 +2,8 @@
2
2
  // the built-in widgets (frontend `_shared/buttonStyle.js` re-exports these).
3
3
  // Platform-free, so both hosts resolve a button identically.
4
4
 
5
+ import { INK_CONTRAST, contrastRatio, inkForBackdrop, isHexColor } from "./contract.js";
6
+
5
7
  export const BUTTON_PADDING_MAX = 64;
6
8
  export const BUTTON_BORDER_WIDTH_MAX = 8;
7
9
 
@@ -58,3 +60,95 @@ export function selfAlignStyle(style) {
58
60
  const alignSelf = SELF_ALIGNMENTS[style && style.align];
59
61
  return alignSelf ? { alignSelf } : null;
60
62
  }
63
+
64
+ // How much of its page contrast a section must cost a colour before it moves:
65
+ // a tint or neutral panel never does, a coloured band always does.
66
+ const SECTION_LOSS = 1.5;
67
+
68
+ /**
69
+ * A variant's own paint, measured against the painted section it sits on
70
+ * (`colors.backdrop`) by the rule that section's text follows. A colour moves
71
+ * only where it is unreadable there AND the section is what broke it, measured
72
+ * against the surface the look was designed for (`colors.backdropBase`). Only
73
+ * what the look painted moves; `owned` names what the maker set.
74
+ *
75
+ * A transparent button re-inks its label and border separately. A fill the
76
+ * section swallows is replaced: a quiet `secondary` becomes the section's
77
+ * outlined button, and an emphatic fill turns inverse (the section's ink as
78
+ * fill, its own colour as label), so it still outranks the quiet ones. Where
79
+ * that label would not read, or the maker chose it, the fill gains an edge.
80
+ *
81
+ * @param {string} variant
82
+ * @param {{container: object, ink: string}} paint
83
+ * @param {object} colors - the surface-aware theme colours.
84
+ * @param {{fill?: boolean, label?: boolean, border?: boolean}} [owned]
85
+ * @returns {{container: object, ink: string}} `paint` itself when nothing moves.
86
+ */
87
+ export function paintOnBackdrop(variant, paint, colors, owned = {}) {
88
+ const { backdrop, backdropBase, onSurface: ink } = colors || {};
89
+ if (owned.fill || ![backdrop, backdropBase, ink].every(isHexColor)) return paint;
90
+ const reink = (colour, minimum) =>
91
+ isHexColor(colour) && brokenBySection(colour, backdrop, backdropBase, minimum)
92
+ ? inkForBackdrop(colour, ink, backdrop, minimum)
93
+ : colour;
94
+ const { container } = paint;
95
+ const fill = container.backgroundColor;
96
+ if (fill === "transparent") return reinkOutline(paint, reink, owned);
97
+ if (reink(fill, INK_CONTRAST.boundary) === fill) return paint;
98
+ if (!owned.label && variant === "secondary") {
99
+ return {
100
+ container: { backgroundColor: "transparent", borderWidth: 1, borderColor: ink },
101
+ ink,
102
+ };
103
+ }
104
+ if (!owned.label && contrastRatio(fill, ink) >= INK_CONTRAST.text) {
105
+ const edge = container.borderColor ? { borderColor: ink } : null;
106
+ return { container: { ...container, backgroundColor: ink, ...edge }, ink: fill };
107
+ }
108
+ if (owned.border) return paint;
109
+ return { container: { ...container, borderWidth: 1, borderColor: ink }, ink: paint.ink };
110
+ }
111
+
112
+ function brokenBySection(colour, backdrop, base, minimum) {
113
+ const here = contrastRatio(colour, backdrop);
114
+ return here < minimum && here * SECTION_LOSS <= contrastRatio(colour, base);
115
+ }
116
+
117
+ function reinkOutline(paint, reink, owned) {
118
+ const { borderColor } = paint.container;
119
+ const label = owned.label ? paint.ink : reink(paint.ink, INK_CONTRAST.text);
120
+ const border = owned.border ? borderColor : reink(borderColor, INK_CONTRAST.boundary);
121
+ if (label === paint.ink && border === borderColor) return paint;
122
+ return { container: { ...paint.container, borderColor: border }, ink: label };
123
+ }
124
+
125
+ const SIDE_BORDER_COLORS = [
126
+ "borderColor",
127
+ "borderTopColor",
128
+ "borderRightColor",
129
+ "borderBottomColor",
130
+ "borderLeftColor",
131
+ ];
132
+
133
+ /**
134
+ * Which of a button's fill, label and border its caller set, so
135
+ * `paintOnBackdrop` never replaces them: the surface `fields`, and colours in
136
+ * the flattened container `style` and `labelStyle`.
137
+ */
138
+ export function ownedButtonPaint(variant, fields, style, labelStyle) {
139
+ const f = fields || {};
140
+ const s = style || {};
141
+ const danger = variant === "danger";
142
+ return {
143
+ fill: Boolean(
144
+ f.background || f.gradient || s.backgroundColor || (danger && f.dangerBackground),
145
+ ),
146
+ label: Boolean(f.textColor || (labelStyle && labelStyle.color) || (danger && f.dangerTextColor)),
147
+ border: Boolean(
148
+ f.borderColor ||
149
+ f.ringColor ||
150
+ SIDE_BORDER_COLORS.some((key) => s[key]) ||
151
+ (danger && f.dangerBorderColor),
152
+ ),
153
+ };
154
+ }
@@ -8,7 +8,12 @@ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
8
8
  import { lucideGlyph } from "./icon.js";
9
9
  import { applyThemeComponentStyle } from "./theme-components.js";
10
10
  import { cornerRadiusOverlay, shadowStyle, gradientProps } from "./card-style.js";
11
- import { buttonSurfaceOverlay, buttonSurfaceTextOverlay } from "./button-style.js";
11
+ import {
12
+ buttonSurfaceOverlay,
13
+ buttonSurfaceTextOverlay,
14
+ ownedButtonPaint,
15
+ paintOnBackdrop,
16
+ } from "./button-style.js";
12
17
 
13
18
  export const BUTTON_VARIANTS = Object.freeze(["primary", "secondary", "outlined", "danger"]);
14
19
 
@@ -39,8 +44,8 @@ function variantPaint(variant, colors) {
39
44
  }
40
45
 
41
46
  // Precedence, weakest first: variant, danger scope, ring, then the fields.
42
- function containerLayers(variant, f, colors) {
43
- const paint = variantPaint(variant, colors);
47
+ function containerLayers(variant, f, colors, owned) {
48
+ const paint = paintOnBackdrop(variant, variantPaint(variant, colors), colors, owned);
44
49
  let ink = paint.ink;
45
50
  const layers = [paint.container];
46
51
  if (variant === "danger") {
@@ -115,7 +120,13 @@ export function makeButton(rn) {
115
120
  ? fields || {}
116
121
  : applyThemeComponentStyle(THEMED_AS, theme, { style: fields }).style || {};
117
122
  const resolvedVariant = BUTTON_VARIANTS.includes(variant) ? variant : "primary";
118
- const { layers, ink } = containerLayers(resolvedVariant, f, colors);
123
+ const owned = ownedButtonPaint(
124
+ resolvedVariant,
125
+ f,
126
+ StyleSheet.flatten(style),
127
+ StyleSheet.flatten(labelStyle),
128
+ );
129
+ const { layers, ink } = containerLayers(resolvedVariant, f, colors, owned);
119
130
 
120
131
  const fontSize =
121
132
  typeof f.fontSize === "number" && Number.isFinite(f.fontSize)
@@ -5,7 +5,7 @@
5
5
  // Every resolver emits ONLY the fields that are set, so a widget's own base
6
6
  // style keeps whatever the author left alone.
7
7
 
8
- import { CONTRACT, normaliseComponentGradient } from "./contract.js";
8
+ import { CONTRACT, isHexColor, mixHex, normaliseComponentGradient } from "./contract.js";
9
9
  import { normaliseCornerRadius, cornerRadiusStyle } from "./corner-radius.js";
10
10
  import { normaliseSpacing, spacingStyle, isZeroSpacing } from "./spacing.js";
11
11
 
@@ -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
@@ -74,6 +118,35 @@ export function gradientProps(value) {
74
118
  : null;
75
119
  }
76
120
 
121
+ /**
122
+ * The colour a card's buttons are measured against: its opaque flat fill, or
123
+ * a gradient's midpoint, as a layout container's ink basis is read. `null`
124
+ * (a translucent or unknown fill) keeps the backdrop the card inherited.
125
+ *
126
+ * @param {object|Array} styles - the card's container style, as composed.
127
+ * @param {{colors: string[]}|null} gradient - its `gradientProps`.
128
+ */
129
+ export function cardBackdrop(styles, gradient) {
130
+ if (gradient) {
131
+ const [from, to] = gradient.colors;
132
+ return isOpaqueHex(from) && isOpaqueHex(to) ? mixHex(from, to, 0.5) : null;
133
+ }
134
+ let fill = null;
135
+ for (const style of [styles].flat(Infinity)) {
136
+ if (style && typeof style === "object" && style.backgroundColor !== undefined) {
137
+ fill = style.backgroundColor;
138
+ }
139
+ }
140
+ return isOpaqueHex(fill) ? fill : null;
141
+ }
142
+
143
+ // The colour maths ignores alpha, so a see-through fill cannot be judged.
144
+ function isOpaqueHex(value) {
145
+ if (!isHexColor(value)) return false;
146
+ const hex = value.slice(1);
147
+ return hex.length !== 8 || hex.slice(6).toLowerCase() === "ff";
148
+ }
149
+
77
150
  /** The card-surface gradient (`cardGradient`). */
78
151
  export function cardGradientProps(style) {
79
152
  return gradientProps(style && style.cardGradient);
package/dist/card-view.js CHANGED
@@ -6,10 +6,16 @@
6
6
  // swap lives here once rather than in each widget.
7
7
 
8
8
  import React from "react";
9
- import { useHostTheme } from "./hooks.js";
9
+ import { useHostTheme, BackdropScope } 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
+ cardBackdrop,
18
+ } from "./card-style.js";
13
19
  import { normaliseThemeComponents } from "./theme-components.js";
14
20
 
15
21
  // A wrapped scroller stays transparent and fits inside the gradient.
@@ -75,9 +81,16 @@ export function makeCard(rn) {
75
81
 
76
82
  /**
77
83
  * @param {object} [props.style] — `card*` fields, `shadow`, `margin`
78
- * (a widget's per-instance style, theme tokens already folded in).
84
+ * (a widget's per-instance style, theme tokens already folded in). On a
85
+ * card with no `baseStyle` it also carries the box's LAYOUT
86
+ * (`CARD_LAYOUT_STYLE_FIELDS`), so `style={{ width: "100%" }}` sizes the
87
+ * card rather than vanishing. Under `as` + a gradient the layout rides the
88
+ * gradient — the card's real box — so the content keys there
89
+ * (`flexDirection`, `gap`, …) arrange the wrapper, not the inner children.
79
90
  * @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.
91
+ * card given none starts from the theme's surface and `card` scope. A
92
+ * caller that supplies one owns the container's layout, so its `style` is
93
+ * read for `card*` fields only — that is the built-in widgets' contract.
81
94
  * @param {React.ElementType} [props.as] — container; anything but `View`
82
95
  * renders INSIDE the gradient, never as it. Other props land on it.
83
96
  * @param {boolean} [props.fillInGradient] — the inner container stretches to
@@ -117,15 +130,22 @@ export function makeCard(rn) {
117
130
  ...explicit,
118
131
  };
119
132
  const base = standalone ? themedBase(theme) : baseStyle;
133
+ // Last, so an author's own declaration beats the theme's surface.
134
+ const layout = standalone ? cardLayoutStyle(style) : null;
120
135
 
121
136
  const overlay = withoutMargin ? cardSurfaceStyle(fields) : cardOverlay(fields);
122
137
  const gradient = cardGradientProps(fields);
138
+ const basis = cardBackdrop(
139
+ [base, overlay, typeof containerStyle === "function" ? null : containerStyle],
140
+ gradient,
141
+ );
142
+ const scoped = React.createElement(BackdropScope, { basis }, children);
123
143
 
124
144
  if (!gradient) {
125
145
  return React.createElement(
126
146
  Container,
127
- { style: composeStyle([base, overlay], containerStyle), ...rest },
128
- children,
147
+ { style: composeStyle([base, overlay, layout], containerStyle), ...rest },
148
+ scoped,
129
149
  );
130
150
  }
131
151
 
@@ -139,10 +159,10 @@ export function makeCard(rn) {
139
159
  {
140
160
  colors: gradient.colors,
141
161
  angle: gradient.angle,
142
- style: composeStyle([base, paint], containerStyle),
162
+ style: composeStyle([base, paint, layout], containerStyle),
143
163
  ...rest,
144
164
  },
145
- children,
165
+ scoped,
146
166
  );
147
167
  }
148
168
 
@@ -153,7 +173,8 @@ export function makeCard(rn) {
153
173
  {
154
174
  colors: gradient.colors,
155
175
  angle: gradient.angle,
156
- style: [base, paint, CLIP_TO_RADIUS],
176
+ // Before the clip: it keeps the gradient inside the corners, not a default.
177
+ style: [base, paint, layout, CLIP_TO_RADIUS],
157
178
  },
158
179
  React.createElement(
159
180
  Container,
@@ -164,7 +185,7 @@ export function makeCard(rn) {
164
185
  ),
165
186
  ...rest,
166
187
  },
167
- children,
188
+ scoped,
168
189
  ),
169
190
  );
170
191
  }
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,14 +2473,14 @@ 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
  },
2446
2480
  {
2447
2481
  name: "Button",
2448
2482
  description:
2449
- 'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Theme Settings > Buttons, and Destructive / Focus ring for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. Never hand-roll a button from `Pressable` + background + radius.',
2483
+ 'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Theme Settings > Buttons, and Destructive / Focus ring for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. On a section that paints its own fill (`theme.colors.backdrop` there), a variant colour that the section makes unreadable takes that section\'s text colour, as its text does: an outlined label and border, a secondary whose fill disappears becomes outlined, and a primary or danger fill turns inverse (that text colour as its fill, its own colour as its label). A white or lightly tinted panel changes nothing, and a button inside a `<Card>` is measured against that card. A colour you pass in `fields`, `style` or `labelStyle`, or that the app\'s button look sets, is never changed. Never hand-roll a button from `Pressable` + background + radius.',
2450
2484
  rnComponent: null,
2451
2485
  docsUrl: null,
2452
2486
  },
@@ -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,17 @@ 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.137.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
+ // 1.141.0: additive (sc-9346) — inside a section that paints its own fill,
4768
+ // `theme.colors.backdrop` is that fill, and `<Button>` re-inks a variant
4769
+ // colour that cannot be read against it by the rule its text follows.
4770
+ version: "1.141.0",
4720
4771
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4721
4772
  hooks: HOOKS,
4722
4773
  primitives: PRIMITIVES,
@@ -4989,6 +5040,25 @@ function deriveSurfaceTokens(backgroundColor) {
4989
5040
  };
4990
5041
  }
4991
5042
 
5043
+ // sc-8370: WCAG 1.4.3 for text, 1.4.11 for a control's boundary.
5044
+ const INK_CONTRAST = Object.freeze({ text: 4.5, boundary: 3 });
5045
+
5046
+ /**
5047
+ * The measured re-ink rule every painted section applies — to its text tiers
5048
+ * and to its buttons alike. `current` is kept while it reaches `minimum`
5049
+ * against `backdrop`; below that, `candidate` replaces it only when it is
5050
+ * genuinely more readable there. A backdrop with no good answer keeps what it
5051
+ * had rather than committing to a colour that is wrong for half of it.
5052
+ */
5053
+ function inkForBackdrop(current, candidate, backdrop, minimum) {
5054
+ if (!isHexColor(current) || !isHexColor(candidate) || !isHexColor(backdrop)) {
5055
+ return current;
5056
+ }
5057
+ const held = contrastRatio(current, backdrop);
5058
+ if (held >= minimum) return current;
5059
+ return contrastRatio(candidate, backdrop) > held ? candidate : current;
5060
+ }
5061
+
4992
5062
  /**
4993
5063
  * Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
4994
5064
  * `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
@@ -5071,6 +5141,8 @@ module.exports = {
5071
5141
  readableTextColor,
5072
5142
  deriveAccentTints,
5073
5143
  deriveSurfaceTokens,
5144
+ inkForBackdrop,
5145
+ INK_CONTRAST,
5074
5146
  gradientAngleToVector,
5075
5147
  normaliseComponentGradient,
5076
5148
  isThemeColorTokenRef,
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,14 +2473,14 @@ 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
  },
2446
2480
  {
2447
2481
  name: "Button",
2448
2482
  description:
2449
- 'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Theme Settings > Buttons, and Destructive / Focus ring for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. Never hand-roll a button from `Pressable` + background + radius.',
2483
+ 'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Theme Settings > Buttons, and Destructive / Focus ring for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. On a section that paints its own fill (`theme.colors.backdrop` there), a variant colour that the section makes unreadable takes that section\'s text colour, as its text does: an outlined label and border, a secondary whose fill disappears becomes outlined, and a primary or danger fill turns inverse (that text colour as its fill, its own colour as its label). A white or lightly tinted panel changes nothing, and a button inside a `<Card>` is measured against that card. A colour you pass in `fields`, `style` or `labelStyle`, or that the app\'s button look sets, is never changed. Never hand-roll a button from `Pressable` + background + radius.',
2450
2484
  rnComponent: null,
2451
2485
  docsUrl: null,
2452
2486
  },
@@ -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,17 @@ 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.137.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
+ // 1.141.0: additive (sc-9346) — inside a section that paints its own fill,
4764
+ // `theme.colors.backdrop` is that fill, and `<Button>` re-inks a variant
4765
+ // colour that cannot be read against it by the rule its text follows.
4766
+ version: "1.141.0",
4716
4767
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4717
4768
  hooks: HOOKS,
4718
4769
  primitives: PRIMITIVES,
@@ -4985,6 +5036,25 @@ function deriveSurfaceTokens(backgroundColor) {
4985
5036
  };
4986
5037
  }
4987
5038
 
5039
+ // sc-8370: WCAG 1.4.3 for text, 1.4.11 for a control's boundary.
5040
+ const INK_CONTRAST = Object.freeze({ text: 4.5, boundary: 3 });
5041
+
5042
+ /**
5043
+ * The measured re-ink rule every painted section applies — to its text tiers
5044
+ * and to its buttons alike. `current` is kept while it reaches `minimum`
5045
+ * against `backdrop`; below that, `candidate` replaces it only when it is
5046
+ * genuinely more readable there. A backdrop with no good answer keeps what it
5047
+ * had rather than committing to a colour that is wrong for half of it.
5048
+ */
5049
+ function inkForBackdrop(current, candidate, backdrop, minimum) {
5050
+ if (!isHexColor(current) || !isHexColor(candidate) || !isHexColor(backdrop)) {
5051
+ return current;
5052
+ }
5053
+ const held = contrastRatio(current, backdrop);
5054
+ if (held >= minimum) return current;
5055
+ return contrastRatio(candidate, backdrop) > held ? candidate : current;
5056
+ }
5057
+
4988
5058
  /**
4989
5059
  * Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
4990
5060
  * `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
@@ -5067,6 +5137,8 @@ export {
5067
5137
  readableTextColor,
5068
5138
  deriveAccentTints,
5069
5139
  deriveSurfaceTokens,
5140
+ inkForBackdrop,
5141
+ INK_CONTRAST,
5070
5142
  gradientAngleToVector,
5071
5143
  normaliseComponentGradient,
5072
5144
  isThemeColorTokenRef,
@@ -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
@@ -46,6 +46,7 @@ import {
46
46
  // sc-5717 — "is this key still at its declared default", shared with the
47
47
  // host codec so the hook and the encoder can never disagree about it.
48
48
  sameWidgetRouteValue,
49
+ deriveSurfaceTokens,
49
50
  } from "./contract.js";
50
51
 
51
52
  /** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
@@ -67,6 +68,27 @@ export function WidgetContextProvider({ value, children }) {
67
68
  return React.createElement(HostWidgetContext.Provider, { value }, children);
68
69
  }
69
70
 
71
+ /**
72
+ * @internal — a surface painting its own fill inside a coloured section
73
+ * becomes the surface its content reads and its buttons are measured against
74
+ * (`<Card>` uses it), by the derivation a layout container applies.
75
+ * Outside such a section there is no backdrop and nothing changes.
76
+ */
77
+ export function BackdropScope({ basis, children }) {
78
+ const ctx = useContext(HostWidgetContext);
79
+ const theme = ctx && ctx.workspace ? ctx.workspace.theme : null;
80
+ const colors = theme && theme.colors;
81
+ const scoped = Boolean(basis && colors && colors.backdrop && colors.backdrop !== basis);
82
+ const value = useMemo(
83
+ () =>
84
+ scoped
85
+ ? { ...ctx, workspace: { ...ctx.workspace, theme: { ...theme, colors: { ...colors, ...deriveSurfaceTokens(basis), backdrop: basis } } } }
86
+ : ctx,
87
+ [scoped, ctx, theme, colors, basis],
88
+ );
89
+ return React.createElement(HostWidgetContext.Provider, { value }, children);
90
+ }
91
+
70
92
  // Exported for ./toast.js + future hook modules outside hooks.js. Internal
71
93
  // utility — widget code should not import this directly.
72
94
  export function useWidgetContextOrThrow(hookName) {
@@ -2173,7 +2195,7 @@ function toDatastoreError(err) {
2173
2195
  // on a genuine loop.
2174
2196
  const VOLATILE_QUERY_RENDER_LIMIT = 10;
2175
2197
 
2176
- const NO_QUERY_RESULT = { key: null, table: null, data: null, error: null, loaded: false };
2198
+ const NO_QUERY_RESULT = { key: null, table: null, data: null, total: null, error: null, loaded: false };
2177
2199
  const NO_RECORD_RESULT = { key: null, data: null, error: null, loaded: false };
2178
2200
 
2179
2201
  function serializeQuery(query) {
@@ -2189,8 +2211,8 @@ const recordResultKey = (table, recordId, includeCapabilities = false) =>
2189
2211
  JSON.stringify([table, recordId, includeCapabilities]);
2190
2212
 
2191
2213
  /**
2192
- * Stateful datastore query hook. Returns { data, loading, fetching, error,
2193
- * refetch }.
2214
+ * Stateful datastore query hook. Returns { data, total, loading, fetching,
2215
+ * error, refetch }.
2194
2216
  *
2195
2217
  * The host injects a `@colixsystems/datastore-client` instance at
2196
2218
  * `ctx.datastore`. We call `ctx.datastore.records(table).list(query)`, which
@@ -2207,6 +2229,9 @@ const recordResultKey = (table, recordId, includeCapabilities = false) =>
2207
2229
  * `_capabilities` ({ can_read, can_write, can_delete, can_grant }), computed
2208
2230
  * for the whole page in one server lookup. Gate a row's Edit / Delete on it.
2209
2231
  *
2232
+ * sc-9150 — `total` is the envelope's `meta.total`: how many rows the query
2233
+ * matches, beyond the page in `data`. null until known.
2234
+ *
2210
2235
  * sc-8595 — stale-while-revalidate. `loading` is true only until the first
2211
2236
  * result for the bound TABLE lands. A changed query on that table, `refetch()`
2212
2237
  * and a page pull-to-refresh keep the rows already in `data` on screen with
@@ -2274,9 +2299,10 @@ export function useDatastoreQuery(table, query) {
2274
2299
  // The datastore-client returns the { data, meta } list envelope
2275
2300
  // verbatim — unwrap to the rows array (default []).
2276
2301
  const rows = res && Array.isArray(res.data) ? res.data : [];
2302
+ const total = res && res.meta && Number.isFinite(res.meta.total) ? res.meta.total : null;
2277
2303
  // Discard the result if a newer fetch has started since we kicked off.
2278
2304
  if (runRef.current !== myRun) return;
2279
- setSettled({ key, table: t, data: rows, error: null, loaded: true });
2305
+ setSettled({ key, table: t, data: rows, total, error: null, loaded: true });
2280
2306
  setInFlight(false);
2281
2307
  } catch (err) {
2282
2308
  if (runRef.current !== myRun) return;
@@ -2285,7 +2311,7 @@ export function useDatastoreQuery(table, query) {
2285
2311
  setSettled((prev) =>
2286
2312
  prev.table === t && prev.loaded
2287
2313
  ? { ...prev, key, error }
2288
- : { key, table: t, data: emptyRows, error, loaded: false },
2314
+ : { key, table: t, data: emptyRows, total: null, error, loaded: false },
2289
2315
  );
2290
2316
  setInFlight(false);
2291
2317
  }
@@ -2340,6 +2366,7 @@ export function useDatastoreQuery(table, query) {
2340
2366
  const hasRows = Boolean(table) && settled.table === table && settled.loaded;
2341
2367
  return {
2342
2368
  data: hasRows ? settled.data : emptyRows,
2369
+ total: hasRows ? settled.total : null,
2343
2370
  loading: !hasRows && busy,
2344
2371
  fetching: busy,
2345
2372
  error: table && !busy && settled.table === table ? settled.error : null,
package/dist/host.d.ts CHANGED
@@ -39,6 +39,21 @@ export type ThemeWidgetStyles = Record<string, ThemeComponentStyle>;
39
39
  */
40
40
  export function normaliseThemeComponents(raw: unknown): ThemeComponents;
41
41
 
42
+ /** Minimum contrast: `text` for labels (WCAG 1.4.3), `boundary` for edges (1.4.11). */
43
+ export const INK_CONTRAST: Readonly<{ text: number; boundary: number }>;
44
+
45
+ /**
46
+ * The measured re-ink rule a painted section applies to its text and buttons:
47
+ * `current` while it reaches `minimum` against `backdrop`, else `candidate`
48
+ * when that reads better there, else `current`.
49
+ */
50
+ export function inkForBackdrop(
51
+ current: string,
52
+ candidate: string,
53
+ backdrop: string,
54
+ minimum: number,
55
+ ): string;
56
+
42
57
  /**
43
58
  * REQ-THEME-ELEMENT host helper: validates a raw `themeConfig.widgetStyles` blob.
44
59
  * Structural only — the key space is the workspace's widget catalog, not the
package/dist/host.js CHANGED
@@ -41,7 +41,7 @@ export {
41
41
  // surface — the page background and every container that carries its own fill.
42
42
  // Host plumbing, deliberately off the author entry point: a widget reads the
43
43
  // already-correct `useTheme().colors`, it never derives them itself.
44
- export { deriveSurfaceTokens } from "./contract.js";
44
+ export { deriveSurfaceTokens, inkForBackdrop, INK_CONTRAST } from "./contract.js";
45
45
 
46
46
  // REQ-WSDK-PLATFORM §6: the host half of `useToast()` — the queue, the
47
47
  // auto-dismiss timing, and the themed values a toast is painted with. The
package/dist/index.d.ts CHANGED
@@ -393,6 +393,11 @@ export interface ThemeTokens {
393
393
  onSurface: string;
394
394
  danger: string;
395
395
  [k: string]: string;
396
+ } & {
397
+ /** Inside a section painting its own fill: that fill. Absent on the page. */
398
+ backdrop?: string;
399
+ /** The surface the look was designed for, which `backdrop` is judged against. */
400
+ backdropBase?: string;
396
401
  };
397
402
  /** sc-3696: the shared elevation vocabulary. Spread a level into a style. */
398
403
  elevation: {
@@ -843,6 +848,8 @@ export interface RecordCapabilities {
843
848
 
844
849
  export interface QueryResult<T> {
845
850
  data: T[];
851
+ /** How many rows the query matches in all, not just this page; null until known. */
852
+ total: number | null;
846
853
  /**
847
854
  * True only until the first result for the bound table lands. A changed
848
855
  * query on the same table, `refetch()` and pull-to-refresh keep the rows
@@ -866,6 +873,34 @@ export function useDatastoreQuery<T = unknown>(
866
873
  query?: Query,
867
874
  ): QueryResult<T>;
868
875
 
876
+ export interface FigureOptions {
877
+ /** Default `"count"`. `"sum"` and `"average"` read the number `column`. */
878
+ aggregate?: "count" | "sum" | "average";
879
+ column?: string;
880
+ /** The rows the figure covers, in any shape `Query.filter` accepts. */
881
+ filter?:
882
+ | Query["filter"]
883
+ | Array<{ column: string; operator: string; value?: unknown; valueMode?: string }>;
884
+ }
885
+
886
+ export interface FigureResult {
887
+ value: number | null;
888
+ /** The formatted figure for `<Stat value>`; null while loading or when there is none. */
889
+ text: string | null;
890
+ /** A sum or average read fewer rows than the table holds, so `text` reads "≈ n". */
891
+ truncated: boolean;
892
+ loading: boolean;
893
+ fetching: boolean;
894
+ error: Error | null;
895
+ refetch(): Promise<void>;
896
+ }
897
+
898
+ /** One live count, sum or average over a table, as the stock Stat computes it. */
899
+ export function useDatastoreFigure(
900
+ table: string | null | undefined,
901
+ options?: FigureOptions,
902
+ ): FigureResult;
903
+
869
904
  export function useDatastoreMutation<T = unknown>(
870
905
  table: string,
871
906
  ): MutationApi<T>;
@@ -2775,7 +2810,14 @@ export const Card: (props: {
2775
2810
  padding?: number | { top?: number; right?: number; bottom?: number; left?: number };
2776
2811
  shadow?: "none" | "sm" | "md" | "lg";
2777
2812
  gradient?: { from: string; to: string; angle?: number };
2778
- /** `card*` style fields, `shadow`, `margin` — a widget's per-instance style. */
2813
+ /**
2814
+ * `card*` style fields, `shadow`, `margin` — a widget's per-instance style.
2815
+ * With no `baseStyle` it also carries the box's layout (width, height, the
2816
+ * flex properties, `alignSelf`, `gap`, position), so `{ width: "100%" }`
2817
+ * sizes the card; anything else about the container goes on
2818
+ * `containerStyle`. Read for the `card*` fields alone when `baseStyle` is
2819
+ * given — the contract the built-in widgets pass their whole style bag on.
2820
+ */
2779
2821
  style?: Record<string, any>;
2780
2822
  /** The caller's own base style; a card given none starts from the theme. */
2781
2823
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.176.0",
3
+ "version": "0.180.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",