@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/README.md +58 -7
- package/dist/contract.cjs +227 -13
- package/dist/contract.js +227 -13
- package/dist/host.d.ts +90 -1
- package/dist/host.js +19 -0
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/linter.cjs +69 -0
- package/dist/linter.js +87 -0
- package/dist/theme-components.cjs +101 -5
- package/dist/theme-components.js +100 -4
- package/dist/toast-host.js +193 -0
- package/package.json +2 -2
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 (!
|
|
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(
|
|
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 };
|
package/dist/theme-components.js
CHANGED
|
@@ -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 (!
|
|
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(
|
|
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.
|
|
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"
|