@colixsystems/widget-sdk 0.86.0 → 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.
@@ -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.86.0",
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-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"
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"