@colixsystems/widget-sdk 0.85.1 → 0.87.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/dist/linter.js CHANGED
@@ -1013,6 +1013,90 @@ function _imagePercentHeightRules(source) {
1013
1013
  return findings;
1014
1014
  }
1015
1015
 
1016
+ // sc-4913 — a measured frame INCLUDES the element's own padding.
1017
+ //
1018
+ // `onLayout` reports the frame width, and padding sits inside that frame. A
1019
+ // widget that measures its padded root and then sizes fixed-pixel grid columns
1020
+ // to fill the measured number overflows its own content box by left+right
1021
+ // padding, so the last column wraps: a whole empty column of whitespace beside
1022
+ // the cards, on both hosts. Nothing else catches it — the widget renders and
1023
+ // every other gate passes.
1024
+ //
1025
+ // Fires only when the measurement feeds sizing arithmetic. A padded box
1026
+ // measured just to branch narrow/wide is off by one padding pair and correct
1027
+ // enough; `isNarrowWidth` is the sanctioned reader for that. A widget that DID
1028
+ // subtract its own padding matches too — no text scan can verify the
1029
+ // subtraction — and that is deliberate: the remediation (measure an unpadded
1030
+ // inner View) is correct for it as well, and retires the arithmetic entirely.
1031
+ const _JSX_TAG_RE = /<([A-Z][\w.]*)\b/g;
1032
+ const _ON_LAYOUT_ATTR_RE = /\bonLayout\s*=/;
1033
+ const _OWN_PADDING_RE = /\bpadding(?:Horizontal|Left|Right|Start|End)?\s*:/;
1034
+ const _WIDTH_HOLDER_RE =
1035
+ /const\s*\[\s*([\w$]+)\s*,\s*([\w$]+)\s*\]\s*=\s*(useState|useContainerWidth)\b/g;
1036
+ // A setter fed a layout width within this many chars of the call is measuring:
1037
+ // covers `setW(e.nativeEvent.layout.width)` and `setW(Math.round(…))` alike.
1038
+ const _LAYOUT_FEED_WINDOW = 80;
1039
+
1040
+ function _measuredWidthNames(code) {
1041
+ const names = [];
1042
+ _WIDTH_HOLDER_RE.lastIndex = 0;
1043
+ let held;
1044
+ while ((held = _WIDTH_HOLDER_RE.exec(code))) {
1045
+ const [, value, setter, hook] = held;
1046
+ if (hook === "useContainerWidth") {
1047
+ names.push(value);
1048
+ continue;
1049
+ }
1050
+ const fedByLayout = new RegExp(
1051
+ `\\b${setter}\\s*\\([\\s\\S]{0,${_LAYOUT_FEED_WINDOW}}?\\blayout\\.width\\b`,
1052
+ );
1053
+ if (fedByLayout.test(code)) names.push(value);
1054
+ }
1055
+ return names;
1056
+ }
1057
+
1058
+ function _sizesFromMeasuredWidth(code, names) {
1059
+ return names.some((name) =>
1060
+ new RegExp(`\\b${name}\\s*[-/*]|[-/*]\\s*\\b${name}\\b`).test(code),
1061
+ );
1062
+ }
1063
+
1064
+ function _measuredPaddingRules(source) {
1065
+ // Comments are blanked so this rule's own documented anti-pattern, and a
1066
+ // commented-out draft, never fire.
1067
+ const code = _stripNonCode(source, { keepStrings: true });
1068
+ const measured = _measuredWidthNames(code);
1069
+ if (!_sizesFromMeasuredWidth(code, measured)) return [];
1070
+ const sourceLines = source.split(/\r?\n/);
1071
+ const findings = [];
1072
+ _JSX_TAG_RE.lastIndex = 0;
1073
+ let tag;
1074
+ while ((tag = _JSX_TAG_RE.exec(code))) {
1075
+ const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
1076
+ const attrs = code.slice(tag.index, end);
1077
+ _JSX_TAG_RE.lastIndex = end;
1078
+ if (!_ON_LAYOUT_ATTR_RE.test(attrs)) continue;
1079
+ const padded = _OWN_PADDING_RE.exec(attrs);
1080
+ if (!padded) continue;
1081
+ // Report the padding, not the tag: on a multi-line open tag the style line
1082
+ // is the actionable one.
1083
+ const line = code
1084
+ .slice(0, tag.index + padded.index)
1085
+ .split(/\r?\n/).length;
1086
+ findings.push({
1087
+ rule: "measured-width-ignores-padding",
1088
+ severity: "warning",
1089
+ label:
1090
+ `<${tag[1]}> has onLayout AND its own padding: the measured width ` +
1091
+ `INCLUDES that padding, so cells sized from it overflow and the last ` +
1092
+ `wraps, leaving an empty column. Measure an unpadded inner View.`,
1093
+ line,
1094
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
1095
+ });
1096
+ }
1097
+ return findings;
1098
+ }
1099
+
1016
1100
  /**
1017
1101
  * Narrow a split-impl widget's manifest to the platform a single bundle file
1018
1102
  * ships to, so `import-platform-mismatch` lints each file against what it
@@ -1083,6 +1167,9 @@ export function lintSource(source, options) {
1083
1167
  findings.push(..._reactInScopeRules(source));
1084
1168
  // sc-3493 — soft warning: percentage height on an <Image> collapses to 0.
1085
1169
  findings.push(..._imagePercentHeightRules(source));
1170
+ // sc-4913 — soft warning: a measured width that includes the widget's own
1171
+ // padding wraps the last grid column into an empty one.
1172
+ findings.push(..._measuredPaddingRules(source));
1086
1173
  // sc-4650 — soft warning: every payment refusal reported as "try again".
1087
1174
  findings.push(..._paymentCurrencyRules(source));
1088
1175
  findings.push(..._hardcodedCurrencyLabelRules(source));
@@ -98,6 +98,65 @@ function normaliseThemeComponents(raw) {
98
98
  return out;
99
99
  }
100
100
 
101
+
102
+ // REQ-THEME-ELEMENT: one value out of the per-widget map. Unlike a component
103
+ // token, there is no declared `type` to coerce against -- the key space is the
104
+ // workspace's widget catalog, not the contract -- so validation here is
105
+ // STRUCTURAL. The authoritative type is the widget's own styleSchema, which the
106
+ // Studio honours by only ever offering fields that widget declares.
107
+ function coerceWidgetStyleValue(value) {
108
+ if (typeof value === "string") {
109
+ const trimmed = value.trim();
110
+ // Long enough for a hex, an enum value or a font name; short enough that a
111
+ // hand-edited theme_config cannot smuggle a payload into every widget.
112
+ return trimmed && trimmed.length <= 64 ? trimmed : undefined;
113
+ }
114
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
115
+ if (typeof value === "boolean") return value;
116
+ if (isPlainObject(value)) return normaliseComponentGradient(value) || undefined;
117
+ return undefined;
118
+ }
119
+
120
+ /**
121
+ * REQ-THEME-ELEMENT: validate `themeConfig.widgetStyles` -- app-wide style values
122
+ * keyed by WIDGET MANIFEST ID, then by that widget's own styleSchema field name.
123
+ *
124
+ * `normaliseThemeComponents` can iterate the CONTRACT because the scope vocabulary
125
+ * is closed. This map's key space is the workspace's widget catalog, so it must
126
+ * iterate the INPUT instead -- which is exactly why the bounds below exist:
127
+ * `theme_config` is an unbounded bag that an unauthenticated GET returns on every
128
+ * cold Player start and that the compiler bakes into the native export.
129
+ *
130
+ * Drops rather than throws, like every other theme validator: a hand-edited blob
131
+ * must degrade to less styling, never to a broken render.
132
+ */
133
+ function normaliseWidgetStyles(raw) {
134
+ if (!isPlainObject(raw)) return {};
135
+ const { maxWidgets, maxFieldsPerWidget } = CONTRACT.themeWidgetStyles;
136
+ const idPattern = CONTRACT.manifestSchema.id.pattern;
137
+ const out = {};
138
+ let widgets = 0;
139
+ for (const [manifestId, fields] of Object.entries(raw)) {
140
+ if (widgets >= maxWidgets) break;
141
+ if (!idPattern.test(manifestId) || !isPlainObject(fields)) continue;
142
+ const kept = {};
143
+ let count = 0;
144
+ for (const [field, value] of Object.entries(fields)) {
145
+ if (count >= maxFieldsPerWidget) break;
146
+ const coerced = coerceWidgetStyleValue(value);
147
+ if (coerced === undefined) continue;
148
+ kept[field] = coerced;
149
+ count += 1;
150
+ }
151
+ // An emptied entry is dropped rather than persisted as `{}`, mirroring
152
+ // normaliseThemeComponents.
153
+ if (count === 0) continue;
154
+ out[manifestId] = kept;
155
+ widgets += 1;
156
+ }
157
+ return out;
158
+ }
159
+
101
160
  /**
102
161
  * The per-component style fields that apply to one widget, keyed by the
103
162
  * `styleSchema` field name the widget actually reads. A widget may sit in more
@@ -110,18 +169,50 @@ function normaliseThemeComponents(raw) {
110
169
  *
111
170
  * @returns {Record<string, string|number>|null} null when nothing applies.
112
171
  */
