@colixsystems/widget-sdk 0.199.0 → 0.201.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 +56 -1
- package/dist/contract.cjs +21 -2
- package/dist/contract.js +21 -2
- package/dist/emoji-picker.js +2 -1
- package/dist/hooks.js +7 -4
- package/dist/host.d.ts +6 -0
- package/dist/host.js +1 -0
- package/dist/index.d.ts +24 -5
- package/dist/linter.cjs +49 -0
- package/dist/linter.js +49 -0
- package/dist/manifest.cjs +6 -0
- package/dist/manifest.js +6 -0
- package/dist/markdown-input.js +6 -1
- package/dist/primitives.js +2 -1
- package/dist/text-field.js +2 -1
- package/dist/text-input-view.js +27 -0
- package/dist/theme-background.cjs +26 -3
- package/dist/theme-background.js +20 -2
- package/dist/touch-input-font-floor.js +22 -0
- package/package.json +1 -1
- package/src/contract.js +21 -2
package/README.md
CHANGED
|
@@ -83,7 +83,62 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
83
83
|
|
|
84
84
|
## Status
|
|
85
85
|
|
|
86
|
-
`v0.
|
|
86
|
+
`v0.201.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
87
|
+
|
|
88
|
+
### What's new in 0.201.0 (contract 1.160.0)
|
|
89
|
+
|
|
90
|
+
**A page badge can stay live while its page is closed — `manifest.badgeCounter` + `useBadgeCount(props)`.** `usePageBadge` only runs while your widget is on screen, so a chat's unread count used to reach the menu only after someone opened the chat's page. Declare `badgeCounter: true` and export a counting hook from every platform entry, and the app runs it in the background for each placement on a page its menu links to (installed marketplace widgets, your workspace's own included):
|
|
91
|
+
|
|
92
|
+
```jsx
|
|
93
|
+
import { useMemo } from "react";
|
|
94
|
+
import {
|
|
95
|
+
useDatastoreQuery,
|
|
96
|
+
useDatastoreSubscription,
|
|
97
|
+
usePageBadge,
|
|
98
|
+
useStableQuery,
|
|
99
|
+
useUser,
|
|
100
|
+
} from "@colixsystems/widget-sdk";
|
|
101
|
+
|
|
102
|
+
function useOpenTasks({ tasksTableId }) {
|
|
103
|
+
const me = useUser().id;
|
|
104
|
+
const table = me ? tasksTableId : null;
|
|
105
|
+
const { data, loading, refetch } = useDatastoreQuery(
|
|
106
|
+
table,
|
|
107
|
+
useStableQuery(() => ({ filter: { Assignee: "eq:me", Done: "eq:false" }, limit: 500 })),
|
|
108
|
+
);
|
|
109
|
+
// Stay live while the page is closed: re-read whenever a task changes.
|
|
110
|
+
const handlers = useMemo(
|
|
111
|
+
() => ({ onCreated: refetch, onUpdated: refetch, onDeleted: refetch }),
|
|
112
|
+
[refetch],
|
|
113
|
+
);
|
|
114
|
+
useDatastoreSubscription(table, handlers);
|
|
115
|
+
return loading ? null : data.length;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The host runs this while the page is closed.
|
|
119
|
+
export function useBadgeCount(props) {
|
|
120
|
+
return useOpenTasks(props);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default function TaskList(props) {
|
|
124
|
+
usePageBadge(useOpenTasks(props));
|
|
125
|
+
// ...
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
- **The hook** is called with the placement's resolved props, inside the widget's own context. Return a whole number, or `null` while the count is not known yet. It must only read: no rendering, no data writes, no navigation, no events or toasts, nothing marked read, and no `usePageBadge`, `useScreenTitle`, `useSectionEmpty` or `useWidgetRoute` setter.
|
|
130
|
+
- **The host enforces read-only.** A counter's datastore and directory offer only their read methods and realtime subscriptions, and notification mutes only `get`/`list`. Writes, typing signals, payments, `notifications.send`, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable, exactly as on a host that does not provide them.
|
|
131
|
+
- **Stay live.** The counter runs for as long as the app is open, so subscribe to changes (as above) or refetch on a slow interval; a counter that reads once goes stale.
|
|
132
|
+
- **Share one implementation.** Count with the same function the widget badges with, so the background count and the open widget never disagree.
|
|
133
|
+
- **`usePageBadge(null)` is a new signal.** An explicit `null` means "not known yet": nothing is written and the current badge stays. Pass `null` until your first load settles, so opening the page never flashes the badge to 0. `undefined`, `0` and any other value that is not a positive number still clear this widget's share, exactly as before, so `usePageBadge(data?.count)` behaves as it always has.
|
|
134
|
+
- **New linter rules.** `badge-counter-export-missing` (error) refuses a widget that declares `badgeCounter` while an entry has no `useBadgeCount` export; `badge-counter-undeclared` (warning) flags the export without the declaration. The export name is published as `CONTRACT.badgeCounterExport`.
|
|
135
|
+
- **Types:** `WidgetManifest.badgeCounter` and `BadgeCounterHook`.
|
|
136
|
+
|
|
137
|
+
Additive: a widget without `badgeCounter` is unaffected.
|
|
138
|
+
|
|
139
|
+
### What's new in 0.200.0 (contract 1.159.0)
|
|
140
|
+
|
|
141
|
+
**Host sign-in screen: a logo on the card.** `resolveLoginLogoUrl(login, appLogoUrl)` answers which logo the built-in sign-in card shows: the author's own sign-in logo (`login.logoUrlResolved`, the host's resolution of `login.logoAssetId`, else the pasted `login.logoUrl`), else the app logo the host already resolved, else `null` for the stock mark. Every candidate passes the same scheme allowlist as the sign-in background, so a `javascript:` or `data:` value never reaches either host. It is kept apart from `normaliseLoginDesign` because an app logo alone is not an authored design. Widgets are unaffected: this is the host surface (`@colixsystems/widget-sdk/host`), and the widget contract version does not change.
|
|
87
142
|
|
|
88
143
|
### What's new in 0.199.0 (contract 1.159.0)
|
|
89
144
|
|
package/dist/contract.cjs
CHANGED
|
@@ -1065,7 +1065,10 @@ const HOOKS = [
|
|
|
1065
1065
|
description:
|
|
1066
1066
|
"Puts count on the menu entry or tab of the page this widget is on (a chat's unread messages, open tasks). " +
|
|
1067
1067
|
"The host sums every widget on the page and keeps the last count after the widget leaves the screen, until it " +
|
|
1068
|
-
"reports again; 0, or
|
|
1068
|
+
"reports again; 0, undefined, or any other value that is not a positive number, clears this widget's share. " +
|
|
1069
|
+
"An explicit null means the count is not known yet and keeps the current badge (pass null until the first load " +
|
|
1070
|
+
"settles, so the badge never flashes to 0). manifest.badgeCounter keeps the badge live while the page is closed; " +
|
|
1071
|
+
"see that field for who may declare it. Returns { supported }: false " +
|
|
1069
1072
|
"on a host without page badges (the Builder canvas), where it does nothing. Call it at render with the current " +
|
|
1070
1073
|
"count, never in a loop. Backed by ctx.navigation.setBadge. Identical on web (Player) and the Expo export.",
|
|
1071
1074
|
returnShape: {
|
|
@@ -3047,6 +3050,13 @@ const MANIFEST_SCHEMA = {
|
|
|
3047
3050
|
"Optional (default false). TRUE when the widget draws a strip of live figures (counts, totals, averages) about the records it shows, or is itself a strip of figures. The AppStudio app-builder reads this when it places an already-installed widget: it counts the widget as the page's figures, so it does not plan a second strip of figures beside it. Set it to match what the component actually renders; leave it false (or omit) when the widget draws no figures.",
|
|
3048
3051
|
default: false,
|
|
3049
3052
|
},
|
|
3053
|
+
badgeCounter: {
|
|
3054
|
+
type: "boolean",
|
|
3055
|
+
required: false,
|
|
3056
|
+
description:
|
|
3057
|
+
"Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
|
|
3058
|
+
default: false,
|
|
3059
|
+
},
|
|
3050
3060
|
events: {
|
|
3051
3061
|
type: "object[]",
|
|
3052
3062
|
required: true,
|
|
@@ -5305,7 +5315,14 @@ const CONTRACT = deepFreeze({
|
|
|
5305
5315
|
// flat `assets/` directory (`bundledAssets`, within `packageMaxBytes`).
|
|
5306
5316
|
// New `useBundledAsset(fileName)` resolves one through the new
|
|
5307
5317
|
// `widget.assets` map; linter rule `bundled-asset-missing` (error).
|
|
5308
|
-
|
|
5318
|
+
// 1.160.0: additive (sc-10829) — optional manifest field `badgeCounter`:
|
|
5319
|
+
// the entry exports `useBadgeCount(props)` (name in `badgeCounterExport`)
|
|
5320
|
+
// and the host runs it while the widget's page is closed, so the page
|
|
5321
|
+
// badge stays live; the host gives a counter a read-only context.
|
|
5322
|
+
// `usePageBadge(null)` now means "not known yet" and keeps the current
|
|
5323
|
+
// badge; `undefined` still clears it. Linter rules `badge-counter-export-missing`
|
|
5324
|
+
// (error) and `badge-counter-undeclared` (warning).
|
|
5325
|
+
version: "1.160.0",
|
|
5309
5326
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5310
5327
|
hooks: HOOKS,
|
|
5311
5328
|
primitives: PRIMITIVES,
|
|
@@ -5331,6 +5348,8 @@ const CONTRACT = deepFreeze({
|
|
|
5331
5348
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
5332
5349
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
5333
5350
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
5351
|
+
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|
|
5352
|
+
badgeCounterExport: "useBadgeCount",
|
|
5334
5353
|
bannedApis: BANNED_APIS,
|
|
5335
5354
|
vettedImports: VETTED_IMPORTS,
|
|
5336
5355
|
allowedBareImports: ALLOWED_BARE_IMPORTS,
|
package/dist/contract.js
CHANGED
|
@@ -1065,7 +1065,10 @@ const HOOKS = [
|
|
|
1065
1065
|
description:
|
|
1066
1066
|
"Puts count on the menu entry or tab of the page this widget is on (a chat's unread messages, open tasks). " +
|
|
1067
1067
|
"The host sums every widget on the page and keeps the last count after the widget leaves the screen, until it " +
|
|
1068
|
-
"reports again; 0, or
|
|
1068
|
+
"reports again; 0, undefined, or any other value that is not a positive number, clears this widget's share. " +
|
|
1069
|
+
"An explicit null means the count is not known yet and keeps the current badge (pass null until the first load " +
|
|
1070
|
+
"settles, so the badge never flashes to 0). manifest.badgeCounter keeps the badge live while the page is closed; " +
|
|
1071
|
+
"see that field for who may declare it. Returns { supported }: false " +
|
|
1069
1072
|
"on a host without page badges (the Builder canvas), where it does nothing. Call it at render with the current " +
|
|
1070
1073
|
"count, never in a loop. Backed by ctx.navigation.setBadge. Identical on web (Player) and the Expo export.",
|
|
1071
1074
|
returnShape: {
|
|
@@ -3043,6 +3046,13 @@ const MANIFEST_SCHEMA = {
|
|
|
3043
3046
|
"Optional (default false). TRUE when the widget draws a strip of live figures (counts, totals, averages) about the records it shows, or is itself a strip of figures. The AppStudio app-builder reads this when it places an already-installed widget: it counts the widget as the page's figures, so it does not plan a second strip of figures beside it. Set it to match what the component actually renders; leave it false (or omit) when the widget draws no figures.",
|
|
3044
3047
|
default: false,
|
|
3045
3048
|
},
|
|
3049
|
+
badgeCounter: {
|
|
3050
|
+
type: "boolean",
|
|
3051
|
+
required: false,
|
|
3052
|
+
description:
|
|
3053
|
+
"Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
|
|
3054
|
+
default: false,
|
|
3055
|
+
},
|
|
3046
3056
|
events: {
|
|
3047
3057
|
type: "object[]",
|
|
3048
3058
|
required: true,
|
|
@@ -5301,7 +5311,14 @@ const CONTRACT = deepFreeze({
|
|
|
5301
5311
|
// flat `assets/` directory (`bundledAssets`, within `packageMaxBytes`).
|
|
5302
5312
|
// New `useBundledAsset(fileName)` resolves one through the new
|
|
5303
5313
|
// `widget.assets` map; linter rule `bundled-asset-missing` (error).
|
|
5304
|
-
|
|
5314
|
+
// 1.160.0: additive (sc-10829) — optional manifest field `badgeCounter`:
|
|
5315
|
+
// the entry exports `useBadgeCount(props)` (name in `badgeCounterExport`)
|
|
5316
|
+
// and the host runs it while the widget's page is closed, so the page
|
|
5317
|
+
// badge stays live; the host gives a counter a read-only context.
|
|
5318
|
+
// `usePageBadge(null)` now means "not known yet" and keeps the current
|
|
5319
|
+
// badge; `undefined` still clears it. Linter rules `badge-counter-export-missing`
|
|
5320
|
+
// (error) and `badge-counter-undeclared` (warning).
|
|
5321
|
+
version: "1.160.0",
|
|
5305
5322
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5306
5323
|
hooks: HOOKS,
|
|
5307
5324
|
primitives: PRIMITIVES,
|
|
@@ -5327,6 +5344,8 @@ const CONTRACT = deepFreeze({
|
|
|
5327
5344
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
5328
5345
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
5329
5346
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
5347
|
+
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|
|
5348
|
+
badgeCounterExport: "useBadgeCount",
|
|
5330
5349
|
bannedApis: BANNED_APIS,
|
|
5331
5350
|
vettedImports: VETTED_IMPORTS,
|
|
5332
5351
|
allowedBareImports: ALLOWED_BARE_IMPORTS,
|
package/dist/emoji-picker.js
CHANGED
|
@@ -10,5 +10,6 @@
|
|
|
10
10
|
import * as ReactNative from "react-native-web";
|
|
11
11
|
import { makeEmojiPicker } from "./emoji-picker-view.js";
|
|
12
12
|
import { Overlay } from "./overlay.js";
|
|
13
|
+
import { TextInput } from "./primitives.js";
|
|
13
14
|
|
|
14
|
-
export const EmojiPicker = makeEmojiPicker({ ...ReactNative, Overlay });
|
|
15
|
+
export const EmojiPicker = makeEmojiPicker({ ...ReactNative, TextInput, Overlay });
|
package/dist/hooks.js
CHANGED
|
@@ -621,8 +621,10 @@ export function useFilePreview(file) {
|
|
|
621
621
|
return { uri };
|
|
622
622
|
}
|
|
623
623
|
|
|
624
|
-
// A count the host can draw: a whole number, 0 for nothing to show
|
|
624
|
+
// A count the host can draw: a whole number, 0 for nothing to show, null
|
|
625
|
+
// only when the widget explicitly says it does not know yet.
|
|
625
626
|
function _badgeCount(count) {
|
|
627
|
+
if (count === null) return null;
|
|
626
628
|
return typeof count === "number" && Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
|
|
627
629
|
}
|
|
628
630
|
|
|
@@ -631,8 +633,9 @@ function _badgeCount(count) {
|
|
|
631
633
|
* on, such as a chat's unread messages. Returns `{ supported }`. The host sums
|
|
632
634
|
* every widget on the page and keeps the last count after the widget leaves
|
|
633
635
|
* the screen, until it reports again; 0 (or anything not a positive number)
|
|
634
|
-
* clears this widget's share.
|
|
635
|
-
*
|
|
636
|
+
* clears this widget's share. sc-10829: an explicit null means "not known
|
|
637
|
+
* yet" and keeps the current badge; undefined still clears. A host without
|
|
638
|
+
* page badges (the Builder canvas) ignores it.
|
|
636
639
|
*/
|
|
637
640
|
export function usePageBadge(count) {
|
|
638
641
|
const ctx = useWidgetContextOrThrow("usePageBadge");
|
|
@@ -640,7 +643,7 @@ export function usePageBadge(count) {
|
|
|
640
643
|
const setBadge = typeof nav.setBadge === "function" ? nav.setBadge : null;
|
|
641
644
|
const value = _badgeCount(count);
|
|
642
645
|
useEffect(() => {
|
|
643
|
-
if (setBadge) setBadge(value);
|
|
646
|
+
if (setBadge && value !== null) setBadge(value);
|
|
644
647
|
}, [setBadge, value]);
|
|
645
648
|
return { supported: Boolean(setBadge) };
|
|
646
649
|
}
|
package/dist/host.d.ts
CHANGED
|
@@ -460,6 +460,12 @@ export interface LoginDesign {
|
|
|
460
460
|
/** The sign-in screen's authored design, or null when nothing is configured. */
|
|
461
461
|
export function normaliseLoginDesign(raw: unknown): LoginDesign | null;
|
|
462
462
|
|
|
463
|
+
/** sc-10845 — the sign-in card's logo: the author's own, else the app logo. */
|
|
464
|
+
export function resolveLoginLogoUrl(
|
|
465
|
+
login: unknown,
|
|
466
|
+
appLogoUrl: string | null | undefined,
|
|
467
|
+
): string | null;
|
|
468
|
+
|
|
463
469
|
/** sc-7800 — true when a stored value is a `theme:<path>` colour binding. */
|
|
464
470
|
export function isThemeTokenRef(value: unknown): boolean;
|
|
465
471
|
|
package/dist/host.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -397,8 +397,23 @@ export interface WidgetManifest {
|
|
|
397
397
|
* /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, value ≤1 KB.
|
|
398
398
|
*/
|
|
399
399
|
translations?: Record<string, { en: string } & Record<string, string>>;
|
|
400
|
+
/**
|
|
401
|
+
* Optional (sc-10829, default false). True when every platform entry also
|
|
402
|
+
* exports `useBadgeCount(props)` (a `BadgeCounterHook`), which the host runs
|
|
403
|
+
* while the widget's page is closed so its menu badge stays live.
|
|
404
|
+
*/
|
|
405
|
+
badgeCounter?: boolean;
|
|
400
406
|
}
|
|
401
407
|
|
|
408
|
+
/**
|
|
409
|
+
* sc-10829 — the `useBadgeCount` export of a `badgeCounter: true` widget. Called
|
|
410
|
+
* with the instance's resolved props inside the widget's own context; returns
|
|
411
|
+
* the page badge count, or `null` while it is not known yet. It only reads (the
|
|
412
|
+
* host gives it read-only clients and no device, payment or AI access): no
|
|
413
|
+
* rendering, writes, navigation, events, toasts or page-chrome hooks.
|
|
414
|
+
*/
|
|
415
|
+
export type BadgeCounterHook = (props: Record<string, unknown>) => number | null;
|
|
416
|
+
|
|
402
417
|
/** A React Native shadow style object — one level of `ThemeTokens.elevation`. */
|
|
403
418
|
export interface ElevationLevel {
|
|
404
419
|
shadowColor?: string;
|
|
@@ -1821,17 +1836,19 @@ export function useNavigation(): {
|
|
|
1821
1836
|
*/
|
|
1822
1837
|
export function useRouteParams(): Record<string, unknown>;
|
|
1823
1838
|
|
|
1824
|
-
/**
|
|
1825
|
-
* sc-10268 — puts `count` on the menu entry or tab of the page this widget is
|
|
1826
|
-
* on. The host sums the page's widgets and keeps the last count after the
|
|
1827
|
-
* widget leaves the screen; 0 clears this widget's share.
|
|
1828
|
-
*/
|
|
1829
1839
|
/**
|
|
1830
1840
|
* sc-10268 — `{ uri }` an `<Image>` can show for a picked file before it is
|
|
1831
1841
|
* uploaded. Null for no file, or a host that cannot preview one.
|
|
1832
1842
|
*/
|
|
1833
1843
|
export function useFilePreview(file: unknown): { uri: string | null };
|
|
1834
1844
|
|
|
1845
|
+
/**
|
|
1846
|
+
* sc-10268 — puts `count` on the menu entry or tab of the page this widget is
|
|
1847
|
+
* on. The host sums the page's widgets and keeps the last count after the
|
|
1848
|
+
* widget leaves the screen; 0 or `undefined` clears this widget's share.
|
|
1849
|
+
* sc-10829: an explicit `null` means the count is not known yet and keeps the
|
|
1850
|
+
* current badge.
|
|
1851
|
+
*/
|
|
1835
1852
|
export function usePageBadge(count: number | null | undefined): {
|
|
1836
1853
|
supported: boolean;
|
|
1837
1854
|
};
|
|
@@ -3851,6 +3868,8 @@ export interface AiWidgetContract {
|
|
|
3851
3868
|
>
|
|
3852
3869
|
>;
|
|
3853
3870
|
readonly bundleExportContract: ReadonlyArray<ContractBundleShape>;
|
|
3871
|
+
/** sc-10829 — the export name of a `badgeCounter: true` entry's hook. */
|
|
3872
|
+
readonly badgeCounterExport: "useBadgeCount";
|
|
3854
3873
|
readonly bannedApis: ReadonlyArray<ContractBannedApi>;
|
|
3855
3874
|
readonly allowedBareImports: ReadonlyArray<string>;
|
|
3856
3875
|
/** Bare specifiers the web host provides by exact name; a widget bundle never inlines them. */
|
package/dist/linter.cjs
CHANGED
|
@@ -1992,6 +1992,54 @@ function lintEditability(manifest, files) {
|
|
|
1992
1992
|
return { ok: true, findings };
|
|
1993
1993
|
}
|
|
1994
1994
|
|
|
1995
|
+
// sc-10829 — `manifest.badgeCounter` promises the host a hook it can run
|
|
1996
|
+
// while the page is closed; each shipped entry must export it by name.
|
|
1997
|
+
function _exportedNameLine(code, name) {
|
|
1998
|
+
const declared = new RegExp(
|
|
1999
|
+
`\\bexport\\s+(?:(?:async\\s+)?function\\b\\s*\\*?\\s*|(?:const|let|var)\\s+)${name}\\b`,
|
|
2000
|
+
);
|
|
2001
|
+
const hit = declared.exec(code);
|
|
2002
|
+
if (hit) return code.slice(0, hit.index).split("\n").length;
|
|
2003
|
+
for (const clause of code.matchAll(/\bexport\s*\{([^}]*)\}/g)) {
|
|
2004
|
+
const names = clause[1].split(",").map((part) => part.trim().split(/\s+as\s+/).pop().trim());
|
|
2005
|
+
if (names.includes(name)) return code.slice(0, clause.index).split("\n").length;
|
|
2006
|
+
}
|
|
2007
|
+
return 0;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
function _badgeCounterRules(source, manifest) {
|
|
2011
|
+
if (!manifest || typeof manifest !== "object") return [];
|
|
2012
|
+
const name = CONTRACT.badgeCounterExport;
|
|
2013
|
+
const line = _exportedNameLine(_stripNonCode(source), name);
|
|
2014
|
+
if (manifest.badgeCounter === true && line === 0) {
|
|
2015
|
+
return [
|
|
2016
|
+
{
|
|
2017
|
+
rule: "badge-counter-export-missing",
|
|
2018
|
+
severity: "error",
|
|
2019
|
+
label:
|
|
2020
|
+
`manifest.badgeCounter is true but this entry does not export ${name}(props). ` +
|
|
2021
|
+
"Export the hook from every platform entry, or drop badgeCounter.",
|
|
2022
|
+
line: 0,
|
|
2023
|
+
snippet: "",
|
|
2024
|
+
},
|
|
2025
|
+
];
|
|
2026
|
+
}
|
|
2027
|
+
if (manifest.badgeCounter !== true && line > 0) {
|
|
2028
|
+
return [
|
|
2029
|
+
{
|
|
2030
|
+
rule: "badge-counter-undeclared",
|
|
2031
|
+
severity: "warning",
|
|
2032
|
+
label:
|
|
2033
|
+
`This entry exports ${name} but manifest.badgeCounter is not true, so the host never runs it. ` +
|
|
2034
|
+
"Declare badgeCounter: true, or remove the export.",
|
|
2035
|
+
line,
|
|
2036
|
+
snippet: source.split(/\r?\n/)[line - 1].trim().slice(0, 200),
|
|
2037
|
+
},
|
|
2038
|
+
];
|
|
2039
|
+
}
|
|
2040
|
+
return [];
|
|
2041
|
+
}
|
|
2042
|
+
|
|
1995
2043
|
function lintSource(source, options) {
|
|
1996
2044
|
if (typeof source !== "string") {
|
|
1997
2045
|
return {
|
|
@@ -2070,6 +2118,7 @@ function lintSource(source, options) {
|
|
|
2070
2118
|
})),
|
|
2071
2119
|
);
|
|
2072
2120
|
findings.push(..._manifestActionRules(options && options.manifest));
|
|
2121
|
+
findings.push(..._badgeCounterRules(source, options && options.manifest));
|
|
2073
2122
|
const hasErrors = findings.some((f) => f.severity !== "warning");
|
|
2074
2123
|
return { ok: !hasErrors, findings };
|
|
2075
2124
|
}
|
package/dist/linter.js
CHANGED
|
@@ -2055,6 +2055,54 @@ export function lintEditability(manifest, files) {
|
|
|
2055
2055
|
return { ok: true, findings };
|
|
2056
2056
|
}
|
|
2057
2057
|
|
|
2058
|
+
// sc-10829 — `manifest.badgeCounter` promises the host a hook it can run
|
|
2059
|
+
// while the page is closed; each shipped entry must export it by name.
|
|
2060
|
+
function _exportedNameLine(code, name) {
|
|
2061
|
+
const declared = new RegExp(
|
|
2062
|
+
`\\bexport\\s+(?:(?:async\\s+)?function\\b\\s*\\*?\\s*|(?:const|let|var)\\s+)${name}\\b`,
|
|
2063
|
+
);
|
|
2064
|
+
const hit = declared.exec(code);
|
|
2065
|
+
if (hit) return code.slice(0, hit.index).split("\n").length;
|
|
2066
|
+
for (const clause of code.matchAll(/\bexport\s*\{([^}]*)\}/g)) {
|
|
2067
|
+
const names = clause[1].split(",").map((part) => part.trim().split(/\s+as\s+/).pop().trim());
|
|
2068
|
+
if (names.includes(name)) return code.slice(0, clause.index).split("\n").length;
|
|
2069
|
+
}
|
|
2070
|
+
return 0;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
function _badgeCounterRules(source, manifest) {
|
|
2074
|
+
if (!manifest || typeof manifest !== "object") return [];
|
|
2075
|
+
const name = CONTRACT.badgeCounterExport;
|
|
2076
|
+
const line = _exportedNameLine(_stripNonCode(source), name);
|
|
2077
|
+
if (manifest.badgeCounter === true && line === 0) {
|
|
2078
|
+
return [
|
|
2079
|
+
{
|
|
2080
|
+
rule: "badge-counter-export-missing",
|
|
2081
|
+
severity: "error",
|
|
2082
|
+
label:
|
|
2083
|
+
`manifest.badgeCounter is true but this entry does not export ${name}(props). ` +
|
|
2084
|
+
"Export the hook from every platform entry, or drop badgeCounter.",
|
|
2085
|
+
line: 0,
|
|
2086
|
+
snippet: "",
|
|
2087
|
+
},
|
|
2088
|
+
];
|
|
2089
|
+
}
|
|
2090
|
+
if (manifest.badgeCounter !== true && line > 0) {
|
|
2091
|
+
return [
|
|
2092
|
+
{
|
|
2093
|
+
rule: "badge-counter-undeclared",
|
|
2094
|
+
severity: "warning",
|
|
2095
|
+
label:
|
|
2096
|
+
`This entry exports ${name} but manifest.badgeCounter is not true, so the host never runs it. ` +
|
|
2097
|
+
"Declare badgeCounter: true, or remove the export.",
|
|
2098
|
+
line,
|
|
2099
|
+
snippet: source.split(/\r?\n/)[line - 1].trim().slice(0, 200),
|
|
2100
|
+
},
|
|
2101
|
+
];
|
|
2102
|
+
}
|
|
2103
|
+
return [];
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2058
2106
|
export function lintSource(source, options) {
|
|
2059
2107
|
if (typeof source !== "string") {
|
|
2060
2108
|
return {
|
|
@@ -2142,6 +2190,7 @@ export function lintSource(source, options) {
|
|
|
2142
2190
|
);
|
|
2143
2191
|
// REQ-WIDGET-ACTION — structural validation of manifest-declared actions.
|
|
2144
2192
|
findings.push(..._manifestActionRules(options && options.manifest));
|
|
2193
|
+
findings.push(..._badgeCounterRules(source, options && options.manifest));
|
|
2145
2194
|
const hasErrors = findings.some((f) => f.severity !== "warning");
|
|
2146
2195
|
return { ok: !hasErrors, findings };
|
|
2147
2196
|
}
|
package/dist/manifest.cjs
CHANGED
|
@@ -325,6 +325,12 @@ function validateManifest(m) {
|
|
|
325
325
|
validateManifestTranslations(manifest.translations, manifest.id, errors);
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
pushIf(
|
|
329
|
+
errors,
|
|
330
|
+
manifest.badgeCounter === undefined || typeof manifest.badgeCounter === "boolean",
|
|
331
|
+
"manifest.badgeCounter must be a boolean when present",
|
|
332
|
+
);
|
|
333
|
+
|
|
328
334
|
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
329
335
|
}
|
|
330
336
|
|
package/dist/manifest.js
CHANGED
|
@@ -371,6 +371,12 @@ function validateManifest(m) {
|
|
|
371
371
|
validateManifestTranslations(manifest.translations, manifest.id, errors);
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
pushIf(
|
|
375
|
+
errors,
|
|
376
|
+
manifest.badgeCounter === undefined || typeof manifest.badgeCounter === "boolean",
|
|
377
|
+
"manifest.badgeCounter must be a boolean when present",
|
|
378
|
+
);
|
|
379
|
+
|
|
374
380
|
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
375
381
|
}
|
|
376
382
|
|
package/dist/markdown-input.js
CHANGED
|
@@ -6,5 +6,10 @@ import * as ReactNative from "react-native-web";
|
|
|
6
6
|
import { makeMarkdownInput } from "./markdown-input-view.js";
|
|
7
7
|
import { RichText } from "./richtext.js";
|
|
8
8
|
import { Overlay } from "./overlay.js";
|
|
9
|
+
import { TextInput } from "./primitives.js";
|
|
9
10
|
|
|
10
|
-
export const MarkdownInput = makeMarkdownInput(
|
|
11
|
+
export const MarkdownInput = makeMarkdownInput(
|
|
12
|
+
{ ...ReactNative, TextInput },
|
|
13
|
+
RichText,
|
|
14
|
+
Overlay,
|
|
15
|
+
);
|
package/dist/primitives.js
CHANGED
|
@@ -40,13 +40,14 @@
|
|
|
40
40
|
// static re-export tracing. Behaviour is identical to the old
|
|
41
41
|
// `react-native` alias path on the web.
|
|
42
42
|
import * as ReactNative from "react-native-web";
|
|
43
|
+
import { makeTextInput } from "./text-input-view.js";
|
|
43
44
|
|
|
44
45
|
export const Text = ReactNative.Text;
|
|
45
46
|
export const View = ReactNative.View;
|
|
46
47
|
export const Pressable = ReactNative.Pressable;
|
|
47
48
|
export const Image = ReactNative.Image;
|
|
48
49
|
export const ScrollView = ReactNative.ScrollView;
|
|
49
|
-
export const TextInput = ReactNative.TextInput;
|
|
50
|
+
export const TextInput = makeTextInput(ReactNative.TextInput);
|
|
50
51
|
// Additional cross-platform primitives the AI agent + handwritten
|
|
51
52
|
// widgets commonly reach for. All work in both react-native-web and
|
|
52
53
|
// native react-native without per-platform code.
|
package/dist/text-field.js
CHANGED
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
import * as ReactNative from "react-native-web";
|
|
5
5
|
import { makeTextField } from "./text-field-view.js";
|
|
6
|
+
import { TextInput } from "./primitives.js";
|
|
6
7
|
|
|
7
|
-
export const TextField = makeTextField(ReactNative);
|
|
8
|
+
export const TextField = makeTextField({ ...ReactNative, TextInput });
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The web `<TextInput>`: the host's input with the touch font floor. Native
|
|
2
|
+
// has no focus zoom, so ./primitives.native.js re-exports RN's input as is.
|
|
3
|
+
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { withTouchInputFontFloor } from "./touch-input-font-floor.js";
|
|
6
|
+
|
|
7
|
+
function hasCoarsePointer() {
|
|
8
|
+
return (
|
|
9
|
+
typeof window !== "undefined" &&
|
|
10
|
+
typeof window.matchMedia === "function" &&
|
|
11
|
+
window.matchMedia("(pointer: coarse)").matches
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {any} BaseTextInput react-native-web's `TextInput`.
|
|
17
|
+
* @returns a `TextInput` taking the same props and ref.
|
|
18
|
+
*/
|
|
19
|
+
export function makeTextInput(BaseTextInput) {
|
|
20
|
+
return React.forwardRef(function TextInput({ style, ...rest }, ref) {
|
|
21
|
+
return React.createElement(BaseTextInput, {
|
|
22
|
+
...rest,
|
|
23
|
+
ref,
|
|
24
|
+
style: withTouchInputFontFloor(style, hasCoarsePointer()),
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -68,8 +68,8 @@ function normaliseImageUrl(raw) {
|
|
|
68
68
|
if (typeof raw !== "string") return null;
|
|
69
69
|
const url = raw.trim();
|
|
70
70
|
if (!url) return null;
|
|
71
|
-
// A
|
|
72
|
-
if (
|
|
71
|
+
// A same-origin path; `/\host` and whitespace are foreign hosts to a browser.
|
|
72
|
+
if (/^\/(?![/\\])\S*$/.test(url)) return url;
|
|
73
73
|
return /^https?:\/\/\S+$/i.test(url) ? url : null;
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -135,4 +135,27 @@ function normaliseLoginDesign(raw) {
|
|
|
135
135
|
return configured ? design : null;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
/**
|
|
139
|
+
* sc-10845 — the sign-in card's logo: the author's own, else the app logo,
|
|
140
|
+
* else null. Hosts resolve `logoAssetId` into `logoUrlResolved` first. Not in
|
|
141
|
+
* `normaliseLoginDesign`: an app logo alone is not an authored design.
|
|
142
|
+
*
|
|
143
|
+
* @param {object|null|undefined} login - `themeConfig.login`.
|
|
144
|
+
* @param {string|null|undefined} appLogoUrl - the host's resolved app logo.
|
|
145
|
+
* @returns {string|null}
|
|
146
|
+
*/
|
|
147
|
+
function resolveLoginLogoUrl(login, appLogoUrl) {
|
|
148
|
+
const own =
|
|
149
|
+
login && typeof login === "object"
|
|
150
|
+
? normaliseImageUrl(login.logoUrlResolved) ||
|
|
151
|
+
normaliseImageUrl(login.logoUrl)
|
|
152
|
+
: null;
|
|
153
|
+
return own || normaliseImageUrl(appLogoUrl);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
LOGIN_TEXT_MAX_LENGTH,
|
|
158
|
+
normaliseThemeGradient,
|
|
159
|
+
normaliseLoginDesign,
|
|
160
|
+
resolveLoginLogoUrl,
|
|
161
|
+
};
|
package/dist/theme-background.js
CHANGED
|
@@ -60,8 +60,8 @@ function normaliseImageUrl(raw) {
|
|
|
60
60
|
if (typeof raw !== "string") return null;
|
|
61
61
|
const url = raw.trim();
|
|
62
62
|
if (!url) return null;
|
|
63
|
-
// A
|
|
64
|
-
if (
|
|
63
|
+
// A same-origin path; `/\host` and whitespace are foreign hosts to a browser.
|
|
64
|
+
if (/^\/(?![/\\])\S*$/.test(url)) return url;
|
|
65
65
|
return /^https?:\/\/\S+$/i.test(url) ? url : null;
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -126,3 +126,21 @@ export function normaliseLoginDesign(raw) {
|
|
|
126
126
|
const configured = Object.values(design).some((value) => value !== null);
|
|
127
127
|
return configured ? design : null;
|
|
128
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* sc-10845 — the sign-in card's logo: the author's own, else the app logo,
|
|
132
|
+
* else null. Hosts resolve `logoAssetId` into `logoUrlResolved` first. Not in
|
|
133
|
+
* `normaliseLoginDesign`: an app logo alone is not an authored design.
|
|
134
|
+
*
|
|
135
|
+
* @param {object|null|undefined} login - `themeConfig.login`.
|
|
136
|
+
* @param {string|null|undefined} appLogoUrl - the host's resolved app logo.
|
|
137
|
+
* @returns {string|null}
|
|
138
|
+
*/
|
|
139
|
+
export function resolveLoginLogoUrl(login, appLogoUrl) {
|
|
140
|
+
const own =
|
|
141
|
+
login && typeof login === "object"
|
|
142
|
+
? normaliseImageUrl(login.logoUrlResolved) ||
|
|
143
|
+
normaliseImageUrl(login.logoUrl)
|
|
144
|
+
: null;
|
|
145
|
+
return own || normaliseImageUrl(appLogoUrl);
|
|
146
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// iOS Safari zooms the page when a focused field's text is under 16px, and
|
|
2
|
+
// never zooms back out. Platform-free so it is testable without a DOM.
|
|
3
|
+
|
|
4
|
+
import { flattenStyle } from "./flatten-style.js";
|
|
5
|
+
|
|
6
|
+
export const MIN_TOUCH_INPUT_FONT_SIZE = 16;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `style` with its font size raised to the touch floor. An unset size is
|
|
10
|
+
* floored too: react-native-web's own input default is 14px.
|
|
11
|
+
*
|
|
12
|
+
* @param {object|Array|null|undefined|false} style
|
|
13
|
+
* @param {boolean} isTouch whether the primary pointer is a finger
|
|
14
|
+
* @returns {object|Array|null|undefined|false} `style` itself when unchanged.
|
|
15
|
+
*/
|
|
16
|
+
export function withTouchInputFontFloor(style, isTouch) {
|
|
17
|
+
if (!isTouch) return style;
|
|
18
|
+
const { fontSize } = flattenStyle(style);
|
|
19
|
+
if (typeof fontSize === "string") return style;
|
|
20
|
+
if (fontSize >= MIN_TOUCH_INPUT_FONT_SIZE) return style;
|
|
21
|
+
return [style, { fontSize: MIN_TOUCH_INPUT_FONT_SIZE }];
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.201.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|
package/src/contract.js
CHANGED
|
@@ -1065,7 +1065,10 @@ const HOOKS = [
|
|
|
1065
1065
|
description:
|
|
1066
1066
|
"Puts count on the menu entry or tab of the page this widget is on (a chat's unread messages, open tasks). " +
|
|
1067
1067
|
"The host sums every widget on the page and keeps the last count after the widget leaves the screen, until it " +
|
|
1068
|
-
"reports again; 0, or
|
|
1068
|
+
"reports again; 0, undefined, or any other value that is not a positive number, clears this widget's share. " +
|
|
1069
|
+
"An explicit null means the count is not known yet and keeps the current badge (pass null until the first load " +
|
|
1070
|
+
"settles, so the badge never flashes to 0). manifest.badgeCounter keeps the badge live while the page is closed; " +
|
|
1071
|
+
"see that field for who may declare it. Returns { supported }: false " +
|
|
1069
1072
|
"on a host without page badges (the Builder canvas), where it does nothing. Call it at render with the current " +
|
|
1070
1073
|
"count, never in a loop. Backed by ctx.navigation.setBadge. Identical on web (Player) and the Expo export.",
|
|
1071
1074
|
returnShape: {
|
|
@@ -3043,6 +3046,13 @@ const MANIFEST_SCHEMA = {
|
|
|
3043
3046
|
"Optional (default false). TRUE when the widget draws a strip of live figures (counts, totals, averages) about the records it shows, or is itself a strip of figures. The AppStudio app-builder reads this when it places an already-installed widget: it counts the widget as the page's figures, so it does not plan a second strip of figures beside it. Set it to match what the component actually renders; leave it false (or omit) when the widget draws no figures.",
|
|
3044
3047
|
default: false,
|
|
3045
3048
|
},
|
|
3049
|
+
badgeCounter: {
|
|
3050
|
+
type: "boolean",
|
|
3051
|
+
required: false,
|
|
3052
|
+
description:
|
|
3053
|
+
"Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
|
|
3054
|
+
default: false,
|
|
3055
|
+
},
|
|
3046
3056
|
events: {
|
|
3047
3057
|
type: "object[]",
|
|
3048
3058
|
required: true,
|
|
@@ -5301,7 +5311,14 @@ const CONTRACT = deepFreeze({
|
|
|
5301
5311
|
// flat `assets/` directory (`bundledAssets`, within `packageMaxBytes`).
|
|
5302
5312
|
// New `useBundledAsset(fileName)` resolves one through the new
|
|
5303
5313
|
// `widget.assets` map; linter rule `bundled-asset-missing` (error).
|
|
5304
|
-
|
|
5314
|
+
// 1.160.0: additive (sc-10829) — optional manifest field `badgeCounter`:
|
|
5315
|
+
// the entry exports `useBadgeCount(props)` (name in `badgeCounterExport`)
|
|
5316
|
+
// and the host runs it while the widget's page is closed, so the page
|
|
5317
|
+
// badge stays live; the host gives a counter a read-only context.
|
|
5318
|
+
// `usePageBadge(null)` now means "not known yet" and keeps the current
|
|
5319
|
+
// badge; `undefined` still clears it. Linter rules `badge-counter-export-missing`
|
|
5320
|
+
// (error) and `badge-counter-undeclared` (warning).
|
|
5321
|
+
version: "1.160.0",
|
|
5305
5322
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5306
5323
|
hooks: HOOKS,
|
|
5307
5324
|
primitives: PRIMITIVES,
|
|
@@ -5327,6 +5344,8 @@ const CONTRACT = deepFreeze({
|
|
|
5327
5344
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
5328
5345
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
5329
5346
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
5347
|
+
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|
|
5348
|
+
badgeCounterExport: "useBadgeCount",
|
|
5330
5349
|
bannedApis: BANNED_APIS,
|
|
5331
5350
|
vettedImports: VETTED_IMPORTS,
|
|
5332
5351
|
allowedBareImports: ALLOWED_BARE_IMPORTS,
|