@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 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.199.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
86
+ `v0.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 anything not a positive number, clears this widget's share. Returns { supported }: false " +
1068
+ "reports again; 0, undefined, or any other value that is not a positive number, clears this widget's share. " +
1069
+ "An explicit null means the count is not known yet and keeps the current badge (pass null until the first load " +
1070
+ "settles, so the badge never flashes to 0). manifest.badgeCounter keeps the badge live while the page is closed; " +
1071
+ "see that field for who may declare it. Returns { supported }: false " +
1069
1072
  "on a host without page badges (the Builder canvas), where it does nothing. Call it at render with the current " +
1070
1073
  "count, never in a loop. Backed by ctx.navigation.setBadge. Identical on web (Player) and the Expo export.",
1071
1074
  returnShape: {
@@ -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
- version: "1.159.0",
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 anything not a positive number, clears this widget's share. Returns { supported }: false " +
1068
+ "reports again; 0, undefined, or any other value that is not a positive number, clears this widget's share. " +
1069
+ "An explicit null means the count is not known yet and keeps the current badge (pass null until the first load " +
1070
+ "settles, so the badge never flashes to 0). manifest.badgeCounter keeps the badge live while the page is closed; " +
1071
+ "see that field for who may declare it. Returns { supported }: false " +
1069
1072
  "on a host without page badges (the Builder canvas), where it does nothing. Call it at render with the current " +
1070
1073
  "count, never in a loop. Backed by ctx.navigation.setBadge. Identical on web (Player) and the Expo export.",
1071
1074
  returnShape: {
@@ -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
- version: "1.159.0",
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,
@@ -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. A host without page badges (the Builder canvas)
635
- * ignores it.
636
+ * clears this widget's share. sc-10829: an explicit null means "not known
637
+ * yet" and keeps the current badge; undefined still clears. A host without
638
+ * page badges (the Builder canvas) ignores it.
636
639
  */
637
640
  export function usePageBadge(count) {
638
641
  const ctx = useWidgetContextOrThrow("usePageBadge");
@@ -640,7 +643,7 @@ export function usePageBadge(count) {
640
643
  const setBadge = typeof nav.setBadge === "function" ? nav.setBadge : null;
641
644
  const value = _badgeCount(count);
642
645
  useEffect(() => {
643
- if (setBadge) setBadge(value);
646
+ if (setBadge && value !== null) setBadge(value);
644
647
  }, [setBadge, value]);
645
648
  return { supported: Boolean(setBadge) };
646
649
  }
package/dist/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
@@ -128,6 +128,7 @@ export {
128
128
  LOGIN_TEXT_MAX_LENGTH,
129
129
  normaliseThemeGradient,
130
130
  normaliseLoginDesign,
131
+ resolveLoginLogoUrl,
131
132
  } from "./theme-background.js";
132
133
 
133
134
  // sc-8448: one DATETIME local-clock conversion for the picker and the Studio.
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
 
@@ -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(ReactNative, RichText, Overlay);
11
+ export const MarkdownInput = makeMarkdownInput(
12
+ { ...ReactNative, TextInput },
13
+ RichText,
14
+ Overlay,
15
+ );
@@ -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.
@@ -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 workspace upload is served same-origin as an absolute path.
72
- if (url.startsWith("/") && !url.startsWith("//")) return url;
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
- module.exports = { LOGIN_TEXT_MAX_LENGTH, normaliseThemeGradient, normaliseLoginDesign };
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
+ };
@@ -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 workspace upload is served same-origin as an absolute path.
64
- if (url.startsWith("/") && !url.startsWith("//")) return url;
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.199.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 anything not a positive number, clears this widget's share. Returns { supported }: false " +
1068
+ "reports again; 0, undefined, or any other value that is not a positive number, clears this widget's share. " +
1069
+ "An explicit null means the count is not known yet and keeps the current badge (pass null until the first load " +
1070
+ "settles, so the badge never flashes to 0). manifest.badgeCounter keeps the badge live while the page is closed; " +
1071
+ "see that field for who may declare it. Returns { supported }: false " +
1069
1072
  "on a host without page badges (the Builder canvas), where it does nothing. Call it at render with the current " +
1070
1073
  "count, never in a loop. Backed by ctx.navigation.setBadge. Identical on web (Player) and the Expo export.",
1071
1074
  returnShape: {
@@ -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
- version: "1.159.0",
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,