113
- function componentStyleFor(manifestId, components) {
172
+ function componentStyleFor(manifestId, components, styleSchema, widgetStyles) {
114
173
  if (typeof manifestId !== "string") return null;
115
174
  const validated = normaliseThemeComponents(components);
116
175
  const out = {};
117
176
  for (const [scope, definition] of Object.entries(CONTRACT.themeComponents)) {
118
177
  const tokens = validated[scope];
178
+ if (!tokens) continue;
179
+ // REQ-THEME-WIDGET: two bindings, in this order.
180
+ //
181
+ // `universalFields` binds by FIELD NAME and reaches every widget: a
182
+ // `cardBackground` can only mean a card surface, so a Mason-generated or
183
+ // marketplace widget that paints one follows "Cards" without appearing in
184
+ // any allowlist. A widget that does not read the field simply ignores an
185
+ // unused style prop, so this changes nothing for the ones that don't.
186
+ //
187
+ // `targets` stays the allowlist for the BARE names (`background`,
188
+ // `textColor`, `color`, `fontSize`, `shadow`), which are shared across
189
+ // scopes -- `appstudio.image` also reads `background`, and the button
190
+ // scope must not leak into it.
191
+ const declared = isPlainObject(styleSchema) ? styleSchema : null;
192
+ for (const [token, field] of Object.entries(definition.universalFields || {})) {
193
+ // Only onto a widget that DECLARES the field. Without this gate the token
194
+ // would land on every widget as an unused style prop -- harmless for one
195
+ // that reads named fields, but a widget spreading `props.style` onto a
196
+ // View would get an unknown style key. Built-ins thread no styleSchema and
197
+ // are covered by `targets` below, so their behaviour is unchanged.
198
+ if (!declared || declared[field] === undefined) continue;
199
+ if (tokens[token] !== undefined) out[field] = tokens[token];
200
+ }
119
201
  const fields = definition.targets[manifestId];
120
- if (!tokens || !fields) continue;
202
+ if (!fields) continue;
121
203
  for (const [token, field] of Object.entries(fields)) {
122
204
  if (tokens[token] !== undefined) out[field] = tokens[token];
123
205
  }
124
206
  }
207
+ // REQ-THEME-ELEMENT: the per-WIDGET-TYPE values land last of the theme layers,
208
+ // so they beat both scope bindings -- naming one widget is strictly more
209
+ // specific than restyling a whole scope. The author's per-instance props.style
210
+ // is still spread after all of this by the caller.
211
+ //
212
+ // No styleSchema gate here, unlike universalFields above: the key IS this
213
+ // widget's manifest id, so the id match is the authorisation.
214
+ const perWidget = normaliseWidgetStyles(widgetStyles)[manifestId];
215
+ if (perWidget) Object.assign(out, perWidget);
125
216
  return Object.keys(out).length > 0 ? out : null;
126
217
  }
127
218
 
@@ -141,12 +232,17 @@ function componentStyleFor(manifestId, components) {
141
232
  * @param {object} props — the widget's resolved props (post-`resolveProps`).
142
233
  * @returns {object} props, with `style` folded when the theme applies.
143
234
  */
144
- function applyThemeComponentStyle(manifestId, theme, props) {
145
- const themed = componentStyleFor(manifestId, theme && theme.components);
235
+ function applyThemeComponentStyle(manifestId, theme, props, styleSchema) {
236
+ const themed = componentStyleFor(
237
+ manifestId,
238
+ theme && theme.components,
239
+ styleSchema,
240
+ theme && theme.widgetStyles,
241
+ );
146
242
  if (!themed) return props;
147
243
  const base = isPlainObject(props) ? props : {};
148
244
  const authored = isPlainObject(base.style) ? base.style : null;
149
245
  return { ...base, style: { ...themed, ...authored } };
150
246
  }
151
247
 
152
- module.exports = { normaliseThemeComponents, applyThemeComponentStyle };
248
+ module.exports = { normaliseThemeComponents, normaliseWidgetStyles, applyThemeComponentStyle };
@@ -90,6 +90,65 @@ export function normaliseThemeComponents(raw) {
90
90
  return out;
91
91
  }
92
92
 
93
+
94
+ // REQ-THEME-ELEMENT: one value out of the per-widget map. Unlike a component
95
+ // token, there is no declared `type` to coerce against -- the key space is the
96
+ // workspace's widget catalog, not the contract -- so validation here is
97
+ // STRUCTURAL. The authoritative type is the widget's own styleSchema, which the
98
+ // Studio honours by only ever offering fields that widget declares.
99
+ function coerceWidgetStyleValue(value) {
100
+ if (typeof value === "string") {
101
+ const trimmed = value.trim();
102
+ // Long enough for a hex, an enum value or a font name; short enough that a
103
+ // hand-edited theme_config cannot smuggle a payload into every widget.
104
+ return trimmed && trimmed.length <= 64 ? trimmed : undefined;
105
+ }
106
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
107
+ if (typeof value === "boolean") return value;
108
+ if (isPlainObject(value)) return normaliseComponentGradient(value) || undefined;
109
+ return undefined;
110
+ }
111
+
112
+ /**
113
+ * REQ-THEME-ELEMENT: validate `themeConfig.widgetStyles` -- app-wide style values
114
+ * keyed by WIDGET MANIFEST ID, then by that widget's own styleSchema field name.
115
+ *
116
+ * `normaliseThemeComponents` can iterate the CONTRACT because the scope vocabulary
117
+ * is closed. This map's key space is the workspace's widget catalog, so it must
118
+ * iterate the INPUT instead -- which is exactly why the bounds below exist:
119
+ * `theme_config` is an unbounded bag that an unauthenticated GET returns on every
120
+ * cold Player start and that the compiler bakes into the native export.
121
+ *
122
+ * Drops rather than throws, like every other theme validator: a hand-edited blob
123
+ * must degrade to less styling, never to a broken render.
124
+ */
125
+ export function normaliseWidgetStyles(raw) {
126
+ if (!isPlainObject(raw)) return {};
127
+ const { maxWidgets, maxFieldsPerWidget } = CONTRACT.themeWidgetStyles;
128
+ const idPattern = CONTRACT.manifestSchema.id.pattern;
129
+ const out = {};
130
+ let widgets = 0;
131
+ for (const [manifestId, fields] of Object.entries(raw)) {
132
+ if (widgets >= maxWidgets) break;
133
+ if (!idPattern.test(manifestId) || !isPlainObject(fields)) continue;
134
+ const kept = {};
135
+ let count = 0;
136
+ for (const [field, value] of Object.entries(fields)) {
137
+ if (count >= maxFieldsPerWidget) break;
138
+ const coerced = coerceWidgetStyleValue(value);
139
+ if (coerced === undefined) continue;
140
+ kept[field] = coerced;
141
+ count += 1;
142
+ }
143
+ // An emptied entry is dropped rather than persisted as `{}`, mirroring
144
+ // normaliseThemeComponents.
145
+ if (count === 0) continue;
146
+ out[manifestId] = kept;
147
+ widgets += 1;
148
+ }
149
+ return out;
150
+ }
151
+
93
152
  /**
94
153
  * The per-component style fields that apply to one widget, keyed by the
95
154
  * `styleSchema` field name the widget actually reads. A widget may sit in more
@@ -102,18 +161,50 @@ export function normaliseThemeComponents(raw) {
102
161
  *
103
162
  * @returns {Record<string, string|number>|null} null when nothing applies.
104
163
  */
105
- function componentStyleFor(manifestId, components) {
164
+ function componentStyleFor(manifestId, components, styleSchema, widgetStyles) {
106
165
  if (typeof manifestId !== "string") return null;
107
166
  const validated = normaliseThemeComponents(components);
108
167
  const out = {};
109
168
  for (const [scope, definition] of Object.entries(CONTRACT.themeComponents)) {
110
169
  const tokens = validated[scope];
170
+ if (!tokens) continue;
171
+ // REQ-THEME-WIDGET: two bindings, in this order.
172
+ //
173
+ // `universalFields` binds by FIELD NAME and reaches every widget: a
174
+ // `cardBackground` can only mean a card surface, so a Mason-generated or
175
+ // marketplace widget that paints one follows "Cards" without appearing in
176
+ // any allowlist. A widget that does not read the field simply ignores an
177
+ // unused style prop, so this changes nothing for the ones that don't.
178
+ //
179
+ // `targets` stays the allowlist for the BARE names (`background`,
180
+ // `textColor`, `color`, `fontSize`, `shadow`), which are shared across
181
+ // scopes -- `appstudio.image` also reads `background`, and the button
182
+ // scope must not leak into it.
183
+ const declared = isPlainObject(styleSchema) ? styleSchema : null;
184
+ for (const [token, field] of Object.entries(definition.universalFields || {})) {
185
+ // Only onto a widget that DECLARES the field. Without this gate the token
186
+ // would land on every widget as an unused style prop -- harmless for one
187
+ // that reads named fields, but a widget spreading `props.style` onto a
188
+ // View would get an unknown style key. Built-ins thread no styleSchema and
189
+ // are covered by `targets` below, so their behaviour is unchanged.
190
+ if (!declared || declared[field] === undefined) continue;
191
+ if (tokens[token] !== undefined) out[field] = tokens[token];
192
+ }
111
193
  const fields = definition.targets[manifestId];
112
- if (!tokens || !fields) continue;
194
+ if (!fields) continue;
113
195
  for (const [token, field] of Object.entries(fields)) {
114
196
  if (tokens[token] !== undefined) out[field] = tokens[token];
115
197
  }
116
198
  }
199
+ // REQ-THEME-ELEMENT: the per-WIDGET-TYPE values land last of the theme layers,
200
+ // so they beat both scope bindings -- naming one widget is strictly more
201
+ // specific than restyling a whole scope. The author's per-instance props.style
202
+ // is still spread after all of this by the caller.
203
+ //
204
+ // No styleSchema gate here, unlike universalFields above: the key IS this
205
+ // widget's manifest id, so the id match is the authorisation.
206
+ const perWidget = normaliseWidgetStyles(widgetStyles)[manifestId];
207
+ if (perWidget) Object.assign(out, perWidget);
117
208
  return Object.keys(out).length > 0 ? out : null;
118
209
  }
119
210
 
@@ -133,8 +224,13 @@ function componentStyleFor(manifestId, components) {
133
224
  * @param {object} props — the widget's resolved props (post-`resolveProps`).
134
225
  * @returns {object} props, with `style` folded when the theme applies.
135
226
  */
136
- export function applyThemeComponentStyle(manifestId, theme, props) {
137
- const themed = componentStyleFor(manifestId, theme && theme.components);
227
+ export function applyThemeComponentStyle(manifestId, theme, props, styleSchema) {
228
+ const themed = componentStyleFor(
229
+ manifestId,
230
+ theme && theme.components,
231
+ styleSchema,
232
+ theme && theme.widgetStyles,
233
+ );
138
234
  if (!themed) return props;
139
235
  const base = isPlainObject(props) ? props : {};
140
236
  const authored = isPlainObject(base.style) ? base.style : null;
@@ -0,0 +1,193 @@
1
+ // REQ-WSDK-PLATFORM §6 — the HOST half of `useToast()`.
2
+ //
3
+ // `toast.js` / `toast.native.js` are the widget half: a widget calls
4
+ // `showToast({ kind, message })` and the SDK forwards it to
5
+ // `ctx.toast.showToast`. This module is what the host puts behind that slot —
6
+ // the queue, the auto-dismiss timing, and the themed values the notification
7
+ // is painted with.
8
+ //
9
+ // It is deliberately presentation-free. The web Player paints the stack with
10
+ // DOM and the Expo export with React Native primitives; sharing everything
11
+ // EXCEPT that JSX is what stops the two hosts from drifting (CLAUDE.md §8).
12
+
13
+ export const TOAST_DEFAULTS = Object.freeze({
14
+ durationMs: 4000,
15
+ maxVisible: 3,
16
+ });
17
+
18
+ const KINDS = Object.freeze(["success", "error", "warning", "info"]);
19
+
20
+ // `error` is the widget-facing kind; `danger` is the theme's colour role.
21
+ const KIND_COLOR_ROLE = Object.freeze({
22
+ success: "success",
23
+ error: "danger",
24
+ warning: "warning",
25
+ info: "info",
26
+ });
27
+
28
+ export function normalizeToastKind(kind) {
29
+ return KINDS.indexOf(kind) === -1 ? "info" : kind;
30
+ }
31
+
32
+ function _elevationToBoxShadow(level) {
33
+ if (!level || typeof level !== "object") return "none";
34
+ const offset = level.shadowOffset || {};
35
+ const x = Number(offset.width) || 0;
36
+ const y = Number(offset.height) || 0;
37
+ const blur = Number(level.shadowRadius) || 0;
38
+ const opacity = Number(level.shadowOpacity) || 0;
39
+ if (!blur && !y && !x) return "none";
40
+ return `${x}px ${y}px ${blur * 2}px rgba(0, 0, 0, ${opacity})`;
41
+ }
42
+
43
+ /**
44
+ * Resolve the values a host paints one toast with, from the workspace theme.
45
+ *
46
+ * Returns primitives only — each host maps them into its own style system, so
47
+ * the same theme yields the same toast on web and native. `elevation` is the
48
+ * React Native style object; `boxShadow` is the CSS string derived from it.
49
+ *
50
+ * @param {object} theme — the resolved workspace theme (`useTheme()` shape).
51
+ * @param {string} kind — `success` | `error` | `warning` | `info`.
52
+ */
53
+ export function resolveToastTokens(theme, kind) {
54
+ const safeKind = normalizeToastKind(kind);
55
+ const colors = (theme && theme.colors) || {};
56
+ const radii = (theme && theme.radii) || {};
57
+ const spacing = (theme && theme.spacing) || {};
58
+ const typography = (theme && theme.typography) || {};
59
+ const sizes = typography.sizes || {};
60
+ const elevation = ((theme && theme.elevation) || {}).lg || {};
61
+
62
+ return {
63
+ kind: safeKind,
64
+ // The kind reads as an accent stripe against the neutral surface rather
65
+ // than a tinted background — contrast then holds in every theme without
66
+ // per-kind foreground maths.
67
+ accent: colors[KIND_COLOR_ROLE[safeKind]] || colors.info || "#2563eb",
68
+ surface: colors.surface || "#ffffff",
69
+ text: colors.onSurface || "#111827",
70
+ border: colors.border || "transparent",
71
+ radius: radii.md || 8,
72
+ padding: spacing.md || 16,
73
+ gap: spacing.sm || 8,
74
+ stackGap: spacing.sm || 8,
75
+ accentBarWidth: spacing.xs || 4,
76
+ fontFamily: typography.fontFamily,
77
+ fontSize: sizes.sm || 14,
78
+ elevation,
79
+ boxShadow: _elevationToBoxShadow(elevation),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Create the host-side toast queue.
85
+ *
86
+ * Presentation-free and framework-free: a host subscribes, renders whatever the
87
+ * listener hands it, and calls `show` from the `ctx.toast.showToast` slot. The
88
+ * timers are injectable so both hosts and the tests drive identical behaviour.
89
+ *
90
+ * @param {object} [opts]
91
+ * @param {number} [opts.durationMs] — auto-dismiss delay per toast.
92
+ * @param {number} [opts.maxVisible] — oldest toasts drop past this depth.
93
+ * @param {Function} [opts.setTimer] / [opts.clearTimer] — timer injection.
94
+ * @returns {{ show: Function, dismiss: Function, subscribe: Function,
95
+ * getToasts: Function, destroy: Function }}
96
+ */
97
+ export function createToastController(opts) {
98
+ const options = opts || {};
99
+ const durationMs =
100
+ Number(options.durationMs) > 0
101
+ ? Number(options.durationMs)
102
+ : TOAST_DEFAULTS.durationMs;
103
+ const maxVisible =
104
+ Number(options.maxVisible) > 0
105
+ ? Number(options.maxVisible)
106
+ : TOAST_DEFAULTS.maxVisible;
107
+ const setTimer =
108
+ typeof options.setTimer === "function" ? options.setTimer : setTimeout;
109
+ const clearTimer =
110
+ typeof options.clearTimer === "function" ? options.clearTimer : clearTimeout;
111
+
112
+ let toasts = [];
113
+ let seq = 0;
114
+ let destroyed = false;
115
+ const timers = new Map();
116
+ const listeners = new Set();
117
+
118
+ function emit() {
119
+ const snapshot = toasts;
120
+ for (const listener of Array.from(listeners)) {
121
+ try {
122
+ listener(snapshot);
123
+ } catch {
124
+ // A throwing host listener must not take the queue (or the widget
125
+ // that raised the toast) down with it.
126
+ }
127
+ }
128
+ }
129
+
130
+ function clearTimerFor(id) {
131
+ const handle = timers.get(id);
132
+ if (handle !== undefined) {
133
+ clearTimer(handle);
134
+ timers.delete(id);
135
+ }
136
+ }
137
+
138
+ function dismiss(id) {
139
+ if (destroyed) return;
140
+ const next = toasts.filter((t) => t.id !== id);
141
+ if (next.length === toasts.length) return;
142
+ clearTimerFor(id);
143
+ toasts = next;
144
+ emit();
145
+ }
146
+
147
+ function show(payload) {
148
+ if (destroyed) return null;
149
+ const opts_ = payload && typeof payload === "object" ? payload : {};
150
+ const message = typeof opts_.message === "string" ? opts_.message : "";
151
+ // Same guard the SDK hook applies — an empty toast is not a notification.
152
+ if (!message) return null;
153
+
154
+ seq += 1;
155
+ const toast = {
156
+ id: `toast-${seq}`,
157
+ kind: normalizeToastKind(opts_.kind),
158
+ message,
159
+ };
160
+ // Newest first: the host renders the stack top-down, so a fresh
161
+ // confirmation is never pushed off-screen by older ones.
162
+ toasts = [toast, ...toasts].slice(0, maxVisible);
163
+ for (const dropped of timers.keys()) {
164
+ if (!toasts.some((t) => t.id === dropped)) clearTimerFor(dropped);
165
+ }
166
+ timers.set(
167
+ toast.id,
168
+ setTimer(() => {
169
+ timers.delete(toast.id);
170
+ dismiss(toast.id);
171
+ }, durationMs),
172
+ );
173
+ emit();
174
+ return toast.id;
175
+ }
176
+
177
+ return {
178
+ show,
179
+ dismiss,
180
+ getToasts: () => toasts,
181
+ subscribe(listener) {
182
+ if (typeof listener !== "function" || destroyed) return () => {};
183
+ listeners.add(listener);
184
+ return () => listeners.delete(listener);
185
+ },
186
+ destroy() {
187
+ destroyed = true;
188
+ for (const id of Array.from(timers.keys())) clearTimerFor(id);
189
+ listeners.clear();
190
+ toasts = [];
191
+ },
192
+ };
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.85.1",
3
+ "version": "0.87.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "node scripts/build.js",
51
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
51
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"